mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 02:10:38 +02:00
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:
parent
bfcdfa59ec
commit
92465c7f1f
3 changed files with 80 additions and 8 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue