Skip to main content

allama_http/
config.rs

1//! Checking a provider builder's configuration: base URLs, secrets, and
2//! the headers a provider sends with every request, and how a URL shows in
3//! `Debug` output.
4//!
5//! Every bundled HTTP provider's `build()` (and `from_env()`) runs these
6//! checks, so a malformed value fails there, with an
7//! [authored](allama::Error::authored) [`allama::Error::Configuration`]
8//! that names the setting or the environment variable, instead of failing
9//! every request later as an invalid request. The messages never quote the
10//! value: a base URL can carry a password, and a key is a secret. Provider
11//! crates of your own use them the same way:
12//!
13//! ```
14//! use allama_http::config;
15//!
16//! // A key read from a Kubernetes secret file keeps its trailing newline.
17//! assert_eq!(config::secret("ACME_API_KEY", "sk-acme\n")?, "sk-acme");
18//! assert_eq!(
19//!     config::base_url("ACME_BASE_URL", " https://gw.example/v1/ ")?,
20//!     "https://gw.example/v1"
21//! );
22//! let err = config::base_url("ACME_BASE_URL", "localhost:8080/v1").unwrap_err();
23//! assert!(matches!(err, allama::Error::Configuration { .. }));
24//! assert!(err.to_string().contains("ACME_BASE_URL"));
25//! # Ok::<(), allama::Error>(())
26//! ```
27//!
28//! Plain `http://` stays allowed: a local or LAN server (Ollama, vLLM) is a
29//! common target. Only send keys over it to hosts you trust on that network.
30
31use std::borrow::Cow;
32
33use crate::{CredentialSlot, DEFAULT_USER_AGENT, Headers, is_credential_header};
34
35/// Check and normalize a base URL from a builder setting or environment
36/// variable named `setting`: surrounding whitespace and trailing slashes
37/// are removed, and the URL must be an absolute `http` or `https` URL with
38/// a host, no whitespace or control characters, and no query or fragment
39/// (requests append their path to it).
40///
41/// A user name and password in the URL (`https://user:pass@gateway`) are
42/// kept, since some gateways take them; [`redact_url`] hides them from
43/// `Debug` output.
44///
45/// # Errors
46///
47/// An authored [`allama::Error::Configuration`] naming `setting`, never the
48/// value.
49pub fn base_url(setting: &str, value: &str) -> allama::Result<String> {
50    let value = value.trim();
51    let invalid = |reason: &str| {
52        allama::Error::configuration(format!("`{setting}` is not a valid base URL: {reason}"))
53            .authored()
54    };
55    if value.is_empty() {
56        return Err(allama::Error::configuration(format!("`{setting}` is empty")).authored());
57    }
58    let Some((scheme, rest)) = value.split_once("://") else {
59        return Err(invalid("it must start with `http://` or `https://`"));
60    };
61    if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
62        return Err(invalid("it must start with `http://` or `https://`"));
63    }
64    if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
65        return Err(invalid("it contains whitespace or control characters"));
66    }
67    if value.contains(['?', '#']) {
68        return Err(invalid(
69            "it has a query or fragment, and requests append their path to it",
70        ));
71    }
72    let authority = rest.split('/').next().unwrap_or_default();
73    let Some((host, port)) = split_authority(authority) else {
74        return Err(invalid("it has no valid host"));
75    };
76    if host.is_empty() {
77        return Err(invalid("it has no host"));
78    }
79    if port.is_some_and(|port| port.parse::<u16>().is_err()) {
80        return Err(invalid("its port is not a number up to 65535"));
81    }
82    Ok(value.trim_end_matches('/').to_owned())
83}
84
85/// Check a secret (an API key or token) from a builder setting or
86/// environment variable named `setting`, and return it without surrounding
87/// whitespace: a key read from a file or a Kubernetes secret often ends in
88/// a newline, which no HTTP header can carry.
89///
90/// # Errors
91///
92/// An authored [`allama::Error::Configuration`] naming `setting`, never the
93/// value, when it is empty after trimming or holds a character an HTTP
94/// header value cannot carry (anything but visible ASCII, spaces and tabs).
95pub fn secret(setting: &str, value: &str) -> allama::Result<String> {
96    let value = value.trim();
97    if value.is_empty() {
98        return Err(allama::Error::configuration(format!("`{setting}` is empty")).authored());
99    }
100    if !is_header_value(value) {
101        return Err(allama::Error::configuration(format!(
102            "`{setting}` contains characters an HTTP header cannot carry"
103        ))
104        .authored());
105    }
106    Ok(value.to_owned())
107}
108
109/// The headers a provider sends with every request: `User-Agent:`
110/// [`DEFAULT_USER_AGENT`], then `headers` (a builder's
111/// `header(name, value)` settings, in order), each replacing the headers of
112/// the same name before it, so `header("user-agent", ..)` replaces the
113/// default. Set them on each request with
114/// [`HttpRequest::with_headers`](crate::HttpRequest::with_headers).
115///
116/// These headers belong to one provider: a gateway's routing header, a
117/// tenant tag, a beta flag. Credentials do not: they are refused here, as
118/// [`HttpRequest::apply_options`](crate::HttpRequest::apply_options) refuses
119/// them in a request's options, so a key always goes through the builder's
120/// key setting or a per-request [`Credential`](crate::Credential), where it
121/// is scoped and redacted.
122///
123/// ```
124/// use allama_http::{CredentialPlacement, CredentialSlot, DEFAULT_USER_AGENT, config};
125///
126/// let slot = CredentialSlot::new("acme", CredentialPlacement::Bearer);
127/// let headers = config::static_headers([("x-route", "eu")], &slot)?;
128/// assert_eq!(headers.get("user-agent"), Some(DEFAULT_USER_AGENT));
129/// assert_eq!(headers.get("x-route"), Some("eu"));
130///
131/// let err = config::static_headers([("x-api-key", "sk-acme")], &slot).unwrap_err();
132/// assert!(matches!(err, allama::Error::Configuration { .. }));
133/// assert!(!err.to_string().contains("sk-acme"));
134/// # Ok::<(), allama::Error>(())
135/// ```
136///
137/// # Errors
138///
139/// An authored [`allama::Error::Configuration`] naming the header, never
140/// its value, when the name is not a valid header name, the value holds a
141/// character a header cannot carry (anything but visible ASCII, spaces and
142/// tabs), or the header carries a credential: it is on the exact credential
143/// list ([`is_credential_header`]), or `slot` writes or replaces it.
144pub fn static_headers<I, N, V>(headers: I, slot: &CredentialSlot) -> allama::Result<Headers>
145where
146    I: IntoIterator<Item = (N, V)>,
147    N: Into<String>,
148    V: Into<String>,
149{
150    let mut all = Headers::new();
151    all.insert("user-agent", DEFAULT_USER_AGENT);
152    for (name, value) in headers {
153        let (name, value) = (name.into(), value.into());
154        if !is_header_name(&name) {
155            return Err(allama::Error::configuration(format!(
156                "`{name}` is not a valid HTTP header name"
157            ))
158            .authored());
159        }
160        if !is_header_value(&value) {
161            return Err(allama::Error::configuration(format!(
162                "the value of the header `{name}` contains characters an HTTP header cannot carry"
163            ))
164            .authored());
165        }
166        if is_credential_header(&name) || slot.owns_header(&name) {
167            return Err(allama::Error::configuration(format!(
168                "the header `{name}` carries a credential: configure the key with the builder's \
169                 key setting, or send it with `HttpRequestOptions::credential`"
170            ))
171            .authored());
172        }
173        all.remove(&name);
174        all.insert(name, value);
175    }
176    Ok(all)
177}
178
179/// `url` as `Debug` output and error messages may show it: without a user
180/// name and password, query or fragment, which can carry credentials.
181///
182/// ```
183/// use allama_http::config::redact_url;
184///
185/// assert_eq!(redact_url("https://user:pass@gw.example:8443/v1?key=k"), "https://gw.example:8443/v1");
186/// assert_eq!(redact_url("https://api.example.com/v1"), "https://api.example.com/v1");
187/// ```
188#[must_use]
189pub fn redact_url(url: &str) -> Cow<'_, str> {
190    let (prefix, rest) = match url.find("://") {
191        Some(at) => url.split_at(at + 3),
192        None => ("", url),
193    };
194    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
195    let userinfo_end = rest[..authority_end].rfind('@').map(|at| at + 1);
196    let rest = &rest[userinfo_end.unwrap_or(0)..];
197    let query = rest.find(['?', '#']);
198    if userinfo_end.is_none() && query.is_none() {
199        return Cow::Borrowed(url);
200    }
201    let rest = &rest[..query.unwrap_or(rest.len())];
202    Cow::Owned(format!("{prefix}{rest}"))
203}
204
205/// Redact every URL in `text`, as [`redact_url`] does: for an HTTP
206/// client's error message, which can quote the request URL.
207#[cfg_attr(not(feature = "reqwest-middleware"), allow(dead_code))]
208pub(crate) fn redact_urls_in(text: &str) -> Cow<'_, str> {
209    if !text.contains("://") {
210        return Cow::Borrowed(text);
211    }
212    let mut out = String::with_capacity(text.len());
213    let mut rest = text;
214    while let Some(at) = rest.find("://") {
215        let start = rest[..at]
216            .rfind(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
217            .map_or(0, |i| i + 1);
218        let end = rest[at..]
219            .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ')' | '`'))
220            .map_or(rest.len(), |i| at + i);
221        out.push_str(&rest[..start]);
222        out.push_str(&redact_url(&rest[start..end]));
223        rest = &rest[end..];
224    }
225    out.push_str(rest);
226    Cow::Owned(out)
227}
228
229/// The host a URL (an absolute or scheme-relative `Location`) points to,
230/// for an authored message: lowercase, without user info or port. `None`
231/// for a relative URL; a host with characters a host name cannot have is
232/// reported as such, never quoted.
233pub(crate) fn url_host(url: &str) -> Option<String> {
234    let url = url.trim();
235    let rest = match url.split_once("://") {
236        Some((scheme, rest))
237            if !scheme.is_empty()
238                && scheme
239                    .chars()
240                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) =>
241        {
242            rest
243        }
244        _ => url.strip_prefix("//")?,
245    };
246    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
247    let host = split_authority(authority).map_or("", |(host, _)| host);
248    let valid = !host.is_empty()
249        && host.len() <= 253
250        && host
251            .chars()
252            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'));
253    Some(if valid {
254        host.to_ascii_lowercase()
255    } else {
256        "an invalid host".to_owned()
257    })
258}
259
260/// An authority's host (an IPv6 literal keeps its brackets) and port,
261/// without user info. `None` when brackets do not match.
262fn split_authority(authority: &str) -> Option<(&str, Option<&str>)> {
263    let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
264    if host_port.starts_with('[') {
265        let close = host_port.find(']')?;
266        let (host, after) = host_port.split_at(close + 1);
267        return match after {
268            "" => Some((host, None)),
269            _ => Some((host, Some(after.strip_prefix(':')?))),
270        };
271    }
272    if host_port.contains([']', '[']) {
273        return None;
274    }
275    Some(match host_port.split_once(':') {
276        Some((host, port)) => (host, Some(port)),
277        None => (host_port, None),
278    })
279}
280
281/// An HTTP header name: a non-empty token.
282fn is_header_name(name: &str) -> bool {
283    !name.is_empty()
284        && name.bytes().all(|b| {
285            b.is_ascii_alphanumeric()
286                || matches!(
287                    b,
288                    b'!' | b'#'
289                        | b'$'
290                        | b'%'
291                        | b'&'
292                        | b'\''
293                        | b'*'
294                        | b'+'
295                        | b'-'
296                        | b'.'
297                        | b'^'
298                        | b'_'
299                        | b'`'
300                        | b'|'
301                        | b'~'
302                )
303        })
304}
305
306/// A value every HTTP client sends as given: visible ASCII, spaces and
307/// tabs.
308fn is_header_value(value: &str) -> bool {
309    value
310        .bytes()
311        .all(|b| b == b'\t' || (b' '..=b'~').contains(&b))
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::CredentialPlacement;
318
319    #[test]
320    fn base_urls_are_trimmed_and_checked() {
321        for (value, expected) in [
322            ("https://api.example.com/v1", "https://api.example.com/v1"),
323            (
324                "  http://localhost:11434/v1/  \n",
325                "http://localhost:11434/v1",
326            ),
327            ("HTTPS://Gw.Example", "HTTPS://Gw.Example"),
328            ("http://[::1]:8000/v1", "http://[::1]:8000/v1"),
329            (
330                "https://user:pass@gw.example/v1",
331                "https://user:pass@gw.example/v1",
332            ),
333        ] {
334            assert_eq!(base_url("base_url", value).unwrap(), expected, "{value}");
335        }
336        for value in [
337            "",
338            "  ",
339            "localhost:8080/v1",
340            "ftp://example.com",
341            "https://",
342            "https:///v1",
343            "https://exa mple.com",
344            "https://example.com/v1\n/x",
345            "https://example.com/v1?api-version=1",
346            "https://example.com/#x",
347            "https://example.com:99999",
348            "https://example.com:port",
349            "http://[::1/v1",
350            "https://attacker.example/x?.openai.azure.com/openai/v1",
351        ] {
352            let err = base_url("ACME_BASE_URL", value).unwrap_err();
353            assert!(
354                matches!(err, allama::Error::Configuration { .. }),
355                "{value}: {err:?}"
356            );
357            let text = err.to_string();
358            assert!(text.contains("`ACME_BASE_URL`"), "{text}");
359            assert!(!err.is_retryable());
360        }
361        let err = base_url("u", "https://user:hunter2@x y").unwrap_err();
362        assert!(!format!("{err} {err:?}").contains("hunter2"));
363    }
364
365    #[test]
366    fn secrets_are_trimmed_and_must_fit_a_header() {
367        assert_eq!(secret("KEY", " sk-x\n").unwrap(), "sk-x");
368        assert_eq!(secret("KEY", "Bearer a b").unwrap(), "Bearer a b");
369        for value in ["", " \n", "sk-\nx", "sk-é", "sk\0"] {
370            let err = secret("OPENAI_API_KEY", value).unwrap_err();
371            assert!(matches!(err, allama::Error::Configuration { .. }));
372            assert!(err.to_string().contains("OPENAI_API_KEY"), "{err}");
373            assert!(!err.to_string().contains("sk-"), "{err}");
374        }
375    }
376
377    #[test]
378    fn static_headers_start_with_the_user_agent_and_refuse_credentials() {
379        let slot = CredentialSlot::new("acme", CredentialPlacement::Header("x-acme-key".into()))
380            .replaces("x-acme-legacy");
381        let headers = static_headers(Vec::<(String, String)>::new(), &slot).unwrap();
382        assert_eq!(
383            headers.iter().collect::<Vec<_>>(),
384            [("user-agent", DEFAULT_USER_AGENT)]
385        );
386        assert!(DEFAULT_USER_AGENT.starts_with("allama/"));
387
388        let headers = static_headers(
389            [
390                ("x-route", "a"),
391                ("User-Agent", "my-app/1.0"),
392                ("X-ROUTE", "b"),
393                ("Idempotency-Key", "fine"),
394            ],
395            &slot,
396        )
397        .unwrap();
398        assert_eq!(
399            headers.iter().collect::<Vec<_>>(),
400            [
401                ("User-Agent", "my-app/1.0"),
402                ("X-ROUTE", "b"),
403                ("Idempotency-Key", "fine")
404            ]
405        );
406
407        for (name, value) in [
408            ("authorization", "Bearer SECRET"),
409            ("x-api-key", "SECRET"),
410            ("X-Acme-Key", "SECRET"),
411            ("x-acme-legacy", "SECRET"),
412            ("bad name", "v"),
413            ("", "v"),
414            ("x-ok", "SECRET\r\nx-injected: 1"),
415        ] {
416            let err = static_headers([(name, value)], &slot).unwrap_err();
417            assert!(
418                matches!(err, allama::Error::Configuration { .. }),
419                "{name}: {err:?}"
420            );
421            assert!(!format!("{err} {err:?}").contains("SECRET"), "{err}");
422        }
423    }
424
425    #[test]
426    fn urls_are_redacted() {
427        for (url, expected) in [
428            ("https://api.example.com/v1", "https://api.example.com/v1"),
429            ("https://user@gw.example/v1", "https://gw.example/v1"),
430            ("https://user:pw@gw.example", "https://gw.example"),
431            ("http://u:p@[::1]:80/v1?key=k#f", "http://[::1]:80/v1"),
432            ("https://gw.example/v1/a@b", "https://gw.example/v1/a@b"),
433            ("https://gw.example?key=k", "https://gw.example"),
434        ] {
435            assert_eq!(redact_url(url), expected, "{url}");
436        }
437        assert_eq!(
438            redact_urls_in(
439                "error sending request for url (https://u:pw@gw.example/v1?key=SECRET): reset"
440            ),
441            "error sending request for url (https://gw.example/v1): reset"
442        );
443        assert_eq!(redact_urls_in("no url here"), "no url here");
444    }
445
446    #[test]
447    fn location_hosts() {
448        for (location, host) in [
449            ("https://Evil.Example/v1?token=x", Some("evil.example")),
450            (
451                "http://user:pw@169.254.169.254:80/latest",
452                Some("169.254.169.254"),
453            ),
454            ("//cdn.example/x", Some("cdn.example")),
455            ("/v1/messages", None),
456            ("v1/messages", None),
457            ("https://ev\u{1b}[31mil/", Some("an invalid host")),
458        ] {
459            assert_eq!(url_host(location).as_deref(), host, "{location}");
460        }
461    }
462}