mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-13 07:28:44 +00:00
* fix(mcp): reject credentials that cannot travel as HTTP header values
A request-scoped secret or user_auth credential with a trailing newline
(the usual result of reading a token from a file, or a CRLF env-file),
CR/LF, surrounding whitespace, or characters outside Latin-1 sailed
through the credential interceptors into the HTTP client, where httpx/h11
reject it with an exception that echoes the full value:
LocalProtocolError: Illegal header value b'Bearer sk-...\n'
ToolErrorHandlingMiddleware copies that message into a model-visible
ToolMessage, so the secret landed in the prompt, the checkpoint, and
traces - everywhere headers_from_context promises it never goes.
Add illegal_header_value_reason to mcp/headers.py, mirroring the
transport's own rules (Latin-1 encodable; h11's field_vchar is [^\x00\s]
with SP/HTAB legal only between visible characters), and fail closed in
both interceptors before the value can reach the client. The denial names
only the secret key (plus the reason) and never repeats the value.
Illegal values are denied regardless of on_missing: the key is present,
so a passthrough fallback would silently run the call under the shared
discovery credential - the exact authority confusion the deny default
exists to prevent.
Values the transport accepts are not rejected: embedded SP/HTAB
('Bearer <token>'), Latin-1 high bytes, and DEL all still pass, pinned
by tests against h11's observed behaviour.
* fix(mcp): tighten header value validation to httpx's ASCII boundary
The validator mirrored h11's Latin-1 boundary, but the transport rejects
more than h11 does: build_server_params hands dict[str, str] headers
through the MCP SDK's create_mcp_http_client into httpx.AsyncClient, and
httpx (pinned 0.28.1) encodes str header values as ASCII - so a Latin-1
high byte like 'Bearer caf\xe9' passed validation here only to raise
UnicodeEncodeError inside httpx before h11 ever ran, with the exception
message repeating the offending value.
Validate str values against ASCII instead, flip the tests that pinned
Latin-1 high bytes as transportable, and pin the boundary against the
real client: create_mcp_http_client must reject what the validator
flags and construct cleanly for what it accepts (embedded SP/HTAB and
DEL still pass).
Addresses review feedback on the ASCII vs Latin-1 boundary.
* fix(mcp): validate OAuth and static header values at the same boundary
The validator added for headers_from_context and user_auth left two paths
uncovered. A token endpoint returning an access_token or token_type with a
newline reached httpx/h11, which raise with the full token in the message, and
ToolErrorHandlingMiddleware copies that message into a model-visible
ToolMessage -- the leak this PR set out to close. The operator's static headers
had the same hole.
OAuthTokenManager.get_authorization_header now renders the Authorization value
through one checked helper, so the tool interceptor, the initial discovery
headers and the durable task path are all covered by a single guard. The
rendered value is what gets checked rather than the two fields separately,
because that is what the transport sees: an access_token with leading
whitespace is legal once it follows "Bearer ".
build_server_params applies the same check to statically configured headers.
build_servers_config already isolates a per-server failure, so a bad value
drops that one server and logs the reason instead of the value.
* docs(mcp): correct which transport echoes the full header value
The rationale claimed httpx and h11 both render the full value into their
exception message. Only h11 does, on the line break and surrounding whitespace
cases. httpx's ASCII failure is a UnicodeEncodeError naming the offending
character and its position, not the credential, so at most one character
escapes there; refusing the value up front buys an actionable error rather than
an encode failure raised from inside the client.
Corrected in headers.py and in every copy of the claim: context_headers.py,
user_scoped_auth.py, oauth.py, client.py, mcp/AGENTS.md, docs/MCP_SERVER.md,
the frontend mcp.mdx, and the test comments carrying the same wording. No
behavior change.
---------
Co-authored-by: Terminator666666 <Terminator666666@users.noreply.github.com>
282 lines
10 KiB
Python
282 lines
10 KiB
Python
"""Tests for case-insensitive header writes shared by the MCP interceptors.
|
|
|
|
HTTP field names are case-insensitive, but the dicts carrying them are not: a
|
|
static ``authorization`` and an injected ``Authorization`` are two keys, both
|
|
reach httpx, and a server reading the field with a single-value accessor gets
|
|
the *static* one — the credential the injection was meant to replace.
|
|
"""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from langchain_mcp_adapters.interceptors import MCPToolCallRequest
|
|
from pydantic import ValidationError
|
|
|
|
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, McpUserScopedAuthConfig
|
|
from deerflow.mcp.headers import (
|
|
apply_header_overrides,
|
|
header_spellings,
|
|
illegal_header_value_reason,
|
|
)
|
|
from deerflow.mcp.oauth import build_oauth_tool_interceptor
|
|
from deerflow.mcp.user_scoped_auth import build_user_scoped_auth_interceptor
|
|
|
|
DISCOVERY = "Bearer discovery-token"
|
|
|
|
|
|
def _request(headers: dict | None = None, runtime: object | None = None, server_name: str = "shared-http") -> MCPToolCallRequest:
|
|
return MCPToolCallRequest(name="act", args={}, server_name=server_name, headers=headers, runtime=runtime)
|
|
|
|
|
|
async def _echo_handler(request: MCPToolCallRequest) -> MCPToolCallRequest:
|
|
return request
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# apply_header_overrides
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_override_replaces_a_differently_cased_key():
|
|
assert apply_header_overrides({"Authorization": DISCOVERY}, {"authorization": "Bearer new"}) == {"Authorization": "Bearer new"}
|
|
|
|
|
|
def test_override_prefers_the_connection_spelling():
|
|
"""The emitted name must match the connection's, since the adapter merges by key."""
|
|
merged = apply_header_overrides(
|
|
{"Authorization": "Bearer from-an-earlier-interceptor"},
|
|
{"AUTHORIZATION": "Bearer new"},
|
|
spellings=header_spellings(["authorization"]),
|
|
)
|
|
assert merged == {"authorization": "Bearer new"}
|
|
|
|
|
|
def test_override_keeps_unrelated_headers():
|
|
merged = apply_header_overrides({"Accept": "application/json"}, {"X-Tenant-Id": "acme"})
|
|
assert merged == {"Accept": "application/json", "X-Tenant-Id": "acme"}
|
|
|
|
|
|
def test_override_accepts_no_base():
|
|
assert apply_header_overrides(None, {"X-Tenant-Id": "acme"}) == {"X-Tenant-Id": "acme"}
|
|
|
|
|
|
def test_override_does_not_mutate_the_base():
|
|
base = {"Authorization": DISCOVERY}
|
|
apply_header_overrides(base, {"authorization": "Bearer new"})
|
|
assert base == {"Authorization": DISCOVERY}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# illegal_header_value_reason
|
|
#
|
|
# The boundary mirrors what the transport enforces: the MCP clients hand
|
|
# ``dict[str, str]`` headers to httpx, which encodes ``str`` values as ASCII
|
|
# (raising UnicodeEncodeError before h11 ever sees the value); h11's
|
|
# field_vchar is ``[^\x00\s]`` with SP/HTAB allowed only between visible
|
|
# characters. Values the transport accepts must not be rejected here.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
"Bearer sk-token\n",
|
|
"Bearer sk-token\r",
|
|
"a\r\nX-Injected: b",
|
|
"a\x00b",
|
|
"a\x0bb",
|
|
"a\x0cb",
|
|
" leading-space",
|
|
"trailing-space ",
|
|
"trailing-tab\t",
|
|
"caf\xe9", # Latin-1 high byte: h11 would send it, but httpx encodes str values as ASCII first
|
|
"\u043f\u0430\u0440\u043e\u043b\u044c",
|
|
],
|
|
)
|
|
def test_transport_rejected_values_are_flagged(value):
|
|
assert illegal_header_value_reason(value) is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
"Bearer sk-token",
|
|
"Bearer abc\tdef",
|
|
"two words",
|
|
"a\x7fb", # DEL: ASCII-encodable, and h11's field_vchar accepts it
|
|
],
|
|
)
|
|
def test_transport_accepted_values_are_not_flagged(value):
|
|
assert illegal_header_value_reason(value) is None
|
|
|
|
|
|
def test_reason_never_repeats_the_value():
|
|
reason = illegal_header_value_reason("sk-secret-value\n")
|
|
assert reason is not None
|
|
assert "sk-secret-value" not in reason
|
|
|
|
|
|
def test_validator_mirrors_the_mcp_http_client_encoding_boundary():
|
|
"""Pin the boundary against the real client the headers are handed to.
|
|
|
|
``build_server_params`` passes ``dict[str, str]`` headers through the MCP
|
|
SDK's ``create_mcp_http_client`` into ``httpx.AsyncClient``, which encodes
|
|
``str`` header values as ASCII at construction time. A value the validator
|
|
accepts must construct that client; the canonical counter-example — a
|
|
Latin-1 high byte h11 itself would happily send — must be flagged by the
|
|
validator, because httpx raises ``UnicodeEncodeError`` before h11 runs.
|
|
That exception names the offending character rather than the credential, so
|
|
what the denial buys here is an actionable error instead of an encode
|
|
failure raised from inside the client.
|
|
"""
|
|
from mcp.shared._httpx_utils import create_mcp_http_client
|
|
|
|
assert illegal_header_value_reason("Bearer caf\xe9") is not None
|
|
with pytest.raises(UnicodeEncodeError):
|
|
create_mcp_http_client(headers={"Authorization": "Bearer caf\xe9"})
|
|
|
|
for value in ("Bearer sk-token", "Bearer abc\tdef ghi", "a\x7fb"):
|
|
assert illegal_header_value_reason(value) is None
|
|
client = create_mcp_http_client(headers={"X-Tenant-Token": value})
|
|
assert client.headers["X-Tenant-Token"] == value
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static header spelling validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_static_headers_reject_case_insensitive_duplicates():
|
|
"""Two spellings of one header in the static map must be rejected at config time."""
|
|
with pytest.raises(ValueError, match="two spellings"):
|
|
McpServerConfig(
|
|
type="http",
|
|
url="https://mcp.example.com/mcp",
|
|
headers={"Authorization": "Bearer a", "authorization": "Bearer b"},
|
|
)
|
|
|
|
|
|
def test_static_headers_allow_distinct_names():
|
|
config = McpServerConfig(
|
|
type="http",
|
|
url="https://mcp.example.com/mcp",
|
|
headers={"X-Tenant": "acme", "X-Org": "engineering"},
|
|
)
|
|
assert config.headers == {"X-Tenant": "acme", "X-Org": "engineering"}
|
|
|
|
|
|
def test_extensions_config_rejects_static_header_duplicates():
|
|
with pytest.raises(ValidationError, match="two spellings"):
|
|
ExtensionsConfig.model_validate(
|
|
{
|
|
"mcpServers": {
|
|
"shared-http": {
|
|
"type": "http",
|
|
"url": "https://mcp.example.com/mcp",
|
|
"headers": {"Authorization": "Bearer a", "authorization": "Bearer b"},
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
def test_gateway_rejects_static_header_case_insensitive_duplicates():
|
|
from app.gateway.routers.mcp import McpServerConfigResponse
|
|
|
|
with pytest.raises(ValidationError, match="two spellings"):
|
|
McpServerConfigResponse(headers={"Authorization": "Bearer a", "AUTHORIZATION": "Bearer b"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The credential interceptors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _server(**overrides) -> ExtensionsConfig:
|
|
return ExtensionsConfig(
|
|
mcp_servers={
|
|
"shared-http": McpServerConfig(
|
|
enabled=True,
|
|
type="http",
|
|
url="https://mcp.example.com/mcp",
|
|
headers={"authorization": DISCOVERY},
|
|
**overrides,
|
|
)
|
|
},
|
|
skills={},
|
|
)
|
|
|
|
|
|
def test_user_auth_credential_replaces_a_differently_cased_static_header():
|
|
config = _server(user_auth=McpUserScopedAuthConfig(header="Authorization", users={"u1": "Bearer per-user"}))
|
|
interceptor = build_user_scoped_auth_interceptor(config)
|
|
runtime = SimpleNamespace(server_info=None, context={"user_id": "u1"})
|
|
result = asyncio.run(interceptor(_request(runtime=runtime), _echo_handler))
|
|
assert result.headers == {"authorization": "Bearer per-user"}
|
|
|
|
|
|
def test_oauth_token_replaces_a_differently_cased_static_header():
|
|
config = _server(
|
|
oauth={
|
|
"enabled": True,
|
|
"token_url": "https://auth.example.com/oauth/token",
|
|
"client_id": "id",
|
|
"client_secret": "secret",
|
|
}
|
|
)
|
|
token_manager = SimpleNamespace(
|
|
has_oauth_servers=lambda: True,
|
|
get_authorization_header=AsyncMock(return_value="Bearer oauth-token"),
|
|
)
|
|
interceptor = build_oauth_tool_interceptor(config, token_manager=token_manager)
|
|
result = asyncio.run(interceptor(_request(), _echo_handler))
|
|
assert result.headers == {"authorization": "Bearer oauth-token"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_durable_task_call_sends_one_authorization_header():
|
|
"""The task caller merges OAuth and interceptor headers into the connection itself."""
|
|
from deerflow.mcp.task_tool_caller import McpTaskToolCaller
|
|
|
|
config = ExtensionsConfig.model_validate(
|
|
{
|
|
"mcpServers": {
|
|
"reports": {
|
|
"type": "http",
|
|
"url": "https://reports.example.com/mcp",
|
|
"headers": {"authorization": DISCOVERY},
|
|
}
|
|
}
|
|
}
|
|
)
|
|
opened: dict[str, str] = {}
|
|
result = SimpleNamespace(structuredContent={"task_id": "remote-1", "status": "running"}, isError=False)
|
|
|
|
class _SessionContext:
|
|
def __init__(self, connection, **_kwargs):
|
|
opened.update(connection.get("headers") or {})
|
|
|
|
async def __aenter__(self):
|
|
return SimpleNamespace(initialize=AsyncMock(), call_tool=AsyncMock(return_value=result))
|
|
|
|
async def __aexit__(self, *_exc):
|
|
return False
|
|
|
|
caller = McpTaskToolCaller(
|
|
config,
|
|
oauth_token_manager=SimpleNamespace(has_oauth_servers=lambda: False, get_authorization_header=AsyncMock(return_value="Bearer oauth-token")),
|
|
)
|
|
|
|
with patch("langchain_mcp_adapters.sessions.create_session", _SessionContext):
|
|
await caller.call_tool(
|
|
server_name="reports",
|
|
tool_name="status",
|
|
arguments={"task_id": "remote-1"},
|
|
user_id="user-1",
|
|
thread_id="thread-1",
|
|
)
|
|
|
|
assert opened == {"authorization": "Bearer oauth-token"}
|