Skip to content

Commit a959ee0

Browse files
committed
feat(http): Origin validation (DNS-rebinding protection) — spec P0 TX/GAP-1
Streamable HTTP §Security: "Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks. If the Origin header is present and invalid, servers MUST respond with HTTP 403 Forbidden." No code path read Origin before this; cors.rs only emitted Access-Control-Allow-Origin: * on responses. - New OriginPolicy on ServerConfig (ADR-031): SameOriginOrLoopback default (absent Origin allowed; loopback or Host-matching origins allowed; otherwise 403), AllowList additive allowlist, Disabled opt-out. - Enforced at both transport handler entries (StreamableHttpHandler + legacy SessionMcpHandler) so hyper and Lambda inherit it; OPTIONS preflight and .well-known routes exempt; 403 pre-auth (WAF position). - Builder knobs: HttpMcpServerBuilder::origin_policy, McpServer::builder().origin_policy, LambdaMcpServerBuilder::origin_policy. - Lambda CORS derivation (ADR-031 §CORS-derived policy): explicit cors_allow_all_origins() → Disabled, origin list → AllowList, unless .origin_policy() overrides. enable_cors on the http server deliberately does NOT derive. Tests: 6 unit (origin.rs) + 7 real-HTTP wire (origin_validation_2026.rs) + Lambda derivation regression (cors_origin_list_derives_origin_allowlist). Revert-and-fail recorded: forcing the streamable gate to Disabled fails cross_origin_is_rejected_with_403_by_default, options_preflight_is_exempt_but_actual_request_is_gated, and the allowlist test with "left: 200, right: 403"-class assertions. Gates: scripts/ci-gates.sh all → ALL GATES PASSED. Driver doc: TX/GAP-1 ticked, transports table row flipped to compliant.
1 parent 83f2235 commit a959ee0

13 files changed

Lines changed: 723 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
2121
### Added
2222

