From 53919bb7d4cd3bfdedb44df09174f4bee5e966d0 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Wed, 26 Aug 2026 14:32:32 +0100 Subject: [PATCH] enforce effective MCP object allowlists Signed-off-by: lucarlig --- _context/wiki/config.md | 9 ++++-- _context/wiki/failure-modes.md | 1 + _context/wiki/routing.md | 12 ++++++++ _context/wiki/security.md | 8 ++--- _context/wiki/testing.md | 2 +- .../src/user_store.rs | 2 ++ .../tests/user_store.rs | 16 ++++++++++ .../src/gateway/mcp_service/initialization.rs | 1 + .../src/gateway/mcp_service/prompts.rs | 7 +++++ .../src/gateway/mcp_service/resources.rs | 7 +++++ .../src/gateway/mcp_service/tools.rs | 7 +++++ .../tests/gateway_pagination.rs | 1 + .../tests/gateway_plugins.rs | 29 ++++++++++++++++++- .../tests/support/list_tools_gateway.rs | 5 ++-- .../tests/support/plugin_gateway.rs | 24 +++++++++++++-- .../tests/secrets_detection_e2e.rs | 3 +- schemas/user_config.json | 9 +++++- 17 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 crates/contextforge-data-plane-apis/tests/user_store.rs diff --git a/_context/wiki/config.md b/_context/wiki/config.md index a9f79880..ceadcb06 100644 --- a/_context/wiki/config.md +++ b/_context/wiki/config.md @@ -129,11 +129,14 @@ BackendMCPGateway add_headers: HashMap ← injected after passthrough remove_headers: Vec ← stripped after add tool_name_aliases: HashMap ← downstream_alias → upstream_original - allowed_tool_names: Vec ← model exists, NOT currently enforced - allowed_resource_names: Vec ← model exists, NOT currently enforced - allowed_prompt_names: Vec ← model exists, NOT currently enforced + allowed_tool_names: Vec ← enforced against resolved upstream tool names + allowed_resource_names: Vec ← display/catalog names; not sufficient for read authorization + allowed_resource_uris: Vec ← enforced against resolved upstream resource URIs + allowed_prompt_names: Vec ← enforced against resolved upstream prompt names ``` +An empty effective allowlist denies every targeted call in that object category. The control-plane publisher must populate `allowed_resource_uris` before this schema is deployed; its current `allowed_resource_names` values cannot authorize URI-based `resources/read` requests. + **Header apply order:** `passthrough_headers` → `add_headers` (override passthrough) → `remove_headers` (applied last). **`passthrough_headers` is session-scoped.** Values are snapshotted from the `initialize` request and baked into the backend transport for the session lifetime. Post-`initialize` calls (tool calls, list calls) reuse those headers. Request-scoped propagation requires per-request transport reconstruction (future work). diff --git a/_context/wiki/failure-modes.md b/_context/wiki/failure-modes.md index 94b25416..cf0bbd9d 100644 --- a/_context/wiki/failure-modes.md +++ b/_context/wiki/failure-modes.md @@ -27,6 +27,7 @@ | --- | --- | | Prefixed name doesn't start with backend name + `-` | Internal error | | No backend entry matches split name | Internal error (`got no responses from backends`) | +| Resolved tool, prompt, or resource URI absent from its effective allowlist | `-32602 Invalid params`; rejected before plugins or backend connection | | Backend entry exists but no running service | Internal error (backend failed during initialize) | | More than one backend entry matches | `INVALID_REQUEST`; session backend entries cleaned up | | Undecodable pagination cursor | `-32602 Invalid params` | diff --git a/_context/wiki/routing.md b/_context/wiki/routing.md index 1013311f..4b3ee981 100644 --- a/_context/wiki/routing.md +++ b/_context/wiki/routing.md @@ -46,6 +46,18 @@ gateway-oneincrement → rejected (no - separator) Methods using the same conditional routing: `read_resource`, `subscribe`, `unsubscribe`, `get_prompt`, `complete`. +## Effective Object Allowlists + +After alias or prefix resolution, targeted calls check the backend-local identifier against the selected backend's effective allowlist: + +| Method | Effective allowlist | +| --- | --- | +| `tools/call` | `allowed_tool_names` | +| `resources/read` | `allowed_resource_uris` | +| `prompts/get` | `allowed_prompt_names` | + +Missing entries return `INVALID_PARAMS` with the same not-found response as an unroutable object. Rejection happens before plugin hooks and backend connection setup, and an empty allowlist denies every object in that category. The control-plane publisher must supply resource URIs; resource display names are not valid authorization keys for `resources/read`. + ## Federated Pagination The gateway wraps per-backend cursors inside its own opaque token (JSON, treated as opaque by MCP clients). First request: all backends queried. Resume: cursor decoded, exhausted backends skipped. New cursor emitted when any backend has more pages. diff --git a/_context/wiki/security.md b/_context/wiki/security.md index 38a5b72b..886b154a 100644 --- a/_context/wiki/security.md +++ b/_context/wiki/security.md @@ -16,7 +16,7 @@ | --- | --- | | ContextForge control plane | Owns login/SSO, users, teams, IAM, API-token issuance and revocation, and external-dataplane configuration publication. `dataplane_publisher.py` writes visibility-filtered `UserConfig` snapshots to Redis by user email. | | ContextForge built-in dataplane | Owns the Python repository's MCP request routes, including old/new protocol and stateful/stateless behavior. | -| ContextForge external dataplane | Has no IAM or user database. It currently verifies modern MCP bearer JWTs locally, loads `UserConfig` by `sub`, and requires the requested virtual host to exist. No runtime control-plane call occurs. | +| ContextForge external dataplane | Has no IAM or user database. It verifies modern MCP bearer JWTs locally, loads `UserConfig` by `sub`, requires the requested virtual host to exist, and enforces effective object allowlists for targeted calls. No runtime control-plane call occurs. | External-dataplane request path: control-plane API token (`sub` = email) → Origin check → `claims_layer` → Redis config lookup → virtual-host check → RMCP Host check → @@ -29,9 +29,9 @@ external-dataplane contract. are required fields; `token_use`, `iat`, `teams`, and `scopes` are optional. - Failures: bad/missing JWT → `401`; no user config → `400`; unavailable virtual host → `404`. -- Authorization is currently coarse: valid JWT plus published virtual host. - JWT scopes/teams and object allowlists are not enforced; publishing a backend - exposes all objects returned by it. +- Authorization remains coarse for JWT scopes and teams, but targeted tool, + prompt, and resource-read calls are denied unless the resolved backend-local + identifier appears in the published effective object allowlist. - This coarse current behavior does not meet the tentative Phase 3 target. The target requires principal- and isolation-bound snapshots, per-request scope and compiled-RBAC enforcement, and default denial for missing or unauthorized diff --git a/_context/wiki/testing.md b/_context/wiki/testing.md index 607b9e63..4c3fa2a6 100644 --- a/_context/wiki/testing.md +++ b/_context/wiki/testing.md @@ -30,7 +30,7 @@ Protocol-sensitive tests and fixtures must cover MCP `2026-07-28` and `2025-11-2 | `gateway_list_tools.rs` | List fanout, prefixing, and merged output. | | `gateway_prompts.rs` | Prompt listing and prefixed `get_prompt` routing. | | `gateway_resource_templates.rs` | Template fanout with prefixed names and URI templates, plus `read_resource` round-trips. | -| `gateway_plugins.rs` | CPEX pre/post tool hooks around `call_tool` and stream events, and prompt hooks around `get_prompt`. | +| `gateway_plugins.rs` | Effective object allowlist rejection before backend connection, CPEX hooks, and request-scoped progress. | These run in `cargo nextest run` with no Docker dependencies. diff --git a/crates/contextforge-data-plane-apis/src/user_store.rs b/crates/contextforge-data-plane-apis/src/user_store.rs index 8ded3fac..049316fc 100644 --- a/crates/contextforge-data-plane-apis/src/user_store.rs +++ b/crates/contextforge-data-plane-apis/src/user_store.rs @@ -34,6 +34,8 @@ pub struct BackendMCPGateway { pub completion: HashMap, pub allowed_resource_names: Vec, + #[serde(default)] + pub allowed_resource_uris: Vec, pub allowed_prompt_names: Vec, pub allowed_tool_names: Vec, } diff --git a/crates/contextforge-data-plane-apis/tests/user_store.rs b/crates/contextforge-data-plane-apis/tests/user_store.rs new file mode 100644 index 00000000..f92cce5c --- /dev/null +++ b/crates/contextforge-data-plane-apis/tests/user_store.rs @@ -0,0 +1,16 @@ +use contextforge_data_plane_apis::user_store::BackendMCPGateway; + +#[test] +fn backend_config_without_resource_uri_allowlist_defaults_to_empty() { + let backend: BackendMCPGateway = serde_json::from_value(serde_json::json!({ + "name": "backend", + "url": "https://backend.example/mcp", + "passthrough_headers": [], + "allowed_resource_names": [], + "allowed_prompt_names": [], + "allowed_tool_names": [] + })) + .expect("legacy backend config should deserialize"); + + assert!(backend.allowed_resource_uris.is_empty()); +} diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs index 936c73b7..98372f82 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/initialization.rs @@ -168,6 +168,7 @@ mod tests { allowed_tool_names: vec![], tool_name_aliases: HashMap::new(), allowed_resource_names: vec![], + allowed_resource_uris: vec![], allowed_prompt_names: vec![], resource_name_aliases: HashMap::new(), prompt_name_aliases: HashMap::new(), diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs index e8b9a18e..955a4ad4 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/prompts.rs @@ -37,6 +37,13 @@ pub(super) async fn get_prompt( message: "Routing problem... backend not found".into(), data: None, })?; + if !backend.allowed_prompt_names.iter().any(|allowed| allowed == &prompt_name) { + return Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: "Routing problem... prompt not found".into(), + data: None, + }); + } let service_name = backend_name.clone(); let pre_result = if let Some(plugin_runtime) = &mcp_service.plugin_runtime { plugin_runtime.before_get_prompt(&request, &prompt_name, &service_name).await? diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs index 19474ed9..94598b2a 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/resources.rs @@ -37,6 +37,13 @@ pub(super) async fn read_resource( message: "Routing problem... backend not found".into(), data: None, })?; + if !backend.allowed_resource_uris.iter().any(|allowed| allowed == &resource_uri) { + return Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: "Routing problem... resource not found".into(), + data: None, + }); + } let service_name = backend_name.clone(); let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, &cx).await?; diff --git a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs index 950e7272..acac8d37 100644 --- a/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs +++ b/crates/contextforge-data-plane-lib/src/gateway/mcp_service/tools.rs @@ -36,6 +36,13 @@ pub(super) async fn call_tool( message: "Routing problem... backend not found".into(), data: None, })?; + if !backend.allowed_tool_names.iter().any(|allowed| allowed == &tool_name) { + return Err(ErrorData { + code: ErrorCode::INVALID_PARAMS, + message: "Routing problem... tool not found".into(), + data: None, + }); + } let service_name = backend_name.clone(); let pre_result = if let Some(plugin_runtime) = &mcp_service.plugin_runtime { plugin_runtime.before_tool_call(&request, &tool_name, &service_name).await? diff --git a/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs b/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs index c3c48e01..7178418f 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_pagination.rs @@ -30,6 +30,7 @@ fn paginating_backend(port: u16) -> BackendMCPGateway { allowed_tool_names: Vec::new(), tool_name_aliases: HashMap::new(), allowed_resource_names: Vec::new(), + allowed_resource_uris: Vec::new(), allowed_prompt_names: Vec::new(), resource_name_aliases: HashMap::new(), prompt_name_aliases: HashMap::new(), diff --git a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs index 51560f6a..13975c1f 100644 --- a/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs +++ b/crates/contextforge-data-plane-lib/tests/gateway_plugins.rs @@ -11,7 +11,7 @@ use rmcp::{ model::{ CallToolRequestParams, CallToolResult, ClientCapabilities, ClientRequest, ContentBlock, ErrorCode, GetPromptRequestParams, GetPromptResult, Implementation, InitializeRequestParams, ProgressNotificationParam, - Request, ResourceContents, Role as McpRole, ServerResult, + ReadResourceRequestParams, Request, ResourceContents, Role as McpRole, ServerResult, }, service::{NotificationContext, PeerRequestOptions, RequestHandle, RoleClient, RunningService}, }; @@ -403,6 +403,33 @@ async fn stateless_tool_error_round_trips() { assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn disallowed_objects_are_rejected_before_backend_connection() { + let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await; + let service = support::connect_modern_client( + gateway.gateway_url(), + support::create_client(TEST_USER_ID), + support::modern_client_info(), + ) + .await; + + for error in [ + service.call_tool(CallToolRequestParams::new("unapproved_tool")).await.unwrap_err(), + service.get_prompt(GetPromptRequestParams::new("unapproved_prompt")).await.unwrap_err(), + service.read_resource(ReadResourceRequestParams::new("file:///unapproved")).await.unwrap_err(), + ] { + let rmcp::service::ServiceError::McpError(error) = error else { + panic!("expected MCP allowlist error, got {error:?}"); + }; + assert_eq!(ErrorCode::INVALID_PARAMS, error.code); + } + assert_eq!( + 0, + *gateway.backend_state.connections.lock().expect("backend connections lock poisoned"), + "allowlist rejection must happen before backend connection" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn stateless_alias_and_namespaced_tool_names_route() { let gateway_port = support::create_ports(1)[0]; diff --git a/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs b/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs index f88d8afc..bc3eac1f 100644 --- a/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs +++ b/crates/contextforge-data-plane-lib/tests/support/list_tools_gateway.rs @@ -230,13 +230,14 @@ fn create_backends(ports: &[u16], with_tls: bool) -> HashMap>, pub(crate) calls: Arc>>, pub(crate) prompts: Arc>>, pub(crate) cancellations: Arc>>, @@ -312,7 +313,10 @@ async fn start_gateway_with_state( let backend_service = StreamableHttpService::new( { let backend_state = backend_state.clone(); - move || Ok(TestBackend { state: backend_state.clone() }) + move || { + *backend_state.connections.lock().expect("backend connections lock poisoned") += 1; + Ok(TestBackend { state: backend_state.clone() }) + } }, LocalSessionManager::default().into(), StreamableHttpServerConfig::default().with_json_response(json_backend_responses), @@ -335,10 +339,24 @@ async fn start_gateway_with_state( passthrough_headers: Vec::new(), add_headers: HashMap::default(), remove_headers: Vec::new(), - allowed_tool_names: Vec::new(), + allowed_tool_names: [ + "sum", + "progress_sum", + "progress_counter_tokens", + "reflect_text", + "wait_for_cancellation", + "missing_tool", + ] + .into_iter() + .map(str::to_owned) + .collect(), tool_name_aliases: HashMap::new(), allowed_resource_names: Vec::new(), - allowed_prompt_names: Vec::new(), + allowed_resource_uris: Vec::new(), + allowed_prompt_names: ["review", "review_bundle"] + .into_iter() + .map(str::to_owned) + .collect(), resource_name_aliases: HashMap::new(), prompt_name_aliases: HashMap::new(), completion: HashMap::new(), diff --git a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs index b197468b..e18d1861 100644 --- a/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs +++ b/crates/contextforge-data-plane/tests/secrets_detection_e2e.rs @@ -379,9 +379,10 @@ async fn write_redis_config(redis_port: u16, backend: &RunningBackend) { passthrough_headers: Vec::new(), add_headers: HashMap::new(), remove_headers: Vec::new(), - allowed_tool_names: Vec::new(), + allowed_tool_names: ["sum", "reflect_text"].into_iter().map(str::to_owned).collect(), tool_name_aliases: HashMap::new(), allowed_resource_names: Vec::new(), + allowed_resource_uris: Vec::new(), allowed_prompt_names: Vec::new(), resource_name_aliases: HashMap::new(), prompt_name_aliases: HashMap::new(), diff --git a/schemas/user_config.json b/schemas/user_config.json index 69576e9e..c28028ab 100644 --- a/schemas/user_config.json +++ b/schemas/user_config.json @@ -95,6 +95,13 @@ "type": "string" } }, + "allowed_resource_uris": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, "allowed_prompt_names": { "type": "array", "items": { @@ -118,4 +125,4 @@ ] } } -} \ No newline at end of file +}