Skip to main content

fizzy_sdk/
security.rs

1//! HTTPS enforcement, origin comparison and header redaction.
2
3use 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
16/// Refuses an endpoint that would carry credentials over plain HTTP, unless it is on this
17/// machine.
18pub fn require_secure_endpoint(url: &Url) -> Result<(), Error> {
19    if url.scheme() == "https" || (url.scheme() == "http" && is_localhost(url)) {
20        Ok(())
21    } else {
22        // Only the origin is named: a pasted URL may carry credentials or a query.
23        Err(Error::usage(format!(
24            "{} must use HTTPS",
25            url.origin().ascii_serialization()
26        )))
27    }
28}
29
30/// Whether the URL names this machine.
31pub 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
47/// Whether two URLs share a scheme, host and port, with default ports treated as equal to
48/// their explicit form.
49pub 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
57/// A copy of the headers with credentials replaced, for logging.
58pub 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}