23+
- **Origin-header validation (DNS-rebinding protection) on the HTTP transport (2026-06-11, ADR-031).** Streamable HTTP §Security requires: "Servers MUST validate the `Origin` header … If the `Origin` header is present and invalid, servers MUST respond with HTTP 403 Forbidden." New `OriginPolicy` on `ServerConfig` — default `SameOriginOrLoopback` (Origin absent → allowed; loopback or Host-matching origins → allowed; anything else → 403), `AllowList(Vec<String>)` additive allowlist, `Disabled` opt-out for upstream-enforced deployments. Enforced at both transport handler entries (streamable + legacy session path), so hyper and Lambda deployments inherit it; OPTIONS preflight and `.well-known` routes exempt. Builder knobs: `HttpMcpServerBuilder::origin_policy` and `McpServer::builder().origin_policy(...)`. Wire tests in `crates/turul-mcp-server/tests/origin_validation_2026.rs` (revert-and-fail proven: disabling the gate fails 3 tests). On the Lambda builder, an explicit CORS configuration derives the policy (`cors_allow_all_origins()` → `Disabled`, an origin list → `AllowList`) unless `.origin_policy()` overrides it — `turul-http-mcp-server`'s blanket `enable_cors` flag deliberately does NOT derive (see ADR-031 §CORS-derived policy). Closes spec-compliance driver gap **TX/GAP-1 (P0)**. **Behavior change:** cross-origin browser clients now require an explicit `AllowList` (previously implicitly admitted via CORS `*`).
2324
- **New crate `turul-mcp-protocol-2026-07-28` at 0.4.0** — first standalone binding for the MCP DRAFT-2026-v1 release candidate (see [https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/)). Stateless protocol core (`initialize` / `notifications/initialized` removed, `Mcp-Session-Id` header removed, per-request capability negotiation in `_meta`), new `server/discover` method, multi-round-trip `InputRequiredResult` (SEP-2322), `CacheableResult` mixin (`ttlMs`, `cacheScope`), W3C Trace Context in `_meta`, JSON Schema 2020-12 on tool input schemas, MCP Apps templates, RFC 9207 auth hardening, error code `-32002 → -32602`. Schema pinned to upstream commit `c3e3f09eb5d271407afac0f0bb6ee2dae5813d1d`. Compliance harness with bidirectional wire-format gate against the upstream's 86 canonical example fixtures (8 modeled cases / 20 fixtures bound at this cut; remainder marked `Kind::NotModeled` for wave-by-wave migration). 343 tests pass under `--features compliance` (160 lib + 179 integration + 3 fixture + 1 doctest), 333 default; `clippy -D warnings` clean. See `crates/turul-mcp-protocol-2026-07-28/COMPLIANCE.md`.
2425
- **First-party 2026-07-28 stateless server (2026-06-07).** `turul-mcp-server` / `turul-http-mcp-server` gained a `server/discover` handler plus a stateless 2026 request path: capabilities, client info, and protocol version travel in per-request `_meta` on every call (no `initialize` / `notifications/initialized` handshake, no `Mcp-Session-Id` header). The transport advertises `MCP-Protocol-Version: 2026-07-28` on the wire. Wire-level acceptance tests cover the stateless core; `server/discover` returns a `CacheableResult` (`ttlMs`/`cacheScope`). The 2025-11-25 stateful core (handshake + session header + GET SSE) remains available under the `protocol-2025-11-25` opt-in.
2526
- **Schema re-pinned to finalized upstream `2026-07-28` (2026-06-07).** Re-vendored `schema/draft-schema.ts` from `modelcontextprotocol/modelcontextprotocol@main` (HTTP ETag `0eeaed15…`, content sha256 `20df36f9…`; was `8bdd4ae5…` at the 2026-05-24 cut). The 159-symbol export surface and 22 method strings are **identical** to the prior pin (stateless core intact — no `initialize`/`ping`/`resources/subscribe`/`tasks/*` reintroduced; verified against live `main`). Exactly three substantive wire changes applied: (1) `LATEST_PROTOCOL_VERSION` / `MCP_VERSION` / `McpVersion::V2026_07_28` serde rename flipped `"DRAFT-2026-v1"` → `"2026-07-28"` (draft literal still accepted on deserialize via serde `alias` for back-compat); (2) `ResultType` became an open union `"complete" | "input_required" | string` — modeled as `ResultType::Other(String)` with custom serde so unknown discriminators round-trip instead of being rejected; (3) `DiscoverResult` now `extends CacheableResult` (`ttlMs`/`cacheScope` required fields added). Also fixed the `clippy::large_enum_variant` gate on the deprecated MRTR `InputRequest`/`InputResponse` unions (scoped `#[allow]` with rationale). Contract-change tests migrated; revert-and-fail verified for the version and `ResultType` deltas. Crate stays at **0.4.0** (unreleased) — completing the spec line 0.4.0 was created to target, not a new version. See `docs/adr/027` revision log (2026-06-07).

