Skip to main content

atd_server_http/
origin.rs

1//! Origin allow-list — fail-closed default per MCP Security Warning.
2//!
3//! SP-streamable-http §4.6: the default allow-list matches Celia's
4//! production-hardened set
5//! (`celia_phr/crates/celia-cli/src/http_server.rs:121-130`):
6//!
7//! - `http://127.0.0.1*` / `https://127.0.0.1*`
8//! - `http://localhost*` / `https://localhost*`
9//! - `tauri://*`
10//!
11//! Any origin not on the list (and not in the operator's `extra_origins`)
12//! gets a 403 + JSON-RPC `-32001`. Requests without an `Origin` header
13//! (curl / same-origin fetch) are allowed because the kernel's
14//! loopback-bind already enforces the DNS-rebinding boundary for them;
15//! same precedent as Celia.
16
17use axum::http::HeaderMap;
18
19/// Returns `true` when the request's `Origin` header is on the default
20/// allow-list, on the `extras` list (verbatim match), or absent. Used by
21/// the `/mcp` handler as the first gate before bearer auth.
22pub fn origin_allowed(headers: &HeaderMap, extras: &[String]) -> bool {
23    let origin = match headers.get("origin").and_then(|v| v.to_str().ok()) {
24        Some(s) => s,
25        // Same-origin / curl / fetch-with-no-origin is fine on a
26        // loopback-only bind — the kernel boundary makes
27        // DNS-rebinding irrelevant for these callers. Matches
28        // celia-cli/src/http_server.rs:170-173 verbatim.
29        None => return true,
30    };
31    if is_default_loopback_origin(origin) {
32        return true;
33    }
34    extras.iter().any(|p| origin == p)
35}
36
37/// The five default loopback / Tauri patterns. Public for tests + the
38/// `--doctor` self-check in adopters (e.g. `atd-ref-server --doctor`).
39pub fn is_default_loopback_origin(origin: &str) -> bool {
40    origin.starts_with("http://127.0.0.1")
41        || origin.starts_with("http://localhost")
42        || origin.starts_with("https://127.0.0.1")
43        || origin.starts_with("https://localhost")
44        || origin == "tauri://localhost"
45        || origin.starts_with("tauri://")
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51    use axum::http::HeaderValue;
52
53    fn headers_with_origin(origin: &str) -> HeaderMap {
54        let mut h = HeaderMap::new();
55        h.insert("origin", HeaderValue::from_str(origin).unwrap());
56        h
57    }
58
59    #[test]
60    fn default_allows_loopback_http() {
61        assert!(origin_allowed(
62            &headers_with_origin("http://127.0.0.1:5173"),
63            &[]
64        ));
65        assert!(origin_allowed(
66            &headers_with_origin("http://localhost:1420"),
67            &[]
68        ));
69    }
70
71    #[test]
72    fn default_allows_loopback_https_and_tauri() {
73        assert!(origin_allowed(
74            &headers_with_origin("https://127.0.0.1"),
75            &[]
76        ));
77        assert!(origin_allowed(
78            &headers_with_origin("https://localhost:8443"),
79            &[]
80        ));
81        assert!(origin_allowed(
82            &headers_with_origin("tauri://localhost"),
83            &[]
84        ));
85        assert!(origin_allowed(&headers_with_origin("tauri://custom"), &[]));
86    }
87
88    #[test]
89    fn default_rejects_remote_origin() {
90        assert!(!origin_allowed(
91            &headers_with_origin("https://evil.example"),
92            &[]
93        ));
94        assert!(!origin_allowed(
95            &headers_with_origin("http://attacker.test:8080"),
96            &[]
97        ));
98    }
99
100    #[test]
101    fn extras_admits_exact_match_only() {
102        let extras = vec!["https://celia.health".to_string()];
103        assert!(origin_allowed(
104            &headers_with_origin("https://celia.health"),
105            &extras
106        ));
107        // No prefix match — extra origins are verbatim.
108        assert!(!origin_allowed(
109            &headers_with_origin("https://celia.health.evil.example"),
110            &extras
111        ));
112        // Default loopback still allowed in presence of extras.
113        assert!(origin_allowed(
114            &headers_with_origin("http://127.0.0.1:5173"),
115            &extras
116        ));
117    }
118
119    #[test]
120    fn missing_origin_header_is_allowed() {
121        // curl, same-origin fetch — kernel-side loopback bind covers
122        // the DNS-rebinding boundary.
123        let headers = HeaderMap::new();
124        assert!(origin_allowed(&headers, &[]));
125    }
126}