From 92465c7f1fd87e9a47c6319c1ad6f71c8d65a260 Mon Sep 17 00:00:00 2001 From: nate nowack Date: Tue, 18 Aug 2026 11:42:34 -0500 Subject: [PATCH] Exclude Cookie from forwarded HTTP headers (#4843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Exclude Cookie from forwarded HTTP headers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) * Keep cookie readable through CurrentHeaders and document it 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/servers/dependency-injection.mdx | 10 +++- fastmcp_slim/fastmcp/server/dependencies.py | 18 ++++--- tests/server/http/test_http_dependencies.py | 60 +++++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx index 8d10b0ca0..0a3770532 100644 --- a/docs/servers/dependency-injection.mdx +++ b/docs/servers/dependency-injection.mdx @@ -199,7 +199,15 @@ def get_user_agent() -> str: return headers.get("user-agent", "Unknown") ``` -By default, problematic headers like `host` and `content-length` are excluded. Use `get_http_headers(include_all=True)` to include all headers. +By default, problematic headers like `host` and `content-length` are excluded, along with the credential headers `authorization` and `cookie`. Credentials are withheld because most callers forward whatever they receive, and a session cookie scoped to your MCP host should not reach a separate backend origin. + +To read a credential header, ask for it by name: + +```python +headers = get_http_headers(include={"cookie"}) +``` + +`CurrentHeaders()` already includes both credential headers, since it exposes the current request to your handler rather than forwarding it. Use `get_http_headers(include_all=True)` to include every header. ### Access Token diff --git a/fastmcp_slim/fastmcp/server/dependencies.py b/fastmcp_slim/fastmcp/server/dependencies.py index a6f06778b..e8006c991 100644 --- a/fastmcp_slim/fastmcp/server/dependencies.py +++ b/fastmcp_slim/fastmcp/server/dependencies.py @@ -547,9 +547,9 @@ def get_http_headers( Never raises an exception, even if there is no active HTTP request (in which case an empty dict is returned). - By default, strips problematic headers like `content-length` and `authorization` - that cause issues if forwarded to downstream services. If `include_all` is True, - all headers are returned. + By default, strips problematic headers like `content-length`, and credential + headers like `authorization` and `cookie`, that cause issues if forwarded to + downstream services. If `include_all` is True, all headers are returned. The `include` parameter allows specific headers to be included even if they would normally be excluded. This is useful for proxy transports that need to forward @@ -570,6 +570,7 @@ def get_http_headers( "expect", "accept", "authorization", + "cookie", # Proxy-related headers "proxy-authenticate", "proxy-authorization", @@ -1068,7 +1069,10 @@ class _CurrentHeaders(Dependency[dict[str, str]]): """Async context manager for HTTP Headers dependency.""" async def __aenter__(self) -> dict[str, str]: - return get_http_headers(include={"authorization"}) + # Credential headers are denied by default because most callers forward + # what they get. This dependency only exposes the current request to the + # handler, so it opts them back in. + return get_http_headers(include={"authorization", "cookie"}) async def __aexit__( self, @@ -1083,9 +1087,9 @@ def CurrentHeaders() -> dict[str, str]: """Get the current HTTP request headers. This dependency provides access to the HTTP headers for the current request, - including the authorization header. Returns an empty dictionary when no HTTP - request is available, making it safe to use in code that might run over any - transport. + including the `authorization` and `cookie` headers, which `get_http_headers()` + withholds by default. Returns an empty dictionary when no HTTP request is + available, making it safe to use in code that might run over any transport. Returns: A dependency that resolves to a dictionary of header name -> value diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index 704baebc3..36be3de6b 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -177,6 +177,66 @@ async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer): assert headers["x-custom-header"] == "should-be-included" +async def test_get_http_headers_excludes_cookie(sse_server: ASGIServer): + """get_http_headers() must not leak the caller's Cookie to a backend. + + The OpenAPI provider forwards this mapping to the upstream named in the + spec, so a session cookie scoped to the MCP host would otherwise reach a + separate origin on every tool call. Callers that genuinely need it can ask + for it back with `include={"cookie"}`, the same escape hatch authorization + uses. + """ + from fastmcp.server.dependencies import get_http_headers + + server = FastMCP() + + @server.tool + def default_headers() -> dict[str, str]: + return get_http_headers() + + @server.tool + def opted_in_headers() -> dict[str, str]: + return get_http_headers(include={"cookie"}) + + async with asgi_server(server, transport="sse") as running_server: + async with running_server.client( + headers={"Cookie": "session=alice-secret", "X-Keep": "yes"} + ) as client: + default = (await client.call_tool("default_headers")).data + assert "cookie" not in default + assert default["x-keep"] == "yes" + + opted_in = (await client.call_tool("opted_in_headers")).data + assert opted_in["cookie"] == "session=alice-secret" + + +async def test_current_headers_still_exposes_cookie(sse_server: ASGIServer): + """CurrentHeaders() reads the request, so credentials stay visible. + + The default denylist protects call sites that forward headers upstream. + A handler inspecting its own request needs the cookie, the same way it + already needs authorization. + """ + from fastmcp.server.dependencies import CurrentHeaders + + server = FastMCP() + + @server.tool + def read_request(headers: dict = CurrentHeaders()) -> dict[str, str]: + return headers + + async with asgi_server(server, transport="sse") as running_server: + async with running_server.client( + headers={ + "Cookie": "session=alice-secret", + "Authorization": "Bearer alice-token", + } + ) as client: + headers = (await client.call_tool("read_request")).data + assert headers["cookie"] == "session=alice-secret" + assert headers["authorization"] == "Bearer alice-token" + + def _worker_snapshot_headers() -> dict[str, str]: """Read the HTTP headers snapshotted at task submission from inside a worker.""" task_info = get_task_context()