Exclude Cookie from forwarded HTTP headers (#4843)

* Exclude Cookie from forwarded HTTP headers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
nate nowack 2026-08-18 11:42:34 -05:00 committed by GitHub
commit 92465c7f1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 80 additions and 8 deletions

View file

@ -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

View file

@ -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

View file

@ -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()