Skip to main content

turul_http_mcp_server/
origin.rs

1//! Origin-header validation (DNS-rebinding protection) for the MCP endpoint.
2//!
3//! Streamable HTTP §Security: "Servers MUST validate the `Origin` header on
4//! all incoming connections to prevent DNS rebinding attacks. If the
5//! `Origin` header is present and invalid, servers MUST respond with
6//! HTTP 403 Forbidden." Policy semantics are recorded in ADR-031.
7
8use hyper::HeaderMap;
9use std::net::{Ipv4Addr, Ipv6Addr};
10
11/// Validation policy for the `Origin` request header on the MCP endpoint.
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub enum OriginPolicy {
14    /// Default. Origin absent → allowed. Origin present → allowed only if
15    /// its host is loopback (`localhost`, `127.0.0.0/8`, `[::1]`). Anything
16    /// else → HTTP 403.
17    ///
18    /// The request's `Host` header is **not** consulted: it is
19    /// attacker-controlled, and a rebinding attacker sets it to agree with
20    /// `Origin`. To serve a browser app from a non-loopback origin, name that
21    /// origin with [`OriginPolicy::AllowList`]. See ADR-031 (2026-08-15).
22    #[default]
23    SameOriginOrLoopback,
24    /// [`OriginPolicy::SameOriginOrLoopback`] semantics plus an explicit
25    /// allowlist of origins (`scheme://host[:port]`; host compared
26    /// case-insensitively, default-port normalized). The literal entry
27    /// `"null"` admits `Origin: null`.
28    AllowList(Vec<String>),
29    /// No validation — for deployments that enforce origin upstream
30    /// (API Gateway / ALB / reverse proxy) or are not browser-reachable.
31    Disabled,
32}
33
34/// `(host_lowercase, effective_port)` of a parsed origin.
35type OriginAuthority = (String, u16);
36
37fn default_port(scheme: &str) -> Option<u16> {
38    match scheme {
39        "http" | "ws" => Some(80),
40        "https" | "wss" => Some(443),
41        _ => None,
42    }
43}
44
45/// Parse `scheme://host[:port]` into a normalized authority.
46fn parse_origin(origin: &str) -> Option<OriginAuthority> {
47    let (scheme, rest) = origin.split_once("://")?;
48    let scheme = scheme.to_ascii_lowercase();
49    // An origin has no path/query, but be lenient about a trailing slash.
50    let authority = rest.strip_suffix('/').unwrap_or(rest);
51    if authority.is_empty() {
52        return None;
53    }
54    let (host, port) = split_host_port(authority)?;
55    let port = match port {
56        Some(p) => p,
57        None => default_port(&scheme)?,
58    };
59    Some((host, port))
60}
61
62/// Split `host[:port]` handling bracketed IPv6 (`[::1]:8080`).
63fn split_host_port(authority: &str) -> Option<(String, Option<u16>)> {
64    if let Some(rest) = authority.strip_prefix('[') {
65        let (host, after) = rest.split_once(']')?;
66        let port = match after.strip_prefix(':') {
67            Some(p) => Some(p.parse().ok()?),
68            None if after.is_empty() => None,
69            None => return None,
70        };
71        Some((host.to_ascii_lowercase(), port))
72    } else if let Some((host, p)) = authority.rsplit_once(':') {
73        if host.is_empty() {
74            return None;
75        }
76        Some((host.to_ascii_lowercase(), Some(p.parse().ok()?)))
77    } else {
78        Some((authority.to_ascii_lowercase(), None))
79    }
80}
81
82fn is_loopback_host(host: &str) -> bool {
83    if host == "localhost" {
84        return true;
85    }
86    if let Ok(v4) = host.parse::<Ipv4Addr>() {
87        return v4.is_loopback();
88    }
89    if let Ok(v6) = host.parse::<Ipv6Addr>() {
90        return v6.is_loopback();
91    }
92    false
93}
94
95/// Validate the request's `Origin` header against `policy`.
96///
97/// `Ok(())` admits the request; `Err(origin_value)` means the caller MUST
98/// respond 403 Forbidden.
99pub(crate) fn validate_origin(headers: &HeaderMap, policy: &OriginPolicy) -> Result<(), String> {
100    if matches!(policy, OriginPolicy::Disabled) {
101        return Ok(());
102    }
103    let Some(origin) = headers.get(hyper::header::ORIGIN) else {
104        return Ok(()); // spec constrains only "present and invalid"
105    };
106    let Ok(origin) = origin.to_str() else {
107        return Err("<non-ascii>".to_string());
108    };
109
110    if let OriginPolicy::AllowList(allowed) = policy
111        && allowed.iter().any(|a| {
112            a == origin
113                || matches!(
114                    (parse_origin(a), parse_origin(origin)),
115                    (Some(x), Some(y)) if x == y
116                )
117        })
118    {
119        return Ok(());
120    }
121
122    let Some(parsed) = parse_origin(origin) else {
123        return Err(origin.to_string()); // includes `Origin: null`
124    };
125    if is_loopback_host(&parsed.0) {
126        return Ok(());
127    }
128    // Deliberately NOT compared against the request's `Host` header. `Host` is
129    // attacker-controlled, and in a DNS-rebinding attack the browser sends
130    // `Host` == the attacker's own name (the URL host, rebound to loopback),
131    // so `Origin` and `Host` always agree and the check would never fire —
132    // admitting exactly the attack this module exists to stop. A legitimate
133    // same-origin deployment and a rebinding attack are indistinguishable from
134    // these two headers alone, so only server-side knowledge of the expected
135    // origin can decide: operators declare it with `OriginPolicy::AllowList`.
136    // See ADR-031 revision 2026-08-15.
137    Err(origin.to_string())
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use hyper::header::{HOST, ORIGIN};
144
145    fn headers(origin: Option<&str>, host: Option<&str>) -> HeaderMap {
146        let mut h = HeaderMap::new();
147        if let Some(o) = origin {
148            h.insert(ORIGIN, o.parse().unwrap());
149        }
150        if let Some(hh) = host {
151            h.insert(HOST, hh.parse().unwrap());
152        }
153        h
154    }
155
156    #[test]
157    fn absent_origin_is_allowed() {
158        let p = OriginPolicy::SameOriginOrLoopback;
159        assert!(validate_origin(&headers(None, Some("example.com")), &p).is_ok());
160    }
161
162    #[test]
163    fn loopback_origins_pass() {
164        let p = OriginPolicy::SameOriginOrLoopback;
165        for o in [
166            "http://localhost",
167            "http://localhost:3000",
168            "http://127.0.0.1:9999",
169            "http://127.8.4.2",
170            "http://[::1]:8080",
171            "https://LOCALHOST:8443",
172        ] {
173            assert!(
174                validate_origin(&headers(Some(o), Some("example.com")), &p).is_ok(),
175                "{o} should pass"
176            );
177        }
178    }
179
180    /// A matching `Host` header MUST NOT admit a non-loopback origin.
181    ///
182    /// This is the DNS-rebinding case itself: the attacker controls both
183    /// headers and sets them consistently, so any rule that trusts their
184    /// agreement admits the attack. Before 2026-08-15 every case here
185    /// returned `Ok` — the conformance suite's `dns-rebinding-protection`
186    /// scenario caught it (`Host` + `Origin` both `evil.example.com` -> 200).
187    #[test]
188    fn matching_host_header_does_not_admit_a_foreign_origin() {
189        let p = OriginPolicy::SameOriginOrLoopback;
190        for (o, host) in [
191            ("http://evil.example.com", "evil.example.com"),
192            ("http://app.example:8080", "app.example:8080"),
193            ("http://app.example", "app.example"), // 80 vs portless
194            ("https://app.example", "app.example"), // 443 vs portless
195            ("https://APP.example:443", "app.example:443"),
196        ] {
197            assert!(
198                validate_origin(&headers(Some(o), Some(host)), &p).is_err(),
199                "{o} vs matching Host {host} must be rejected — Host is attacker-controlled"
200            );
201        }
202    }
203
204    /// The supported way to serve a browser app from a non-loopback origin.
205    #[test]
206    fn same_origin_on_a_public_host_is_reachable_via_allowlist() {
207        let p = OriginPolicy::AllowList(vec!["https://app.example".into()]);
208        assert!(
209            validate_origin(
210                &headers(Some("https://app.example"), Some("app.example")),
211                &p
212            )
213            .is_ok()
214        );
215        assert!(
216            validate_origin(
217                &headers(Some("https://evil.example"), Some("evil.example")),
218                &p
219            )
220            .is_err()
221        );
222    }
223
224    #[test]
225    fn cross_origin_null_and_garbage_are_rejected() {
226        let p = OriginPolicy::SameOriginOrLoopback;
227        for (o, host) in [
228            ("http://attacker.example", "127.0.0.1:8641"),
229            ("http://app.example:9000", "app.example:8080"), // port mismatch
230            ("null", "127.0.0.1:8641"),
231            ("not a url", "127.0.0.1:8641"),
232        ] {
233            assert!(
234                validate_origin(&headers(Some(o), Some(host)), &p).is_err(),
235                "{o} vs Host {host} should be rejected"
236            );
237        }
238    }
239
240    #[test]
241    fn allowlist_is_additive_and_port_normalized() {
242        let p = OriginPolicy::AllowList(vec!["https://app.example".into(), "null".into()]);
243        let host = Some("127.0.0.1:8641");
244        assert!(validate_origin(&headers(Some("https://app.example"), host), &p).is_ok());
245        assert!(validate_origin(&headers(Some("https://app.example:443"), host), &p).is_ok());
246        assert!(validate_origin(&headers(Some("null"), host), &p).is_ok());
247        // additive: loopback still passes
248        assert!(validate_origin(&headers(Some("http://localhost:3000"), host), &p).is_ok());
249        // unlisted still rejected
250        assert!(validate_origin(&headers(Some("https://other.example"), host), &p).is_err());
251    }
252
253    #[test]
254    fn disabled_skips_everything() {
255        let p = OriginPolicy::Disabled;
256        assert!(validate_origin(&headers(Some("http://attacker.example"), None), &p).is_ok());
257    }
258}