diff --git a/Cargo.lock b/Cargo.lock index f4ae014..50a3e04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,6 +666,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "typed-builder", + "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index ea9a3f8..d879d40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ reqwest = "0.13" rustls = { version = "0.23", features = ["ring"] } cpex = "=0.2.2" uuid = { version = "1.23.1", features = ["v4"] } +url = { version = "2.5", features = ["serde"] } axum = "0.8" openport = { version = "0.4.0", features = ["rand"] } secrets_detection_rust = { git = "https://github.com/IBM/cpex-plugins", rev = "6ff7af74587574fe6115ce87427519b63f6062da", package = "secrets_detection_rust" } diff --git a/crates/contextforge-gateway-rs-apis/Cargo.toml b/crates/contextforge-gateway-rs-apis/Cargo.toml index 478c641..2cbbe7f 100644 --- a/crates/contextforge-gateway-rs-apis/Cargo.toml +++ b/crates/contextforge-gateway-rs-apis/Cargo.toml @@ -12,7 +12,7 @@ keywords.workspace = true cpex.workspace = true serde= {workspace = true, features=["derive"]} serde_json.workspace = true -url = { version = "2.5.8", features = ["serde"] } +url = { workspace = true } schemars = { version = "1.2.1", features = ["url2", "preserve_order"] } [lints] diff --git a/crates/contextforge-gateway-rs-lib/Cargo.toml b/crates/contextforge-gateway-rs-lib/Cargo.toml index 09ce27e..7d22ab5 100644 --- a/crates/contextforge-gateway-rs-lib/Cargo.toml +++ b/crates/contextforge-gateway-rs-lib/Cargo.toml @@ -41,6 +41,7 @@ rustls.workspace = true rustls-pki-types = { version = "1.14.1", features = ["std", "alloc"] } tokio-rustls = "0.26.4" typed-builder = "0.23.2" +url = { workspace = true, features = ["serde"] } secret-string = "0.0.2" diff --git a/crates/contextforge-gateway-rs-lib/src/common.rs b/crates/contextforge-gateway-rs-lib/src/common.rs index d4d4ab5..a60c214 100644 --- a/crates/contextforge-gateway-rs-lib/src/common.rs +++ b/crates/contextforge-gateway-rs-lib/src/common.rs @@ -5,6 +5,7 @@ use std::{ path::PathBuf, sync::Arc, }; +use url::Origin; use clap::{Parser, ValueEnum}; use http::uri::Authority; @@ -257,12 +258,106 @@ pub struct Config { #[arg(long, env = "CONTEXTFORGE_GATEWAY_LOG_ROTATION")] pub log_rotation: Option, + + /// Allowlist of browser Origins permitted on MCP Streamable HTTP requests. + /// + /// Each entry must be a fully-qualified origin with scheme, e.g. + /// `https://app.example.com` or `http://localhost:3000`. + /// Port comparison is exact after RFC 3986 default-port normalization: + /// `https://app.example.com` and `https://app.example.com:443` are + /// equivalent; `https://app.example.com:8443` is distinct. + /// + /// Behaviour when this list is **non-empty**: + /// - No `Origin` header → accepted (native/non-browser clients). + /// - `Origin` present and matching an entry → accepted. + /// - `Origin` present, malformed, `null`, or not in the list → HTTP 403. + /// + /// Behaviour when this list is **empty** (default): + /// - No `Origin` header → accepted. + /// - `Origin` present → **HTTP 403** (no same-origin fallback; an empty + /// allowlist is not a bypass). + /// + /// Supply multiple origins as a comma-separated string: + /// `https://app.example.com,https://other.example.com` + #[arg( + long, + env = "CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS", + value_delimiter = ',', + num_args = 0.. + )] + pub mcp_allowed_origins: Vec, + + /// Pre-parsed form of `mcp_allowed_origins`, populated by [`Config::finalize`]. + /// + /// Using `#[clap(skip)]` keeps this invisible to the CLI / env-var parser; + /// it is always derived from `mcp_allowed_origins` and never set directly. + #[clap(skip)] + pub mcp_parsed_origins: Vec, + + /// Allowlist of `Host` header values (authorities) trusted on inbound MCP + /// requests, used as the companion DNS-rebinding control. + /// + /// Each entry is a hostname or `host:port` authority, e.g. + /// `gateway.example.com` or `gateway.example.com:8080`. + /// Port is optional; an entry without a port matches that host on any port. + /// + /// When this list is **non-empty**, any request whose `Host` header does + /// not match an entry is rejected with HTTP 403 before Origin validation. + /// + /// When this list is **empty** (default), Host validation is disabled. + /// For deployments exposed directly to the internet, set this alongside + /// `mcp_allowed_origins`. + /// + /// Supply multiple hosts as a comma-separated string: + /// `gateway.example.com,gateway.example.com:443` + #[arg( + long, + env = "CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS", + value_delimiter = ',', + num_args = 0.. + )] + pub mcp_allowed_hosts: Vec, +} + +impl Config { + /// Parses `mcp_allowed_origins` into typed [`Origin`] values and stores + /// them in `mcp_parsed_origins`. Call this once after clap parsing + /// completes so the middleware can compare against pre-parsed values + /// instead of re-parsing on every request. + /// + /// Returns an error if any configured origin string is invalid. + /// The error message names all invalid entries so the operator can correct + /// the configuration without restarting repeatedly. + /// + /// # Errors + /// + /// Returns [`ConfigValidationError::InvalidMcpAllowedOrigins`] when one or + /// more entries in `mcp_allowed_origins` cannot be parsed as a valid + /// serialized origin. + pub fn finalize(&mut self) -> Result<(), ConfigValidationError> { + use crate::layers::mcp_origin::parse_origin_str; + let mut parsed = Vec::with_capacity(self.mcp_allowed_origins.len()); + let mut invalid = Vec::new(); + for s in &self.mcp_allowed_origins { + match parse_origin_str(s) { + Some(origin) => parsed.push(origin), + None => invalid.push(s.as_str()), + } + } + if !invalid.is_empty() { + return Err(ConfigValidationError::InvalidMcpAllowedOrigins(invalid.join(", "))); + } + self.mcp_parsed_origins = parsed; + Ok(()) + } } #[derive(Error, Debug)] pub enum ConfigValidationError { #[error("Redis Configuration Error")] RedisConfigurationError(String), + #[error("Invalid mcp_allowed_origins entries: {0}")] + InvalidMcpAllowedOrigins(String), } impl TryFrom<&Config> for RedisConfig { diff --git a/crates/contextforge-gateway-rs-lib/src/layers/mcp_origin.rs b/crates/contextforge-gateway-rs-lib/src/layers/mcp_origin.rs new file mode 100644 index 0000000..432e44a --- /dev/null +++ b/crates/contextforge-gateway-rs-lib/src/layers/mcp_origin.rs @@ -0,0 +1,860 @@ +use axum::{body::Body, extract::State, middleware::Next, response::Response}; +use http::{StatusCode, header, uri::Authority}; +use tracing::{debug, warn}; +use url::{Origin, Url}; + +use crate::common::Config; + +// ── Origin parsing ──────────────────────────────────────────────────────────── + +/// Strictly parses a serialized RFC 6454 origin string into a typed +/// [`url::Origin`]. +/// +/// A valid serialized origin is exactly `scheme "://" host [":" port]` with +/// **no** userinfo, path, query, or fragment component. The `url` crate +/// silently repairs many malformed inputs (backslashes, userinfo stripping, +/// etc.), so this function validates the raw string before handing it to the +/// parser: +/// +/// - Contains `\` → rejected (backslash normalization attack). +/// - Contains `@` before the first `/` → rejected (userinfo present). +/// - Contains `?` or `#` → rejected (query / fragment present). +/// +/// Returns `None` for the literal `"null"` opaque origin (RFC 6454 §6.2) and +/// for any value that fails the checks above. +/// +/// Port normalization is handled by the `url` crate: `https://blah.com:443` +/// and `https://blah.com` produce the same `Origin::Tuple`; `https://blah.com:8443` +/// is distinct. +fn parse_origin(raw: &str) -> Option { + // ── Pre-parse structural checks on the raw string ───────────────────── + + // Leading/trailing whitespace is not valid in a serialized origin + // (RFC 6454 §6.1) and the url crate silently trims it, so reject early. + if raw != raw.trim() { + return None; + } + + if raw.trim().eq_ignore_ascii_case("null") { + return None; + } + + // Backslash — the url crate treats it as a slash (WHATWG URL §5.1). + if raw.contains('\\') { + return None; + } + // Userinfo — "@" before the first "/" after the scheme separator. + // A valid origin has no path, so any "@" means userinfo. + if raw.contains('@') { + return None; + } + // Query / fragment. + if raw.contains('?') || raw.contains('#') { + return None; + } + + // A serialized origin is exactly `scheme "://" host [":" port]`. + // This rejects "https:///…" and "https:////…" where the url crate silently + // collapses the extra slashes into a valid URL. + let Some((_, authority_part)) = raw.split_once("://") else { + return None; // no "://" at all + }; + // Extra leading slashes after "://" mean the authority is empty or wrong. + if authority_part.starts_with('/') || authority_part.is_empty() { + return None; + } + // A trailing ":" with no port digits is malformed (e.g. "https://host:"). + // Strip any IPv6 brackets first so "[::1]:" is also caught. + let host_for_port_check = authority_part.trim_start_matches('['); + if let Some((_, port_part)) = host_for_port_check.rsplit_once(':') + && port_part.is_empty() + { + return None; + } + + // Append "/" so the url crate accepts a bare `scheme://host[:port]` string. + let url = Url::parse(&format!("{raw}/")).ok()?; + + // ── Post-parse structural checks ────────────────────────────────────── + // Path must be exactly the "/" we appended. + if url.path() != "/" { + return None; + } + // Re-check userinfo fields (defense-in-depth, url crate may strip "@"). + if !url.username().is_empty() || url.password().is_some() { + return None; + } + // No query or fragment. + if url.query().is_some() || url.fragment().is_some() { + return None; + } + // Must have a host. + url.host()?; + + // Reject opaque origins (data:, blob:, …). + match url.origin() { + Origin::Tuple(_, _, _) => Some(url.origin()), + Origin::Opaque(_) => None, + } +} + +/// Public-to-crate entry point for [`Config::finalize`] to validate configured +/// origins at startup without exposing the private `parse_origin` function. +pub(crate) fn parse_origin_str(raw: &str) -> Option { + parse_origin(raw) +} + +// ── Host allowlist ──────────────────────────────────────────────────────────── + +/// Parses the `Host` header (or HTTP/2 `:authority` pseudo-header) into an +/// [`Authority`]. +fn request_authority(request: &http::Request) -> Option { + request + .headers() + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .or_else(|| request.uri().authority().cloned()) +} + +/// Returns `true` when `authority` matches at least one entry in +/// `allowed_hosts`. +/// +/// Entries are plain hostnames (`gateway.example.com`) or `host:port` +/// authorities (`gateway.example.com:8080`) — no scheme prefix. +/// +/// - Entry **without** a port → matches that host on **any** port. +/// - Entry **with** a port → matches only that exact `(host, port)` pair. +/// +/// Comparison is case-insensitive on the host component. +fn authority_in_allowlist(authority: &Authority, allowed_hosts: &[String]) -> bool { + let request_host = authority.host().to_ascii_lowercase(); + let request_port = authority.port_u16(); + + allowed_hosts.iter().any(|entry| { + let (entry_host, entry_port) = match entry.rsplit_once(':') { + Some((h, p)) => match p.parse::() { + Ok(port) => (h.to_ascii_lowercase(), Some(port)), + Err(_) => (entry.to_ascii_lowercase(), None), + }, + None => (entry.to_ascii_lowercase(), None), + }; + entry_host == request_host && entry_port.is_none_or(|p| Some(p) == request_port) + }) +} + +// ── Response helpers ────────────────────────────────────────────────────────── + +fn forbidden_response() -> Response { + Response::builder() + .status(StatusCode::FORBIDDEN) + .header(header::CONTENT_TYPE, "text/plain") + .body(Body::from("Forbidden: Origin header is not allowed")) + .expect("response should build") +} + +// ── Middleware ──────────────────────────────────────────────────────────────── + +/// Axum middleware that enforces the MCP 2026-07-28 Streamable HTTP +/// DNS-rebinding protection requirement. +/// +/// Per : +/// +/// > 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. +/// +/// ## Decision table +/// +/// | Condition | Result | +/// |---|---| +/// | `mcp_allowed_hosts` set, `Host` not in list | ❌ 403 | +/// | `Origin` absent | ✅ accept (native / non-browser clients) | +/// | `Origin: null` | ❌ 403 | +/// | `Origin` malformed, has backslash / userinfo / path / query / fragment | ❌ 403 | +/// | `mcp_allowed_origins` non-empty, parsed `Origin` in list | ✅ accept | +/// | `mcp_allowed_origins` non-empty, parsed `Origin` not in list | ❌ 403 | +/// | `mcp_allowed_origins` **empty** (default) | ❌ 403 — no fallback | +/// +/// **There is no same-origin fallback.** A present `Origin` always requires +/// an explicit trusted allowlist (`CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS`). +/// An empty allowlist is not a bypass; it rejects every `Origin` that is present. +/// +/// Port comparison uses [`url::Origin`] typed equality, which normalizes +/// default ports: `https://app.example.com:443` and `https://app.example.com` +/// are the same origin; `https://app.example.com:8443` is different. +/// +/// This layer fires before JWT claims validation, session creation, and any +/// backend fan-out. +pub async fn mcp_origin_layer(State(config): State, request: http::Request, next: Next) -> Response { + // ── 1. Host allowlist check ─────────────────────────────────────────────── + if !config.mcp_allowed_hosts.is_empty() { + match request_authority(&request) { + None => { + warn!("mcp_origin_layer - rejected request: Host header missing or unparseable"); + return forbidden_response(); + }, + Some(ref authority) if !authority_in_allowlist(authority, &config.mcp_allowed_hosts) => { + warn!("mcp_origin_layer - rejected request: Host not in allowlist host = {authority}"); + return forbidden_response(); + }, + Some(_) => debug!("mcp_origin_layer - Host is in allowlist"), + } + } + + // ── 2. Origin header check ──────────────────────────────────────────────── + let Some(origin_header) = request.headers().get(header::ORIGIN) else { + // No Origin header → native / non-browser client; always allow. + debug!("mcp_origin_layer - no Origin header, allowing request"); + return next.run(request).await; + }; + + let Ok(origin_str) = origin_header.to_str() else { + warn!("mcp_origin_layer - rejected non-UTF-8 Origin header"); + return forbidden_response(); + }; + + // Opaque / sandbox origin — always rejected regardless of config. + if origin_str.trim().eq_ignore_ascii_case("null") { + warn!("mcp_origin_layer - rejected opaque null Origin"); + return forbidden_response(); + } + + let Some(request_origin) = parse_origin(origin_str) else { + warn!("mcp_origin_layer - rejected malformed Origin header origin = {origin_str}"); + return forbidden_response(); + }; + + // ── 3. Allowlist check ──────────────────────────────────────────────────── + // An empty allowlist is not a bypass: any present Origin is rejected until + // the operator explicitly configures trusted origins. + if config.mcp_parsed_origins.is_empty() { + warn!("mcp_origin_layer - rejected Origin: no allowed origins configured origin = {origin_str}"); + return forbidden_response(); + } + + if config.mcp_parsed_origins.contains(&request_origin) { + debug!("mcp_origin_layer - Origin accepted via allowlist origin = {origin_str}"); + next.run(request).await + } else { + warn!("mcp_origin_layer - rejected Origin not in allowlist origin = {origin_str}"); + forbidden_response() + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use axum::{Router, body::to_bytes, middleware, routing::get}; + use http::{Request, StatusCode}; + use tower::ServiceExt; + use url::Origin; + + use super::*; + + // ── helpers ─────────────────────────────────────────────────────────────── + + fn config_origins(origins: &[&str]) -> Config { + let mut c = + Config { mcp_allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(), ..Config::default() }; + c.finalize().expect("test origins should be valid"); + c + } + + fn config_hosts(hosts: &[&str]) -> Config { + Config { mcp_allowed_hosts: hosts.iter().map(|s| (*s).to_owned()).collect(), ..Config::default() } + } + + fn config_origins_and_hosts(origins: &[&str], hosts: &[&str]) -> Config { + let mut c = Config { + mcp_allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(), + mcp_allowed_hosts: hosts.iter().map(|s| (*s).to_owned()).collect(), + ..Config::default() + }; + c.finalize().expect("test origins should be valid"); + c + } + + fn make_app(config: Config) -> axum::Router { + Router::new() + .route("/mcp", get(handler).post(handler).delete(handler)) + .layer(middleware::from_fn_with_state(config.clone(), mcp_origin_layer)) + .with_state(config) + } + + async fn handler() -> StatusCode { + StatusCode::NO_CONTENT + } + + // ── parse_origin unit tests ─────────────────────────────────────────────── + + #[test] + fn null_origin_returns_none() { + assert!(parse_origin("null").is_none()); + assert!(parse_origin("NULL").is_none()); + assert!(parse_origin("Null").is_none()); + } + + #[test] + fn empty_origin_returns_none() { + assert!(parse_origin("").is_none()); + } + + #[test] + fn origin_without_scheme_returns_none() { + assert!(parse_origin("app.example.com").is_none()); + } + + #[test] + fn origin_with_path_returns_none() { + assert!(parse_origin("https://app.example.com/some/path").is_none()); + } + + #[test] + fn origin_with_trailing_slash_returns_none() { + assert!(parse_origin("https://app.example.com/").is_none()); + } + + #[test] + fn origin_with_query_returns_none() { + assert!(parse_origin("https://app.example.com?q=1").is_none()); + } + + #[test] + fn origin_with_fragment_returns_none() { + assert!(parse_origin("https://app.example.com#frag").is_none()); + } + + #[test] + fn origin_with_userinfo_returns_none() { + // "@" in the raw string is caught before parsing. + assert!(parse_origin("https://user@app.example.com").is_none()); + } + + #[test] + fn origin_with_backslash_returns_none() { + // url crate silently normalizes backslash to "/"; pre-parse check blocks it. + assert!(parse_origin(r"https:\app.example.com").is_none()); + assert!(parse_origin(r"https:\\app.example.com").is_none()); + } + + #[test] + fn origin_with_data_scheme_returns_none() { + // data: produces an opaque origin. + assert!(parse_origin("data:text/plain,foo").is_none()); + } + + #[test] + fn https_default_port_443_equals_portless() { + let portless = parse_origin("https://app.example.com").unwrap(); + let explicit = parse_origin("https://app.example.com:443").unwrap(); + assert_eq!(portless, explicit, "https://blah.com:443 must equal https://blah.com"); + } + + #[test] + fn http_default_port_80_equals_portless() { + let portless = parse_origin("http://app.example.com").unwrap(); + let explicit = parse_origin("http://app.example.com:80").unwrap(); + assert_eq!(portless, explicit); + } + + #[test] + fn non_default_port_8443_is_distinct_from_portless() { + let portless = parse_origin("https://app.example.com").unwrap(); + let non_default = parse_origin("https://app.example.com:8443").unwrap(); + assert_ne!(portless, non_default, "https://blah.com:8443 must NOT equal https://blah.com"); + } + + #[test] + fn parse_origin_is_case_insensitive_on_scheme_and_host() { + let lower = parse_origin("https://app.example.com").unwrap(); + let upper = parse_origin("HTTPS://APP.EXAMPLE.COM").unwrap(); + assert_eq!(lower, upper); + } + + #[test] + fn ipv6_origin_parsed_correctly() { + // IPv6 address produces a valid Tuple origin. + let o = parse_origin("http://[::1]:8080").unwrap(); + assert!(matches!(o, Origin::Tuple(_, _, 8080))); + } + + // ── parse_origin: new strict syntax regressions ─────────────────────────── + + #[test] + fn extra_slashes_after_scheme_returns_none() { + // "https:///…" and "https:////…" — url crate collapses these to a valid + // host but they are not valid serialized origins. + assert!(parse_origin("https:///app.example.com").is_none()); + assert!(parse_origin("https:////app.example.com").is_none()); + } + + #[test] + fn trailing_colon_without_port_returns_none() { + // "https://app.example.com:" — url crate accepts this as no-port. + assert!(parse_origin("https://app.example.com:").is_none()); + } + + #[test] + fn leading_whitespace_returns_none() { + // url crate silently trims leading/trailing whitespace. + assert!(parse_origin(" https://app.example.com").is_none()); + assert!(parse_origin("https://app.example.com ").is_none()); + } + + // ── parse_origin_str / Config::finalize unit tests ──────────────────────── + + #[test] + fn empty_configured_origins_produces_empty_parsed_list() { + let mut c = Config::default(); + assert!(c.finalize().is_ok()); + assert!(c.mcp_parsed_origins.is_empty()); + } + + // ── Config::finalize startup-error tests ────────────────────────────────── + + #[test] + fn finalize_with_valid_origins_succeeds() { + let mut c = Config { mcp_allowed_origins: vec!["https://app.example.com".to_owned()], ..Config::default() }; + assert!(c.finalize().is_ok()); + assert_eq!(c.mcp_parsed_origins.len(), 1); + } + + #[test] + fn finalize_with_invalid_origin_returns_error() { + let mut c = Config { + mcp_allowed_origins: vec!["https://valid.example.com".to_owned(), r"https:\bad".to_owned()], + ..Config::default() + }; + let err = c.finalize().unwrap_err(); + assert!(err.to_string().contains(r"https:\bad")); + // mcp_parsed_origins must not be partially populated on error. + assert!(c.mcp_parsed_origins.is_empty()); + } + + #[test] + fn finalize_reports_all_invalid_origins_in_error() { + let mut c = Config { + mcp_allowed_origins: vec![r"https:\bad1".to_owned(), "https:////bad2.example.com".to_owned()], + ..Config::default() + }; + let err = c.finalize().unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains(r"https:\bad1"), "expected bad1 in: {msg}"); + assert!(msg.contains("https:////bad2.example.com"), "expected bad2 in: {msg}"); + } + + // ── authority_in_allowlist unit tests ───────────────────────────────────── + + #[test] + fn authority_exact_host_match() { + let auth = "gateway.example.com".parse::().unwrap(); + assert!(authority_in_allowlist(&auth, &["gateway.example.com".to_owned()])); + } + + #[test] + fn authority_entry_without_port_matches_any_port() { + let auth = "gateway.example.com:8080".parse::().unwrap(); + assert!(authority_in_allowlist(&auth, &["gateway.example.com".to_owned()])); + } + + #[test] + fn authority_entry_with_port_matches_only_that_port() { + let auth8080 = "gateway.example.com:8080".parse::().unwrap(); + let auth443 = "gateway.example.com:443".parse::().unwrap(); + assert!(authority_in_allowlist(&auth8080, &["gateway.example.com:8080".to_owned()])); + assert!(!authority_in_allowlist(&auth443, &["gateway.example.com:8080".to_owned()])); + } + + #[test] + fn authority_mismatch_returns_false() { + let auth = "evil.example.com".parse::().unwrap(); + assert!(!authority_in_allowlist(&auth, &["gateway.example.com".to_owned()])); + } + + // ── middleware: no Origin ───────────────────────────────────────────────── + + #[tokio::test] + async fn no_origin_accepted_with_empty_config() { + let app = make_app(Config::default()); + let req = Request::builder().uri("/mcp").method("GET").body(Body::empty()).unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn no_origin_accepted_with_allowlist_configured() { + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder().uri("/mcp").method("GET").body(Body::empty()).unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + // ── middleware: empty allowlist rejects any present Origin ──────────────── + + #[tokio::test] + async fn present_origin_with_empty_allowlist_returns_403() { + // Empty allowlist is not a bypass; any present Origin must be rejected. + let app = make_app(Config::default()); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn attacker_controlled_host_and_origin_match_but_still_rejected_without_allowlist() { + // DNS-rebinding: attacker controls both Host and Origin to the same value. + // Without an explicit allowlist this must be rejected, not accepted. + let app = make_app(Config::default()); + let req = Request::builder() + .uri("http://attacker.invalid/mcp") + .method("POST") + .header(header::HOST, "attacker.invalid") + .header(header::ORIGIN, "http://attacker.invalid") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + // ── middleware: allowlist (non-empty) ───────────────────────────────────── + + #[tokio::test] + async fn allowlisted_origin_accepted() { + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn non_allowlisted_origin_returns_403() { + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://attacker.invalid") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn allowlisted_origin_with_explicit_default_port_accepted() { + // Browser sends :443 explicitly; allowlist has no port — same origin. + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://app.example.com:443") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn allowlist_entry_with_443_accepts_portless_origin() { + // Allowlist has :443; browser sends no port — same origin. + let app = make_app(config_origins(&["https://app.example.com:443"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn non_default_port_not_in_allowlist_returns_403() { + // Allowlist entry normalizes to :443; :8443 is a different origin. + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://app.example.com:8443") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn allowlist_with_8443_does_not_match_default_port() { + // Allowlist entry is :8443; portless request is :443 — different origin. + let app = make_app(config_origins(&["https://app.example.com:8443"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn multiple_origins_in_allowlist_all_accepted() { + let app = make_app(config_origins(&["https://app.example.com", "http://localhost:3000"])); + for origin in &["https://app.example.com", "http://localhost:3000"] { + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, *origin) + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT, "expected accept for {origin}"); + } + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://other.invalid") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + // ── middleware: HTTPS origin-form requests ──────────────────────────────── + + #[tokio::test] + async fn https_origin_accepted_when_allowlisted_origin_form_request() { + // A normal HTTP/1.1 request has URI `/mcp` (origin-form, no scheme). + // The scheme cannot be inferred from the request URI; only the Origin + // header value matters for the allowlist comparison. + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + // origin-form URI — no scheme + .uri("/mcp") + .method("POST") + .header(header::HOST, "app.example.com") + .header(header::ORIGIN, "https://app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn http_origin_rejected_when_only_https_allowlisted_origin_form_request() { + // Origin: http://... must not match an allowlist entry for https://... + // even when the request URI has no scheme and Host matches. + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::HOST, "app.example.com") + .header(header::ORIGIN, "http://app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + // ── middleware: malformed-but-normalizable Origins ──────────────────────── + + #[tokio::test] + async fn backslash_origin_returns_403() { + // url crate would normalize https:\app.example.com to https://app.example.com + // but pre-parse check must reject it first. + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, r"https:\app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn userinfo_origin_returns_403() { + // url crate strips userinfo from the origin; we must reject before that. + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://user@app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn origin_with_query_returns_403() { + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://app.example.com?q=1") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn origin_with_fragment_returns_403() { + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://app.example.com#frag") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + // ── middleware: null / malformed (always 403) ───────────────────────────── + + #[tokio::test] + async fn null_origin_returns_403_with_allowlist() { + let app = make_app(config_origins(&["https://app.example.com"])); + let req = + Request::builder().uri("/mcp").method("POST").header(header::ORIGIN, "null").body(Body::empty()).unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn null_origin_returns_403_without_allowlist() { + let app = make_app(Config::default()); + let req = + Request::builder().uri("/mcp").method("POST").header(header::ORIGIN, "null").body(Body::empty()).unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn malformed_origin_returns_403() { + let app = make_app(Config::default()); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "not-an-origin") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + // ── middleware: DELETE method ───────────────────────────────────────────── + + #[tokio::test] + async fn delete_allowlisted_origin_accepted() { + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("DELETE") + .header(header::ORIGIN, "https://app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn delete_non_allowlisted_origin_returns_403() { + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("DELETE") + .header(header::ORIGIN, "https://attacker.invalid") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + // ── middleware: Host allowlist ──────────────────────────────────────────── + + #[tokio::test] + async fn request_with_allowed_host_passes_host_check() { + let app = make_app(config_hosts(&["gateway.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("GET") + .header(header::HOST, "gateway.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn request_with_disallowed_host_returns_403() { + let app = make_app(config_hosts(&["gateway.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::HOST, "evil.example.com") + .header(header::ORIGIN, "https://app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn host_and_origin_both_valid_accepted() { + let app = make_app(config_origins_and_hosts(&["https://app.example.com"], &["gateway.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::HOST, "gateway.example.com") + .header(header::ORIGIN, "https://app.example.com") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn valid_host_but_invalid_origin_returns_403() { + let app = make_app(config_origins_and_hosts(&["https://app.example.com"], &["gateway.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::HOST, "gateway.example.com") + .header(header::ORIGIN, "https://attacker.invalid") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + // ── misc ────────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn forbidden_response_body_is_non_empty() { + let app = make_app(config_origins(&["https://app.example.com"])); + let req = Request::builder() + .uri("/mcp") + .method("POST") + .header(header::ORIGIN, "https://attacker.invalid") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + let body = to_bytes(res.into_body(), 256).await.unwrap(); + assert!(!body.is_empty()); + } +} diff --git a/crates/contextforge-gateway-rs-lib/src/layers/mod.rs b/crates/contextforge-gateway-rs-lib/src/layers/mod.rs index d6356e5..e4b5275 100644 --- a/crates/contextforge-gateway-rs-lib/src/layers/mod.rs +++ b/crates/contextforge-gateway-rs-lib/src/layers/mod.rs @@ -1,4 +1,5 @@ pub mod claims_id; +pub mod mcp_origin; pub mod session_id; pub mod user_config_store; pub mod virtual_host_config; diff --git a/crates/contextforge-gateway-rs-lib/src/lib.rs b/crates/contextforge-gateway-rs-lib/src/lib.rs index 07104ae..bf49129 100644 --- a/crates/contextforge-gateway-rs-lib/src/lib.rs +++ b/crates/contextforge-gateway-rs-lib/src/lib.rs @@ -40,6 +40,7 @@ use crate::{ gateway::LocalUserSessionStore, layers::{ claims_id::claims_layer, + mcp_origin::mcp_origin_layer, session_id::{SessionIdState, session_id_layer}, user_config_store::user_config_store_layer, virtual_host_config::virtual_host_config_layer, @@ -64,7 +65,10 @@ pub struct Gateway { } impl Gateway { - pub async fn run_gateway(self) -> Result<()> { + pub async fn run_gateway(mut self) -> Result<()> { + // Parse mcp_allowed_origins into typed url::Origin values once at startup. + // Returns an error (and aborts startup) if any configured origin is invalid. + self.config.finalize()?; let config = &self.config; let session_manager = self.session_manager; let user_config_store = match self.user_config_store_type { @@ -81,7 +85,17 @@ impl Gateway { }; let mcp_plugin_runtime = self.plugin_runtime; - let streamable_config = StreamableHttpServerConfig::default().disable_allowed_hosts(); + // Host and Origin validation is owned by the outer mcp_origin_layer. + // Disable RMCP's built-in checks so they do not conflict with ours. + // When the operator has configured an allowed-hosts list, pass it to + // RMCP as well for defense-in-depth; RMCP's list uses bare hostnames. + let streamable_config = if config.mcp_allowed_hosts.is_empty() { + StreamableHttpServerConfig::default().disable_allowed_hosts().disable_allowed_origins() + } else { + StreamableHttpServerConfig::default() + .with_allowed_hosts(config.mcp_allowed_hosts.iter().map(String::as_str)) + .disable_allowed_origins() + }; let reqwest_backend_client = reqwest::Client::try_from(config)?; @@ -135,7 +149,10 @@ impl Gateway { .layer(middleware::from_fn_with_state(session_id_state, session_id_layer)) .layer(middleware::from_fn_with_state(mcp_add_state.clone(), claims_layer)) .layer(middleware::from_fn(virtual_host_id_layer)) - .layer(cors_layer); + .layer(cors_layer) + // mcp_origin_layer is the outermost wrapper: fires before JWT auth, + // session creation, and backend fan-out. + .layer(middleware::from_fn_with_state(config.clone(), mcp_origin_layer)); #[cfg(feature = "with_tools")] let app = tools::add_tools(app); diff --git a/docs/book/src/request-flow.md b/docs/book/src/request-flow.md index f4320b1..94cc1c6 100644 --- a/docs/book/src/request-flow.md +++ b/docs/book/src/request-flow.md @@ -62,6 +62,7 @@ TCP/TLS listener -> HttpMetricsLayer -> TraceLayer -> /contextforge-rs nested router + -> mcp_origin_layer (MCP 2026-07-28: Host allowlist check, then Origin allowlist; absent Origin passes, empty allowlist rejects every present Origin) -> CORS layer -> virtual_host_id_layer -> claims_layer @@ -103,6 +104,7 @@ The request layers insert the context used later by RMCP handlers: | Layer | Request behavior | Failure behavior | | --- | --- | --- | +| `mcp_origin_layer` | (1) If `mcp_allowed_hosts` is set, rejects `Host` not in the list. (2) Absent `Origin` passes. (3) `Origin` must be in `mcp_allowed_origins`; an empty allowlist rejects every present `Origin` (no same-origin fallback). Strict serialized-origin syntax enforced; ports normalized per RFC 3986. | Returns `403` for disallowed `Host`, non-allowlisted, opaque (`null`), malformed, or extra-slash `Origin`. | | `virtual_host_id_layer` | Extracts `/servers/{virtual_host_id}/mcp` and inserts `VirtualHostId`. | Returns `400` when the inner path does not match. | | `claims_layer` | Validates `Authorization: Bearer ...` with configured RS/HMAC decoder, issuer, audience, and expiration. Inserts `ContextForgeClaims`. | Returns `401` for missing or invalid bearer auth. | | `session_id_layer` | Reads `Mcp-session-id` and inserts `SessionId` when present. | Missing session id is allowed here; authorized MCP handlers reject it later when required. | @@ -202,5 +204,6 @@ cursor when more pages remain across any backend. The HTTP response then unwinds through `virtual_host_config_layer`, `user_config_store_layer`, `session_id_layer`, `claims_layer`, -`virtual_host_id_layer`, CORS, trace, and metrics. On successful `DELETE`, `session_id_layer` performs local session and -backend transport cleanup during this unwind. +`virtual_host_id_layer`, CORS, `mcp_origin_layer`, trace, and metrics. On +successful `DELETE`, `session_id_layer` performs local session and backend +transport cleanup during this unwind. diff --git a/docs/book/src/security-model.md b/docs/book/src/security-model.md index d1643a7..4a06150 100644 --- a/docs/book/src/security-model.md +++ b/docs/book/src/security-model.md @@ -49,9 +49,58 @@ Authentication is bearer-JWT only: | Upstream | HTTPS-only by default; plain HTTP must be opted into with `--upstream-connection-mode`. mTLS client identity is supported per process. | | Redis | Plain, TLS, or mTLS via `--redis-mode`. Use TLS or mTLS anywhere Redis crosses a trust zone, because Redis is the config trust boundary. | -CORS is currently wide open (any origin, method, and header). The API is -bearer-token based and cookie-free, so cross-site request forgery does not -apply, but expect this to tighten as policy work lands. +## MCP Origin and Host Validation + +The gateway enforces the MCP 2026-07-28 Streamable HTTP transport +[DNS-rebinding security requirement](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http): + +> 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. + +`mcp_origin_layer` is the single enforcement point for both Origin and Host +validation. It fires before JWT claims verification, session creation, +virtual-host lookup, and backend fan-out. + +### Host allowlist (`CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS`) + +An optional comma-separated list of trusted `Host` authorities +(`gateway.example.com` or `gateway.example.com:8080`). + +When **non-empty**: requests whose `Host` header does not match an entry are +rejected with **HTTP 403** before Origin validation is attempted. An entry +without a port matches that host on any port; an entry with a port matches only +that exact port. + +When **empty** (default): Host validation is disabled. Recommended to set +alongside `mcp_allowed_origins` for public-internet deployments. + +### Origin allowlist (`CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS`) + +A comma-separated list of fully-qualified browser origins +(`https://app.example.com`, `http://localhost:3000`). + +| `mcp_allowed_origins` | `Origin` absent | `Origin` in list | `Origin` not in list | `null` / malformed | +| --- | --- | --- | --- | --- | +| **non-empty** | ✅ accepted | ✅ accepted | ❌ HTTP 403 | ❌ HTTP 403 | +| **empty** (default) | ✅ accepted | ❌ HTTP 403 | ❌ HTTP 403 | ❌ HTTP 403 | + +**An empty allowlist is not a bypass.** When `mcp_allowed_origins` is not +configured, every request that carries an `Origin` header is rejected with HTTP +403. There is no same-origin fallback: comparing `Origin` with `Host` would let +a DNS-rebinding attacker satisfy both values simultaneously, defeating the +protection entirely. + +Origins are strictly validated before comparison: backslash sequences, userinfo +(`@`), path, query, and fragment components cause immediate rejection, preventing +the `url` crate's WHATWG-compliant normalization from silently repairing +malformed inputs into a valid origin. + +Port comparison uses typed `url::Origin` equality after RFC 3986 default-port +normalization: `https://app.example.com` and `https://app.example.com:443` are +the same origin; `https://app.example.com:8443` is a different origin. +Configured origins are parsed once at startup; invalid entries are logged and +skipped. ## Local Bootstrap Helpers