Terminator666666 c17aa8b98f
fix(mcp): reject credentials that cannot travel as HTTP header values (#5066)
* 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>
2026-08-31 15:07:30 +08:00

151 lines
7.3 KiB
Python

"""Per-user credential injection for shared MCP servers.
One configured HTTP/SSE MCP server can serve several DeerFlow users, each
authenticated to the remote service with their own credential. A server opts in
by declaring a ``user_auth`` block (:class:`McpUserScopedAuthConfig`) mapping
DeerFlow user ids to credential header values. On every tool call the
interceptor resolves the authenticated user and rewrites the configured header
via ``request.override(headers=...)`` — the same per-call mechanism the OAuth
interceptor uses.
The server entry's static ``headers`` are used only for startup tool discovery
(``tools/list``); they never authenticate a user's tool call when ``user_auth``
is enabled for that server, except under an explicit ``on_missing:
"passthrough"`` opt-out.
Fail-closed by default: an unmapped user (including the anonymous
``DEFAULT_USER_ID`` fallback), or a mapped credential whose ``$ENV_VAR``
reference resolved to an empty string, gets an actionable ``ToolException``
instead of another user's credential or the discovery credential.
"""
from __future__ import annotations
import logging
from typing import Any
from langchain_core.tools import ToolException
from deerflow.config.extensions_config import ExtensionsConfig, McpUserScopedAuthConfig
from deerflow.mcp.headers import (
apply_header_overrides,
header_spellings,
illegal_header_value_reason,
)
from deerflow.runtime.user_context import resolve_runtime_user_id
logger = logging.getLogger(__name__)
def _current_runtime() -> Any | None:
"""Best-effort access to the LangGraph runtime for the current tool call.
``get_runtime()`` raises outside a runtime context (embedded clients, unit
tests, discovery paths); ``resolve_runtime_user_id`` accepts ``None`` and
falls back to LangGraph auth config and the request-scoped user
ContextVar, so failures here reduce accuracy but never crash the call.
"""
try:
from langgraph.runtime import get_runtime
return get_runtime()
except Exception:
return None
def build_user_scoped_auth_interceptor(extensions_config: ExtensionsConfig) -> Any | None:
"""Build a tool interceptor injecting per-user credentials, or ``None``.
Returns ``None`` when no enabled server declares an enabled ``user_auth``
block, so callers can skip registration entirely (mirrors
``build_oauth_tool_interceptor``).
"""
user_auth_by_server: dict[str, McpUserScopedAuthConfig] = {}
# The server's static header spellings, so a configured ``header`` that
# differs from the static one only in case still *replaces* it at the
# adapter's case-sensitive connection merge (see ``mcp/headers.py``).
spellings_by_server: dict[str, dict[str, str]] = {}
for server_name, server_config in extensions_config.get_enabled_mcp_servers().items():
if server_config.user_auth is None or not server_config.user_auth.enabled:
continue
if server_config.type not in ("sse", "http"):
# A stdio server has no HTTP headers: the pooled stdio path forwards
# rewritten headers as call meta, never a transport header, so the
# credential would go nowhere while deny errors still fired for
# unmapped users. Warn-and-skip matches the existing convention for
# transport/config mismatches (e.g. tool_call_timeout on non-stdio).
logger.warning(
"MCP server '%s' declares user_auth but uses the '%s' transport; user-scoped credentials only apply to 'sse'/'http' servers — ignoring user_auth for this server",
server_name,
server_config.type,
)
continue
user_auth_by_server[server_name] = server_config.user_auth
spellings_by_server[server_name] = header_spellings(server_config.headers)
if not user_auth_by_server:
return None
async def user_scoped_auth_interceptor(request: Any, handler: Any) -> Any:
user_auth = user_auth_by_server.get(request.server_name)
if user_auth is None:
return await handler(request)
# Prefer the runtime attached to the request (set by the adapter when
# the call originates inside a graph); fall back to the ambient
# LangGraph runtime, then to resolve_runtime_user_id's own chain
# (LangGraph auth config → request-scoped user ContextVar → default).
runtime = getattr(request, "runtime", None)
if runtime is None:
runtime = _current_runtime()
user_id = resolve_runtime_user_id(runtime)
# Empty string covers a `$ENV_VAR` reference whose variable was unset:
# ExtensionsConfig.resolve_env_variables stores "" for those, and an
# empty credential must fail closed rather than send an empty header.
credential = user_auth.users.get(user_id, "")
if not credential:
if user_auth.on_missing == "passthrough":
return await handler(request)
logger.warning(
"Denied MCP tool call to server '%s': no user-scoped credential for user '%s'",
request.server_name,
user_id,
)
# The resolved id is included so the operator can copy the exact
# ``users`` key: it differs by deployment path (a safe-slug like
# ``alice-example-com-ab12cd34`` via LangGraph auth, a raw user
# UUID via the embedded Gateway). It is the caller's own id, so
# surfacing it leaks nothing across users.
raise ToolException(
f"No credential is configured for your account (user id '{user_id}') on MCP server '{request.server_name}'. Ask the operator to add this exact id to that server's user_auth.users map (or set its environment variable)."
)
# A credential the transport would refuse (trailing newline from a
# token file or a CRLF env-file, non-ASCII) must be rejected here. On
# the line break and whitespace cases h11 renders the full value into
# its exception message, which ToolErrorHandlingMiddleware copies into
# a model-visible ToolMessage. Always denied, regardless of on_missing
# — the user *is* mapped, so falling back to the discovery credential
# would silently run the call under the shared authority.
reason = illegal_header_value_reason(credential)
if reason is not None:
logger.warning(
"Denied MCP tool call to server '%s': the user_auth credential for user '%s' cannot be sent as an HTTP header value (%s)",
request.server_name,
user_id,
reason,
)
raise ToolException(
f"The credential configured for your account (user id '{user_id}') on MCP server '{request.server_name}' {reason}, so it cannot be sent as an HTTP header. "
"Ask the operator to fix that server's user_auth.users entry; a stray newline in the value or its environment variable is the usual cause."
)
updated_headers = apply_header_overrides(
request.headers,
{user_auth.header: credential},
spellings=spellings_by_server.get(request.server_name),
)
return await handler(request.override(headers=updated_headers))
return user_scoped_auth_interceptor