crates/turul-http-mcp-server/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub mod json_rpc_responses;
5252
pub mod mcp_session;
5353
pub mod middleware;
5454
pub mod notification_bridge;
55+
pub mod origin;
5556
pub mod prelude;
5657
pub mod protocol;
5758
pub mod routes;
@@ -71,6 +72,7 @@ pub use notification_bridge::{
7172
BroadcastError, NotificationBroadcaster, SharedNotificationBroadcaster,
7273
StreamManagerNotificationBroadcaster,
7374
};
75+
pub use origin::OriginPolicy;
7476
pub use protocol::{
7577
McpProtocolVersion, extract_last_event_id, extract_protocol_version, extract_session_id,
7678
};
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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+
8+
use hyper::HeaderMap;
9+
use std::net::{Ipv4Addr, Ipv6Addr};
10+
11+
/// Validation policy for the `Origin` request header on the MCP endpoint.
12+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
13+
pub enum OriginPolicy {
14+
/// Default. Origin absent → allowed. Origin present → allowed only if
15+
/// its host is loopback (`localhost`, `127.0.0.0/8`, `[::1]`) or its
16+
/// authority matches the request's `Host` header (default-port
17+
/// normalized). Anything else → HTTP 403.
18+
#[default]
19+
SameOriginOrLoopback,
20+
/// [`OriginPolicy::SameOriginOrLoopback`] semantics plus an explicit
21+
/// allowlist of origins (`scheme://host[:port]`; host compared
22+
/// case-insensitively, default-port normalized). The literal entry
23+
/// `"null"` admits `Origin: null`.
24+
AllowList(Vec<String>),
25+
/// No validation — for deployments that enforce origin upstream
26+
/// (API Gateway / ALB / reverse proxy) or are not browser-reachable.
27+
Disabled,
28+
}
29+
30+
/// `(host_lowercase, effective_port)` of a parsed origin.
31+
type OriginAuthority = (String, u16);
32+
33+
fn default_port(scheme: &str) -> Option<u16> {
34+
match scheme {
35+
"http" | "ws" => Some(80),
36+
"https" | "wss" => Some(443),
37+
_ => None,
38+
}
39+
}
40+
41+
/// Parse `scheme://host[:port]` into a normalized authority.
42+
fn parse_origin(origin: &str) -> Option<OriginAuthority> {
43+
let (scheme, rest) = origin.split_once("://")?;
44+
let scheme = scheme.to_ascii_lowercase();
45+
// An origin has no path/query, but be lenient about a trailing slash.
46+
let authority = rest.strip_suffix('/').unwrap_or(rest);
47+
if authority.is_empty() {
48+
return None;
49+
}
50+
let (host, port) = split_host_port(authority)?;
51+
let port = match port {
52+
Some(p) => p,
53+
None => default_port(&scheme)?,
54+
};
55+
Some((host, port))
56+
}
57+
58+
/// Split `host[:port]` handling bracketed IPv6 (`[::1]:8080`).
59+
fn split_host_port(authority: &str) -> Option<(String, Option<u16>)> {
60+
if let Some(rest) = authority.strip_prefix('[') {
61+
let (host, after) = rest.split_once(']')?;
62+
let port = match after.strip_prefix(':') {
63+
Some(p) => Some(p.parse().ok()?),
64+
None if after.is_empty() => None,
65+
None => return None,
66+
};
67+
Some((host.to_ascii_lowercase(), port))
68+
} else if let Some((host, p)) = authority.rsplit_once(':') {
69+
if host.is_empty() {
70+
return None;
71+
}
72+
Some((host.to_ascii_lowercase(), Some(p.parse().ok()?)))
73+
} else {
74+
Some((authority.to_ascii_lowercase(), None))
75+
}
76+
}
77+
78+
fn is_loopback_host(host: &str) -> bool {
79+
if host == "localhost" {
80+
return true;
81+
}
82+
if let Ok(v4) = host.parse::<Ipv4Addr>() {
83+
return v4.is_loopback();
84+
}
85+
if let Ok(v6) = host.parse::<Ipv6Addr>() {
86+
return v6.is_loopback();
87+
}
88+
false
89+
}
90+
91+
/// Does the origin authority match the request `Host` header?
92+
///
93+
/// `Host` carries no scheme; when it has no explicit port, the origin
94+
/// matches if its effective port is a scheme default (80/443).
95+
fn matches_host_header(origin: &OriginAuthority, host_header: &str) -> bool {
96+
let Some((host, host_port)) = split_host_port(host_header.trim()) else {
97+
return false;
98+
};
99+
if origin.0 != host {
100+
return false;
101+
}
102+
match host_port {
103+
Some(p) => origin.1 == p,
104+
None => origin.1 == 80 || origin.1 == 443,
105+
}
106+
}
107+
108+
/// Validate the request's `Origin` header against `policy`.
109+
///
110+
/// `Ok(())` admits the request; `Err(origin_value)` means the caller MUST
111+
/// respond 403 Forbidden.
112+
pub(crate) fn validate_origin(headers: &HeaderMap, policy: &OriginPolicy) -> Result<(), String> {
113+
if matches!(policy, OriginPolicy::Disabled) {
114+
return Ok(());
115+
}
116+
let Some(origin) = headers.get(hyper::header::ORIGIN) else {
117+
return Ok(()); // spec constrains only "present and invalid"
118+
};
119+
let Ok(origin) = origin.to_str() else {
120+
return Err("<non-ascii>".to_string());
121+
};
122+
123+
if let OriginPolicy::AllowList(allowed) = policy
124+
&& allowed.iter().any(|a| {
125+
a == origin
126+
|| matches!(
127+
(parse_origin(a), parse_origin(origin)),
128+
(Some(x), Some(y)) if x == y
129+
)
130+
})
131+
{
132+
return Ok(());
133+
}
134+
135+
let Some(parsed) = parse_origin(origin) else {
136+
return Err(origin.to_string()); // includes `Origin: null`
137+
};
138+
if is_loopback_host(&parsed.0) {
139+
return Ok(());
140+
}
141+
let host_header = headers
142+
.get(hyper::header::HOST)
143+
.and_then(|h| h.to_str().ok());
144+
if let Some(host) = host_header
145+
&& matches_host_header(&parsed, host)
146+
{
147+
return Ok(());
148+
}
149+
Err(origin.to_string())
150+
}
151+
152+
#[cfg(test)]
153+
mod tests {
154+
use super::*;
155+
use hyper::header::{HOST, ORIGIN};
156+
157+
fn headers(origin: Option<&str>, host: Option<&str>) -> HeaderMap {
158+
let mut h = HeaderMap::new();
159+
if let Some(o) = origin {
160+
h.insert(ORIGIN, o.parse().unwrap());
161+
}
162+
if let Some(hh) = host {
163+
h.insert(HOST, hh.parse().unwrap());
164+
}
165+
h
166+
}
167+
168+
#[test]
169+
fn absent_origin_is_allowed() {
170+
let p = OriginPolicy::SameOriginOrLoopback;
171+
assert!(validate_origin(&headers(None, Some("example.com")), &p).is_ok());
172+
}
173+
174+
#[test]
175+
fn loopback_origins_pass() {
176+
let p = OriginPolicy::SameOriginOrLoopback;
177+
for o in [
178+
"http://localhost",
179+
"http://localhost:3000",
180+
"http://127.0.0.1:9999",
181+
"http://127.8.4.2",
182+
"http://[::1]:8080",
183+
"https://LOCALHOST:8443",
184+
] {
185+
assert!(
186+
validate_origin(&headers(Some(o), Some("example.com")), &p).is_ok(),
187+
"{o} should pass"
188+
);
189+
}
190+
}
191+
192+
#[test]
193+
fn same_host_passes_with_port_normalization() {
194+
let p = OriginPolicy::SameOriginOrLoopback;
195+
for (o, host) in [
196+
("http://app.example:8080", "app.example:8080"),
197+
("http://app.example", "app.example"), // 80 vs portless
198+
("https://app.example", "app.example"), // 443 vs portless
199+
("https://APP.example:443", "app.example:443"),
200+
] {
201+
assert!(
202+
validate_origin(&headers(Some(o), Some(host)), &p).is_ok(),
203+
"{o} vs Host {host} should pass"
204+
);
205+
}
206+
}
207+
208+
#[test]
209+
fn cross_origin_null_and_garbage_are_rejected() {
210+
let p = OriginPolicy::SameOriginOrLoopback;
211+
for (o, host) in [
212+
("http://attacker.example", "127.0.0.1:8641"),
213+
("http://app.example:9000", "app.example:8080"), // port mismatch
214+
("null", "127.0.0.1:8641"),
215+
("not a url", "127.0.0.1:8641"),
216+
] {
217+
assert!(
218+
validate_origin(&headers(Some(o), Some(host)), &p).is_err(),
219+
"{o} vs Host {host} should be rejected"
220+
);
221+
}
222+
}
223+
224+
#[test]
225+
fn allowlist_is_additive_and_port_normalized() {
226+
let p = OriginPolicy::AllowList(vec!["https://app.example".into(), "null".into()]);
227+
let host = Some("127.0.0.1:8641");
228+
assert!(validate_origin(&headers(Some("https://app.example"), host), &p).is_ok());
229+
assert!(validate_origin(&headers(Some("https://app.example:443"), host), &p).is_ok());
230+
assert!(validate_origin(&headers(Some("null"), host), &p).is_ok());
231+
// additive: loopback still passes
232+
assert!(validate_origin(&headers(Some("http://localhost:3000"), host), &p).is_ok());
233+
// unlisted still rejected
234+
assert!(validate_origin(&headers(Some("https://other.example"), host), &p).is_err());
235+
}
236+
237+
#[test]
238+
fn disabled_skips_everything() {
239+
let p = OriginPolicy::Disabled;
240+
assert!(validate_origin(&headers(Some("http://attacker.example"), None), &p).is_ok());
241+
}
242+
}

