1use url::Url;
4
5use crate::error::Error;
6use crate::http::{HeaderMap, HeaderValue};
7
8const SENSITIVE_HEADERS: &[&str] = &[
9 "authorization",
10 "proxy-authorization",
11 "cookie",
12 "set-cookie",
13 "x-csrf-token",
14];
15
16pub fn require_secure_endpoint(url: &Url) -> Result<(), Error> {
19 if url.scheme() == "https" || (url.scheme() == "http" && is_localhost(url)) {
20 Ok(())
21 } else {
22 Err(Error::usage(format!(
24 "{} must use HTTPS",
25 url.origin().ascii_serialization()
26 )))
27 }
28}
29
30pub fn is_localhost(url: &Url) -> bool {
32 match url.host_str() {
33 Some(host) => {
34 let host = host
35 .trim_start_matches('[')
36 .trim_end_matches(']')
37 .to_ascii_lowercase();
38 host == "localhost"
39 || host == "127.0.0.1"
40 || host == "::1"
41 || host.ends_with(".localhost")
42 }
43 None => false,
44 }
45}
46
47pub fn is_same_origin(a: &Url, b: &Url) -> bool {
50 a.scheme().eq_ignore_ascii_case(b.scheme())
51 && a.host_str()
52 .unwrap_or_default()
53 .eq_ignore_ascii_case(b.host_str().unwrap_or_default())
54 && a.port_or_known_default() == b.port_or_known_default()
55}
56
57pub fn redact_headers(headers: &HeaderMap) -> HeaderMap {
59 let mut redacted = headers.clone();
60 for name in SENSITIVE_HEADERS {
61 if redacted.contains_key(*name) {
62 redacted.insert(*name, HeaderValue::from_static("[REDACTED]"));
63 }
64 }
65 redacted
66}
67
68#[cfg(test)]
69#[allow(clippy::unwrap_used)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn localhost_may_use_plain_http() {
75 assert!(require_secure_endpoint(&Url::parse("http://localhost:3000").unwrap()).is_ok());
76 assert!(require_secure_endpoint(&Url::parse("http://127.0.0.1:8080/x").unwrap()).is_ok());
77 assert!(require_secure_endpoint(&Url::parse("http://app.localhost").unwrap()).is_ok());
78 assert!(require_secure_endpoint(&Url::parse("https://fizzy.do").unwrap()).is_ok());
79 assert!(require_secure_endpoint(&Url::parse("http://evil.example.com").unwrap()).is_err());
80 let error = require_secure_endpoint(
81 &Url::parse("http://user:s3cret@evil.example.com/x?t=s3cret").unwrap(),
82 )
83 .unwrap_err();
84 assert!(!error.to_string().contains("s3cret"), "{error}");
85 }
86
87 #[test]
88 fn same_origin_ignores_default_ports_and_case() {
89 let a = Url::parse("https://Fizzy.DO/999/boards").unwrap();
90 assert!(is_same_origin(
91 &a,
92 &Url::parse("HTTPS://fizzy.do:443/other").unwrap()
93 ));
94 assert!(!is_same_origin(
95 &a,
96 &Url::parse("http://fizzy.do/other").unwrap()
97 ));
98 assert!(!is_same_origin(
99 &a,
100 &Url::parse("https://evil.example.com/boards").unwrap()
101 ));
102 }
103
104 #[test]
105 fn credentials_are_redacted() {
106 let mut headers = HeaderMap::new();
107 headers.insert("Authorization", HeaderValue::from_static("Bearer secret"));
108 headers.insert("Cookie", HeaderValue::from_static("session_token=secret"));
109 headers.insert(
110 "Proxy-Authorization",
111 HeaderValue::from_static("Basic secret"),
112 );
113 headers.insert("Accept", HeaderValue::from_static("application/json"));
114 let redacted = redact_headers(&headers);
115 assert_eq!(redacted["authorization"], "[REDACTED]");
116 assert_eq!(redacted["cookie"], "[REDACTED]");
117 assert_eq!(redacted["proxy-authorization"], "[REDACTED]");
118 assert_eq!(redacted["accept"], "application/json");
119 }
120}