Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion _context/wiki/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ TCP/TLS listener
-> /contextforge-rs nested router
-> mcp_origin_layer → validates Host then Origin (403 when disallowed)
-> CORS layer
-> mcp_header_limits_layer → MCP standard header budgets (431 when exceeded)
-> virtual_host_id_layer → inserts VirtualHostId (400 on path mismatch)
-> claims_layer → inserts ContextForgeClaims (401 on bad/missing JWT)
-> session_id_layer → inserts SessionId if present
Expand All @@ -23,14 +24,17 @@ TCP/TLS listener
checks the optional Host allowlist first, then rejects any present Origin that
is malformed or not allowlisted; requests without Origin continue. See
[Security](security.md#mcp-origin-and-host-validation).
`mcp_header_limits_layer` rejects excessive MCP standard headers before JWT
validation, config lookup, session creation, backend fanout, or RMCP body
parsing.

MCP handlers read typed extensions — they never parse headers, paths, or Redis keys directly.

## Pipeline Shape

```text
downstream request
-> Host/Origin validation → virtual host extraction → JWT validation → session extraction
-> Host/Origin validation → MCP header limits → virtual host extraction → JWT validation → session extraction
-> user config lookup → MCP handler validation
-> request plugin hooks
-> backend MCP call (concurrent via join_all for initialize/list)
Expand Down
14 changes: 14 additions & 0 deletions _context/wiki/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,19 @@ Origin and Host settings retain the explicitly configured
| --- | --- | --- | --- |
| `--mcp-allowed-origins <origin,...>` | `CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS` | None | Browser Origin allowlist. Without it, requests lacking `Origin` pass and every request carrying `Origin` receives HTTP `403`. |
| `--mcp-allowed-hosts <authority,...>` | `CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS` | None | Optional request-authority allowlist. When configured, missing, malformed, or unlisted authorities receive HTTP `403`. |
| `--mcp-standard-header-max-count <n>` | `CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_COUNT` | `32` | Maximum MCP standard headers accepted on one request. |
| `--mcp-standard-header-max-value-bytes <n>` | `CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_VALUE_BYTES` | `8192` | Maximum byte length accepted for one MCP standard header value. |
| `--mcp-standard-header-max-total-bytes <n>` | `CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES` | `65536` | Approximate maximum combined bytes across MCP standard header names and values. |

Values are comma-separated. Origin entries must be fully qualified serialized
origins such as `https://app.example.com`; Host entries are authorities such as
`gateway.example.com` or `gateway.example.com:8443`. See [Security](security.md#mcp-origin-and-host-validation).
The MCP standard header limits apply to `Mcp-Method`, `Mcp-Name`,
`Mcp-Protocol-Version`, `Mcp-Session-Id`, and `Mcp-Param-*`. A configured value
of `0` is treated as the documented default. The byte totals are
application-level budgets based on header name and value lengths; they are not
exact wire-size accounting and do not model HTTP/2 header compression.
Non-MCP headers remain bounded by the HTTP transport.

### Redis

Expand Down Expand Up @@ -134,6 +143,11 @@ BackendMCPGateway
| Hop-by-hop | `Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `Proxy-Connection`, `TE`, `Trailer`, `Trailers`, `Transfer-Encoding`, `Upgrade` |
| RMCP-reserved | `Mcp-Session-Id`, `Accept`, `Last-Event-Id` |
| Gateway-managed | `Host` (set from backend URL host + port; never overridden by config) |
| Computed MCP standard | `Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, `Mcp-Param-*` |

`Authorization` and `Cookie` are not protected here because backend
authentication through `passthrough_headers` or `add_headers` is intentional
runtime configuration.

Redis storage: `MessagePack(User::new(sub))` → `MessagePack(UserConfig)`.

Expand Down
7 changes: 7 additions & 0 deletions _context/wiki/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ or path/query/fragment/userinfo-bearing origins are rejected. Default ports are
normalized (`https://a` equals `https://a:443`). There is no same-origin
fallback; configure both allowlists for public deployments.

`mcp_header_limits_layer` enforces configurable count, per-value byte, and
approximate total byte budgets for MCP standard request headers before JWT
validation or RMCP body parsing. That budget covers `Mcp-Method`, `Mcp-Name`,
`Mcp-Protocol-Version`, `Mcp-Session-Id`, and `Mcp-Param-*`. It is an
application-level guard for MCP standard headers only; non-MCP headers remain
bounded by the HTTP transport.

## Local Bootstrap Helpers (`with_tools`)

The `contextforge-data-plane-lib/with_tools` feature compiles in:
Expand Down
16 changes: 16 additions & 0 deletions crates/contextforge-data-plane-lib/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,18 @@ pub struct Config {
#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")]
pub otlp_metrics_endpoint: Option<http::Uri>,

/// Maximum number of MCP standard headers accepted on a single request.
#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_COUNT", default_value_t = DEFAULT_MCP_STANDARD_HEADER_MAX_COUNT)]
pub mcp_standard_header_max_count: usize,

/// Maximum byte length accepted for a single MCP standard header value.
#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_VALUE_BYTES", default_value_t = DEFAULT_MCP_STANDARD_HEADER_MAX_VALUE_BYTES)]
pub mcp_standard_header_max_value_bytes: usize,

/// Approximate maximum total bytes accepted across MCP standard headers.
#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES", default_value_t = DEFAULT_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES)]
pub mcp_standard_header_max_total_bytes: usize,

#[arg(long, env = "CONTEXTFORGE_DATA_PLANE_NUMBER_OF_CPUS")]
pub number_of_cpus: Option<usize>,

Expand Down Expand Up @@ -275,6 +287,10 @@ pub struct Config {
pub mcp_allowed_hosts: Option<Vec<Authority>>,
}

pub const DEFAULT_MCP_STANDARD_HEADER_MAX_COUNT: usize = 32;
pub const DEFAULT_MCP_STANDARD_HEADER_MAX_VALUE_BYTES: usize = 8 * 1024;
pub const DEFAULT_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES: usize = 64 * 1024;

#[derive(Error, Debug)]
pub enum ConfigValidationError {
#[error("Redis Configuration Error")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::gateway::{
mcp_call_validator::InitializeCallValidator,
session_store::{UserSession, UserSessionStore},
};
use crate::mcp_standard_headers;

pub(super) async fn initialize<T>(
mcp_service: &McpService<T>,
Expand Down Expand Up @@ -247,6 +248,7 @@ fn apply_header_config(
/// - Hop-by-hop (RFC 7230 §6.1): `Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Trailers`, `Transfer-Encoding`, `Upgrade`
/// - Non-standard hop-by-hop: `Proxy-Connection` (must not cross gateway boundary)
/// - RMCP transport-reserved: `Mcp-Session-Id`, `Accept`, `Last-Event-Id`
/// - MCP standard computed headers: `Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, `Mcp-Param-*`
fn is_protected_header(name: &http::HeaderName) -> bool {
const PROTECTED: &[&str] = &[
"host",
Expand All @@ -270,7 +272,7 @@ fn is_protected_header(name: &http::HeaderName) -> bool {
"accept",
"last-event-id",
];
PROTECTED.iter().any(|&p| name.as_str().eq_ignore_ascii_case(p))
PROTECTED.iter().any(|&p| name.as_str().eq_ignore_ascii_case(p)) || mcp_standard_headers::is_computed(name)
}

#[cfg(test)]
Expand Down Expand Up @@ -406,6 +408,36 @@ mod tests {
assert!(headers.is_empty(), "no RMCP-reserved header must reach the upstream config");
}

#[test]
fn computed_mcp_headers_cannot_be_passed_through_added_or_removed() {
let mut headers = HashMap::new();
headers.insert(http::HeaderName::from_static("mcp-method"), http::HeaderValue::from_static("tools/call"));
headers.insert(http::HeaderName::from_static("mcp-param-user"), http::HeaderValue::from_static("computed"));
let ds = downstream(&[
("Mcp-Method", "wrong/method"),
("Mcp-Name", "wrong-tool"),
("Mcp-Protocol-Version", "2020-01-01"),
("Mcp-Param-User", "wrong-user"),
]);
let cfg = backend(
&["mcp-method", "mcp-name", "mcp-protocol-version", "mcp-param-user"],
&[
("Mcp-Method", "added/method"),
("Mcp-Name", "added-tool"),
("Mcp-Protocol-Version", "2020-01-01"),
("Mcp-Param-User", "added-user"),
],
&["mcp-method", "mcp-param-user"],
);

apply_header_config(&mut headers, &cfg, Some(&ds));

assert_eq!(headers[&http::HeaderName::from_static("mcp-method")], "tools/call");
assert_eq!(headers[&http::HeaderName::from_static("mcp-param-user")], "computed");
assert!(!headers.contains_key(&http::HeaderName::from_static("mcp-name")));
assert!(!headers.contains_key(&http::HeaderName::from_static("mcp-protocol-version")));
}

#[test]
fn body_framing_and_connection_management_headers_cannot_be_forwarded() {
let mut headers = HashMap::new();
Expand Down
227 changes: 227 additions & 0 deletions crates/contextforge-data-plane-lib/src/layers/mcp_header_limits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
use axum::{body::Body, extract::State, middleware::Next, response::Response};
use http::{StatusCode, header};
use tracing::debug;

use crate::common::{
Config, DEFAULT_MCP_STANDARD_HEADER_MAX_COUNT, DEFAULT_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES,
DEFAULT_MCP_STANDARD_HEADER_MAX_VALUE_BYTES,
};
use crate::mcp_standard_headers;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct McpStandardHeaderLimits {
pub(crate) count: usize,
pub(crate) value_bytes: usize,
pub(crate) total_bytes: usize,
}

impl McpStandardHeaderLimits {
pub(crate) fn from_config(config: &Config) -> Self {
Self {
count: configured_or_default(config.mcp_standard_header_max_count, DEFAULT_MCP_STANDARD_HEADER_MAX_COUNT),
value_bytes: configured_or_default(
config.mcp_standard_header_max_value_bytes,
DEFAULT_MCP_STANDARD_HEADER_MAX_VALUE_BYTES,
),
total_bytes: configured_or_default(
config.mcp_standard_header_max_total_bytes,
DEFAULT_MCP_STANDARD_HEADER_MAX_TOTAL_BYTES,
),
}
}
}

fn configured_or_default(configured: usize, default: usize) -> usize {
if configured == 0 { default } else { configured }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct McpStandardHeaderUsage {
count: usize,
value_bytes: usize,
total_bytes: usize,
}

pub(crate) async fn mcp_header_limits_layer(
State(limits): State<McpStandardHeaderLimits>,
request: http::Request<axum::body::Body>,
next: Next,
) -> Response {
if let Some(usage) = exceeded_limits(request.headers(), limits) {
let count = usage.count;
let value_bytes = usage.value_bytes;
let total_bytes = usage.total_bytes;
debug!(
"mcp_header_limits_layer - rejecting request count = {count} value_bytes = {value_bytes} total_bytes = {total_bytes}"
);
return Response::builder()
.status(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from("MCP standard header limits exceeded"))
.expect("Expecting this to work");
}

next.run(request).await
}

fn exceeded_limits(headers: &http::HeaderMap, limits: McpStandardHeaderLimits) -> Option<McpStandardHeaderUsage> {
let mut count = 0usize;
let mut total_bytes = 0usize;

for (name, value) in headers.iter().filter(|(name, _)| mcp_standard_headers::is_limited(name)) {
count = count.saturating_add(1);
if count > limits.count {
return Some(McpStandardHeaderUsage { count, value_bytes: 0, total_bytes });
}

let value_bytes = value.as_bytes().len();
if value_bytes > limits.value_bytes {
return Some(McpStandardHeaderUsage { count, value_bytes, total_bytes });
}

// Application budget only: this is not exact HTTP/1 wire size and does
// not model HTTP/2 HPACK compression.
total_bytes = total_bytes.saturating_add(name.as_str().len()).saturating_add(value_bytes);
if total_bytes > limits.total_bytes {
return Some(McpStandardHeaderUsage { count, value_bytes, total_bytes });
}
}

None
}

#[cfg(test)]
mod tests {
use std::sync::Arc;

use async_trait::async_trait;
use axum::{Router, body::Body, middleware, response::Response, routing::get};
use contextforge_data_plane_apis::{User, user_store::UserConfig};
use http::{Request, StatusCode};
use tower::ServiceExt;

use crate::{
Config,
common::{ContextForgeDataPlaneAppState, JwtTokenDecoders},
layers::{
claims_id::claims_layer,
mcp_header_limits::{McpStandardHeaderLimits, mcp_header_limits_layer},
},
user_config_store::{ConfigStoreError, UserConfigStore},
};

async fn ok() -> Response {
Response::builder().status(StatusCode::OK).body(Body::empty()).expect("Expecting this to work")
}

fn app(limits: McpStandardHeaderLimits) -> Router {
Router::new().route("/", get(ok)).layer(middleware::from_fn_with_state(limits, mcp_header_limits_layer))
}

fn request_with_headers(headers: &[(&str, &str)]) -> Request<Body> {
let mut builder = Request::builder().uri("/");
for (name, value) in headers {
builder = builder.header(*name, *value);
}
builder.body(Body::empty()).expect("Expecting this to work")
}

#[tokio::test]
async fn rejects_too_many_mcp_headers() {
let limits = McpStandardHeaderLimits { count: 2, value_bytes: 1024, total_bytes: 4096 };
let response = app(limits)
.oneshot(request_with_headers(&[
("Mcp-Method", "tools/call"),
("Mcp-Name", "example"),
("Mcp-Param-User", "alice"),
]))
.await
.expect("Expecting this to work");

assert_eq!(response.status(), StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE);
}

#[tokio::test]
async fn rejects_oversized_mcp_header_value() {
let limits = McpStandardHeaderLimits { count: 32, value_bytes: 4, total_bytes: 4096 };
let response = app(limits)
.oneshot(request_with_headers(&[("Mcp-Param-User", "alice")]))
.await
.expect("Expecting this to work");

assert_eq!(response.status(), StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE);
}

#[tokio::test]
async fn rejects_excessive_total_mcp_header_bytes() {
let limits = McpStandardHeaderLimits { count: 32, value_bytes: 16, total_bytes: 24 };
let response = app(limits)
.oneshot(request_with_headers(&[("Mcp-Method", "tools/call"), ("Mcp-Name", "example")]))
.await
.expect("Expecting this to work");

assert_eq!(response.status(), StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE);
}

#[tokio::test]
async fn counts_mcp_headers_case_insensitively() {
let limits = McpStandardHeaderLimits { count: 1, value_bytes: 1024, total_bytes: 4096 };
let response = app(limits)
.oneshot(request_with_headers(&[("McP-MeThOd", "tools/call"), ("mCp-PaRaM-User", "alice")]))
.await
.expect("Expecting this to work");

assert_eq!(response.status(), StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE);
}

#[tokio::test]
async fn ignores_non_mcp_headers_for_mcp_specific_budget() {
let limits = McpStandardHeaderLimits { count: 1, value_bytes: 1024, total_bytes: 4096 };
let response = app(limits)
.oneshot(request_with_headers(&[
("X-One", "1"),
("X-Two", "2"),
("X-Three", "3"),
("Mcp-Method", "tools/call"),
]))
.await
.expect("Expecting this to work");

assert_eq!(response.status(), StatusCode::OK);
}

#[derive(Clone)]
struct UnusedConfigStore;

#[async_trait]
impl UserConfigStore for UnusedConfigStore {
async fn get_config<'a>(&self, _key: &'a User) -> Result<UserConfig, ConfigStoreError> {
unreachable!("mcp header limit rejection must run before config lookup")
}

async fn set_config<'a>(&self, _key: &'a User, _user_config: &'a UserConfig) -> Result<(), ConfigStoreError> {
unreachable!("mcp header limit rejection must run before config lookup")
}
}

#[tokio::test]
async fn rejects_excessive_mcp_headers_before_auth() {
let limits = McpStandardHeaderLimits { count: 1, value_bytes: 1024, total_bytes: 4096 };
let state = ContextForgeDataPlaneAppState {
jwt_token_decoding_keys: JwtTokenDecoders { rs: None, hmac_sha: None },
config_store: Arc::new(UnusedConfigStore),
config: Config::default(),
};
let app = Router::new()
.route("/", get(ok))
.layer(middleware::from_fn_with_state(state, claims_layer))
.layer(middleware::from_fn_with_state(limits, mcp_header_limits_layer));

let response = app
.oneshot(request_with_headers(&[("Mcp-Method", "tools/call"), ("Mcp-Name", "example")]))
.await
.expect("Expecting this to work");

assert_eq!(response.status(), StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE);
}
}
1 change: 1 addition & 0 deletions crates/contextforge-data-plane-lib/src/layers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod claims_id;
pub mod mcp_header_limits;
pub mod mcp_origin;
pub mod session_id;
pub mod user_config_store;
Expand Down
Loading
Loading