1use std::borrow::Cow;
32
33use crate::{CredentialSlot, DEFAULT_USER_AGENT, Headers, is_credential_header};
34
35pub 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
85pub 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
109pub 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#[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#[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
229pub(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
260fn 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
281fn 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
306fn 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}