crates/turul-http-mcp-server/src/server.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ pub struct ServerConfig {
4646
///
4747
/// Set to false for hardened deployments that require session for all methods.
4848
pub allow_unauthenticated_ping: bool,
49+
/// Origin-header validation policy (DNS-rebinding protection).
50+
///
51+
/// Streamable HTTP §Security: "If the `Origin` header is present and
52+
/// invalid, servers MUST respond with HTTP 403 Forbidden."
53+
pub origin_policy: crate::origin::OriginPolicy,
4954
}
5055

5156
impl Default for ServerConfig {
@@ -59,6 +64,7 @@ impl Default for ServerConfig {
5964
enable_post_sse: false, // Disabled by default for better client compatibility (e.g., MCP Inspector)
6065
session_expiry_minutes: 30, // 30 minutes default
6166
allow_unauthenticated_ping: true, // Allow pre-init pings per MCP spec
67+
origin_policy: crate::origin::OriginPolicy::default(),
6268
}
6369
}
6470
}
@@ -195,6 +201,12 @@ impl HttpMcpServerBuilder {
195201
///
196202
/// Default: `true` (sessionless pings allowed per MCP spec).
197203
/// Set to `false` for hardened deployments requiring session for all methods.
204+
/// Set the Origin-header validation policy (DNS-rebinding protection).
205+
pub fn origin_policy(mut self, policy: crate::origin::OriginPolicy) -> Self {
206+
self.config.origin_policy = policy;
207+
self
208+
}
209+
198210
pub fn allow_unauthenticated_ping(mut self, allow: bool) -> Self {
199211
self.config.allow_unauthenticated_ping = allow;
200212
self

crates/turul-http-mcp-server/src/session_handler.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,22 @@ impl SessionMcpHandler {
259259
req.method(),
260260
req.uri().path()
261261
);
262+
// DNS-rebinding protection (ADR-031): present-and-invalid Origin → 403.
263+
// OPTIONS preflight is exempt; the actual request is gated.
264+
if *req.method() != Method::OPTIONS
265+
&& let Err(origin) =
266+
crate::origin::validate_origin(req.headers(), &self.config.origin_policy)
267+
{
268+
warn!("Rejecting request with disallowed Origin: {origin}");
269+
let response: Response<JsonRpcBody> = Response::builder()
270+
.status(hyper::StatusCode::FORBIDDEN)
271+
.header("Content-Type", "text/plain")
272+
.body(Full::new(bytes::Bytes::from_static(
273+
b"Forbidden: Origin not allowed",
274+
)))
275+
.expect("static 403 response");
276+
return Ok(response.map(convert_to_unified_body));
277+
}
262278
match *req.method() {
263279
Method::POST => {
264280
let response = self.handle_json_rpc_request(req).await?;

crates/turul-http-mcp-server/src/streamable_http.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,24 @@ impl StreamableHttpHandler {
513513
.map(|body| body.map_err(|never| match never {}).boxed_unsync());
514514
}
515515

516+
// DNS-rebinding protection (ADR-031): "If the `Origin` header is
517+
// present and invalid, servers MUST respond with HTTP 403 Forbidden."
518+
// OPTIONS preflight (handled above) is exempt; the actual request is
519+
// gated here, ahead of validation, auth, and body parsing.
520+
if let Err(origin) =
521+
crate::origin::validate_origin(req.headers(), &self.config.origin_policy)
522+
{
523+
warn!("Rejecting request with disallowed Origin: {origin}");
524+
return Response::builder()
525+
.status(StatusCode::FORBIDDEN)
526+
.header("Content-Type", "text/plain")
527+
.body(Full::new(Bytes::from_static(
528+
b"Forbidden: Origin not allowed",
529+
)))
530+
.unwrap()
531+
.map(|body| body.map_err(|never| match never {}).boxed_unsync());
532+
}
533+
516534
// 2026-07-28 stateless core: the MCP endpoint accepts POST only. Legacy-era
517535
// GET (standalone SSE stream, optionally resumed via Last-Event-ID) and
518536
// DELETE (session termination) get 405 Method Not Allowed

0 commit comments

Comments
 (0)