Skip to main content

hey_sdk/
security.rs

1use url::Url;
2
3use crate::error::Error;
4use crate::http::{HeaderMap, HeaderValue};
5
6const SENSITIVE_HEADERS: &[&str] = &["authorization", "cookie", "set-cookie", "x-csrf-token"];
7
8/// Refuses an endpoint that would carry credentials over plain HTTP, unless it is on this
9/// machine.
10pub fn require_secure_endpoint(url: &Url) -> Result<(), Error> {
11    if url.scheme() == "https" || (url.scheme() == "http" && is_localhost(url)) {
12        Ok(())
13    } else {
14        Err(Error::usage(format!("{url} must use HTTPS")))
15    }
16}
17
18/// Whether a URL points at this machine: `localhost`, a `.localhost` name, or a loopback
19/// address.
20pub fn is_localhost(url: &Url) -> bool {
21    match url.host_str() {
22        Some(host) => {
23            let host = host
24                .trim_start_matches('[')
25                .trim_end_matches(']')
26                .to_ascii_lowercase();
27            host == "localhost"
28                || host == "127.0.0.1"
29                || host == "::1"
30                || host.ends_with(".localhost")
31        }
32        None => false,
33    }
34}
35
36/// Whether two URLs share a scheme, host and port, with default ports treated as equal to
37/// their explicit form.
38pub fn is_same_origin(a: &Url, b: &Url) -> bool {
39    a.scheme().eq_ignore_ascii_case(b.scheme())
40        && a.host_str()
41            .unwrap_or_default()
42            .eq_ignore_ascii_case(b.host_str().unwrap_or_default())
43        && a.port_or_known_default() == b.port_or_known_default()
44}
45
46/// A copy of the headers with credentials replaced, for logging.
47pub fn redact_headers(headers: &HeaderMap) -> HeaderMap {
48    let mut redacted = headers.clone();
49    for name in SENSITIVE_HEADERS {
50        if redacted.contains_key(*name) {
51            redacted.insert(*name, HeaderValue::from_static("[REDACTED]"));
52        }
53    }
54    redacted
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn localhost_may_use_plain_http() {
63        assert!(require_secure_endpoint(&Url::parse("http://localhost:3000").unwrap()).is_ok());
64        assert!(require_secure_endpoint(&Url::parse("http://127.0.0.1:8080/x").unwrap()).is_ok());
65        assert!(require_secure_endpoint(&Url::parse("http://app.localhost").unwrap()).is_ok());
66        assert!(require_secure_endpoint(&Url::parse("https://app.hey.com").unwrap()).is_ok());
67        assert!(require_secure_endpoint(&Url::parse("http://evil.example.com").unwrap()).is_err());
68    }
69
70    #[test]
71    fn same_origin_ignores_default_ports_and_case() {
72        let a = Url::parse("https://App.HEY.com/boxes").unwrap();
73        assert!(is_same_origin(
74            &a,
75            &Url::parse("HTTPS://app.hey.com:443/other").unwrap()
76        ));
77        assert!(!is_same_origin(
78            &a,
79            &Url::parse("http://app.hey.com/other").unwrap()
80        ));
81        assert!(!is_same_origin(
82            &a,
83            &Url::parse("https://evil.example.com/boxes").unwrap()
84        ));
85    }
86
87    #[test]
88    fn credentials_are_redacted() {
89        let mut headers = HeaderMap::new();
90        headers.insert("Authorization", HeaderValue::from_static("Bearer secret"));
91        headers.insert("Accept", HeaderValue::from_static("application/json"));
92        let redacted = redact_headers(&headers);
93        assert_eq!(redacted["authorization"], "[REDACTED]");
94        assert_eq!(redacted["accept"], "application/json");
95    }
96}