diff --git a/Cargo.lock b/Cargo.lock index d0314ca8..6abd30c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -649,6 +649,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "typed-builder", + "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 7ed72eb5..eacf3e93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] } 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"] } cpex-secrets-detection = { path = "./crates/plugins/cpex-secrets-detection" } diff --git a/crates/contextforge-data-plane-apis/Cargo.toml b/crates/contextforge-data-plane-apis/Cargo.toml index 4440f6cf..ebf09dff 100644 --- a/crates/contextforge-data-plane-apis/Cargo.toml +++ b/crates/contextforge-data-plane-apis/Cargo.toml @@ -15,7 +15,7 @@ repository.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-data-plane-lib/Cargo.toml b/crates/contextforge-data-plane-lib/Cargo.toml index 90df766f..6505887b 100644 --- a/crates/contextforge-data-plane-lib/Cargo.toml +++ b/crates/contextforge-data-plane-lib/Cargo.toml @@ -44,6 +44,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-data-plane-lib/src/common.rs b/crates/contextforge-data-plane-lib/src/common.rs index 0ee8b324..b736b35e 100644 --- a/crates/contextforge-data-plane-lib/src/common.rs +++ b/crates/contextforge-data-plane-lib/src/common.rs @@ -1,11 +1,3 @@ -use std::{ - fs::{self, File}, - io::{Cursor, Read}, - net::SocketAddr, - path::PathBuf, - sync::Arc, -}; - use clap::{Parser, ValueEnum}; use http::uri::Authority; use jsonwebtoken::DecodingKey; @@ -13,8 +5,16 @@ use redis::{ConnectionAddr, IntoConnectionInfo, RedisError}; use rustls_pki_types::{CertificateDer, PrivatePkcs8KeyDer, pem::PemObject}; use secret_string::SecretString; use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File}, + io::{Cursor, Read}, + net::SocketAddr, + path::PathBuf, + sync::Arc, +}; use thiserror::Error; use typed_builder::TypedBuilder; +use url::Url; use crate::user_config_store::UserConfigStore; @@ -257,6 +257,22 @@ pub struct Config { #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_LOG_ROTATION")] pub log_rotation: Option, + + #[arg( + long, + env = "CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS", + value_delimiter = ',', + num_args = 1.. + )] + pub mcp_allowed_origins: Option>, + + #[arg( + long, + env = "CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS", + value_delimiter = ',', + num_args = 1.. + )] + pub mcp_allowed_hosts: Option>, } #[derive(Error, Debug)] diff --git a/crates/contextforge-data-plane-lib/src/layers/mcp_origin.rs b/crates/contextforge-data-plane-lib/src/layers/mcp_origin.rs new file mode 100644 index 00000000..302bcf88 --- /dev/null +++ b/crates/contextforge-data-plane-lib/src/layers/mcp_origin.rs @@ -0,0 +1,573 @@ +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; + +fn parse_origin(raw: &str) -> Option { + let url = Url::parse(&format!("{raw}/")).ok()?; + if url.path() != "/" || !url.username().is_empty() || url.password().is_some() { + return None; + } + if url.query().is_some() || url.fragment().is_some() { + return None; + } + match url.origin() { + Origin::Tuple(_, _, _) => Some(url.origin()), + Origin::Opaque(_) => None, + } +} + +#[cfg(test)] +pub(crate) fn parse_origin_str(raw: &str) -> Option { + parse_origin(raw) +} + +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()) +} + +fn authority_in_allowlist(authority: &Authority, allowed_hosts: &[Authority]) -> bool { + let request_port = authority.port_u16(); + allowed_hosts.iter().any(|entry| { + entry.host().eq_ignore_ascii_case(authority.host()) && entry.port_u16().is_none_or(|p| Some(p) == request_port) + }) +} + +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") +} + +/// MCP 2026-07-28 DNS-rebinding protection middleware. +pub async fn mcp_origin_layer(State(config): State, request: http::Request, next: Next) -> Response { + if let Some(ref allowed_hosts) = config.mcp_allowed_hosts { + 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, 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"), + } + } + + let Some(origin_header) = request.headers().get(header::ORIGIN) else { + 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(); + }; + + let Some(request_origin) = parse_origin(origin_str) else { + warn!("mcp_origin_layer - rejected malformed Origin header origin = {origin_str}"); + return forbidden_response(); + }; + + let Some(ref allowed_origins) = config.mcp_allowed_origins else { + warn!("mcp_origin_layer - rejected Origin: no allowed origins configured origin = {origin_str}"); + return forbidden_response(); + }; + + let allowed = allowed_origins.iter().map(Url::origin).any(|o| o == request_origin); + if allowed { + 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, Url}; + + use super::*; + + // ── helpers ─────────────────────────────────────────────────────────────── + + fn config_origins(origins: &[&str]) -> Config { + Config { + mcp_allowed_origins: Some(origins.iter().map(|s| s.parse::().unwrap()).collect()), + ..Config::default() + } + } + + fn config_hosts(hosts: &[&str]) -> Config { + Config { + mcp_allowed_hosts: Some(hosts.iter().map(|s| s.parse::().unwrap()).collect()), + ..Config::default() + } + } + + fn config_origins_and_hosts(origins: &[&str], hosts: &[&str]) -> Config { + Config { + mcp_allowed_origins: Some(origins.iter().map(|s| s.parse::().unwrap()).collect()), + mcp_allowed_hosts: Some(hosts.iter().map(|s| s.parse::().unwrap()).collect()), + ..Config::default() + } + } + + 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_data_scheme_returns_none() { + 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); + } + + #[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); + } + + #[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() { + let o = parse_origin("http://[::1]:8080").unwrap(); + assert!(matches!(o, Origin::Tuple(_, _, 8080))); + } + + // ── parse_origin_str unit tests ─────────────────────────────────────────── + + #[test] + fn parse_origin_str_accepts_valid_origin() { + assert!(parse_origin_str("https://app.example.com").is_some()); + } + + #[test] + fn parse_origin_str_rejects_invalid_origin() { + assert!(parse_origin_str("not-an-origin").is_none()); + } + + // ── 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".parse::().unwrap()])); + } + + #[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".parse::().unwrap()])); + } + + #[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".parse::().unwrap()])); + assert!(!authority_in_allowlist(&auth443, &["gateway.example.com:8080".parse::().unwrap()])); + } + + #[test] + fn authority_mismatch_returns_false() { + let auth = "evil.example.com".parse::().unwrap(); + assert!(!authority_in_allowlist(&auth, &["gateway.example.com".parse::().unwrap()])); + } + + // ── 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() { + 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() { + 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() { + 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() { + 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() { + 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() { + 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() { + 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, "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() { + 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: 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-data-plane-lib/src/layers/mod.rs b/crates/contextforge-data-plane-lib/src/layers/mod.rs index d6356e56..e4b52754 100644 --- a/crates/contextforge-data-plane-lib/src/layers/mod.rs +++ b/crates/contextforge-data-plane-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-data-plane-lib/src/lib.rs b/crates/contextforge-data-plane-lib/src/lib.rs index 3f71ef6a..4a7f9b32 100644 --- a/crates/contextforge-data-plane-lib/src/lib.rs +++ b/crates/contextforge-data-plane-lib/src/lib.rs @@ -4,6 +4,7 @@ use axum::middleware; use axum_otel_metrics::HttpMetricsLayerBuilder; use contextforge_data_plane_cpex::GatewayPluginRuntimeHandle; use futures::FutureExt; +use http::uri::Authority; use jsonwebtoken::DecodingKey; use rmcp::transport::{ StreamableHttpServerConfig, @@ -40,6 +41,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, @@ -81,7 +83,15 @@ impl Gateway { }; let mcp_plugin_runtime = self.plugin_runtime; - let streamable_config = StreamableHttpServerConfig::default().disable_allowed_hosts(); + // mcp_origin_layer is the sole enforcement point; disable RMCP's built-in checks. + // Pass the host list to RMCP as well when configured (defense-in-depth). + let streamable_config = if let Some(ref hosts) = config.mcp_allowed_hosts { + StreamableHttpServerConfig::default() + .with_allowed_hosts(hosts.iter().map(Authority::as_str)) + .disable_allowed_origins() + } else { + StreamableHttpServerConfig::default().disable_allowed_hosts().disable_allowed_origins() + }; let reqwest_backend_client = reqwest::Client::try_from(config)?; @@ -135,7 +145,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 de553445..ba907306 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 e6dfb034..cccc096f 100644 --- a/docs/book/src/security-model.md +++ b/docs/book/src/security-model.md @@ -49,9 +49,59 @@ 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 validated at startup by the clap `value_parser`; any +invalid entry causes startup to abort with a formatted error before the process +reaches `run_gateway`. ## Local Bootstrap Helpers