Skip to main content

speccoder_web/
security.rs

1//! Who may talk to the host. Loopback is not access control: any local
2//! process and any web page can reach 127.0.0.1. So:
3//!
4//! - every `/api` request carries the per-start token as a bearer header —
5//!   never in a URL, which ends up in logs and history;
6//! - the `Host` header must name the loopback address and this port, so a
7//!   page on another site whose name resolves to 127.0.0.1 (DNS rebinding)
8//!   is refused;
9//! - a request that changes anything must come from this origin;
10//! - the interface is served with a policy that lets it load and send to
11//!   this host only, and never be framed.
12
13use axum::http::{HeaderMap, Method, header};
14
15/// What the page may load and where it may send: this host and nothing else.
16pub const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self'; \
17     style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; \
18     font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
19
20/// A fresh token: 32 random bytes, hex.
21#[must_use]
22pub fn token() -> String {
23    let mut bytes = [0u8; 32];
24    // Without an OS random source there is no safe token: an empty one
25    // matches nothing (see `authorised`).
26    if getrandom::fill(&mut bytes).is_err() {
27        return String::new();
28    }
29    bytes.iter().map(|b| format!("{b:02x}")).collect()
30}
31
32/// Compares without leaking where the first difference is.
33fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
34    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
35}
36
37/// The bearer token is the host's.
38#[must_use]
39pub fn authorised(token: &str, headers: &HeaderMap) -> bool {
40    !token.is_empty()
41        && headers
42            .get(header::AUTHORIZATION)
43            .and_then(|v| v.to_str().ok())
44            .and_then(|v| v.strip_prefix("Bearer "))
45            .is_some_and(|t| constant_time_eq(t.as_bytes(), token.as_bytes()))
46}
47
48/// The names this host answers to.
49#[must_use]
50pub fn hosts(port: u16) -> [String; 3] {
51    [
52        format!("127.0.0.1:{port}"),
53        format!("localhost:{port}"),
54        format!("[::1]:{port}"),
55    ]
56}
57
58/// The `Host` header names this host (DNS rebinding defence).
59#[must_use]
60pub fn host_ok(port: u16, headers: &HeaderMap) -> bool {
61    headers
62        .get(header::HOST)
63        .and_then(|v| v.to_str().ok())
64        .is_some_and(|h| hosts(port).iter().any(|ok| ok == h))
65}
66
67/// A request that changes something comes from this origin: browsers send
68/// `Origin` on every non-GET fetch, so a cross-site form or script is
69/// refused even if it guessed the token.
70#[must_use]
71pub fn origin_ok(port: u16, method: &Method, headers: &HeaderMap) -> bool {
72    if method == Method::GET || method == Method::HEAD {
73        return true;
74    }
75    match headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
76        // A non-browser client with the token (a script, the CLI) sends none.
77        None => true,
78        Some(o) => hosts(port).iter().any(|h| o == format!("http://{h}")),
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    fn headers(pairs: &[(header::HeaderName, &str)]) -> HeaderMap {
87        let mut h = HeaderMap::new();
88        for (k, v) in pairs {
89            if let Ok(v) = v.parse() {
90                h.insert(k.clone(), v);
91            }
92        }
93        h
94    }
95
96    #[test]
97    fn the_token_host_and_origin_are_checked() {
98        let t = token();
99        assert_eq!(t.len(), 64);
100        assert!(authorised(
101            &t,
102            &headers(&[(header::AUTHORIZATION, &format!("Bearer {t}"))])
103        ));
104        assert!(!authorised(
105            &t,
106            &headers(&[(header::AUTHORIZATION, "Bearer nope")])
107        ));
108        assert!(!authorised(&t, &HeaderMap::new()));
109        assert!(!authorised(
110            "",
111            &headers(&[(header::AUTHORIZATION, "Bearer ")])
112        ));
113        assert!(host_ok(4321, &headers(&[(header::HOST, "127.0.0.1:4321")])));
114        assert!(host_ok(4321, &headers(&[(header::HOST, "localhost:4321")])));
115        assert!(!host_ok(
116            4321,
117            &headers(&[(header::HOST, "evil.example:4321")])
118        ));
119        assert!(!host_ok(4321, &headers(&[(header::HOST, "127.0.0.1:9")])));
120        assert!(origin_ok(
121            4321,
122            &Method::POST,
123            &headers(&[(header::ORIGIN, "http://127.0.0.1:4321")])
124        ));
125        assert!(!origin_ok(
126            4321,
127            &Method::POST,
128            &headers(&[(header::ORIGIN, "https://evil.example")])
129        ));
130        assert!(origin_ok(
131            4321,
132            &Method::GET,
133            &headers(&[(header::ORIGIN, "https://evil.example")])
134        ));
135    }
136}