You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Authorization and audit evaluate the pre-mutation MCP request
Labels: bug
Bug description
When a mutating webhook and authorization are both configured, ToolHive authorizes the pre-mutation MCP request while the backend executes the post-mutation body.
ParsingMiddleware (pkg/mcp/parser.go:87) parses the request body once and publishes the
resulting *ParsedMCPRequest in two places: into the request context under MCPRequestContextKey, and into a ParsedRequestHolder so middleware that wraps the parser
(audit) can observe it. It then deliberately refuses to parse again (:91-94).
The mutating webhook middleware (pkg/webhook/mutating/middleware.go:133) replaces r.Body
with the patched body but calls next.ServeHTTP(w, r) with the request unchanged — no new
context, no holder refresh. Because the mutating webhook sits inside the parser and outside
authorization (pkg/runner/middleware.go:164/:177/:226, and pkg/runner/config_builder.go:691/:707/:723), every downstream consumer of the cached parse
decides on bytes the backend will not execute:
Consumer
Reads
Effect
pkg/authz/middleware.go:196
.ResourceID, .Arguments
Cedar authorizes tool A with arguments A′; the backend runs tool B with arguments B′
pkg/audit/auditor.go:361,373
.Method, .ResourceID
The audit event's type and target.name describe a request that never ran
Spans and metrics attribute the call to the pre-mutation tool; sanitizeArguments exports pre-redaction argument values
pkg/usagemetrics/middleware.go:39
.Method
Miscounted tool calls
The audit case is the one that ships in the default configuration: the event type and target name
are not gated on includeRequestData (default false, pkg/audit/config.go:90). Disabling
request-data capture does not make the audit trail accurate.
docs/middleware.md:31 documents the opposite behavior:
Mutating webhooks: Transform the parsed MCP request before later policy evaluation.
By contrast the validating webhook is correct — it re-reads r.Body itself
(pkg/webhook/validating/middleware.go:104), so it genuinely sees post-mutation bytes.
Steps to reproduce
Configure a mutating webhook that patches /mcp_request/params/name (or anything under /mcp_request/params/arguments/) together with a Cedar authorization policy that refers to the
tool name or argument values. IsPatchScopedToMCPRequest
(pkg/webhook/mutating/patch.go:66) permits both paths, i.e. exactly the fields authorization
decides on.
Covered by regression tests in pkg/webhook/mutating/middleware_test.go
(TestMutatingMiddleware_ParsedRequestReflectsRenamedTool, ...ReflectsPatchedArgument, ...HolderReflectsMutatedResourceID), each of which fails without the fix.
Expected behavior
Policy evaluation, audit and telemetry see the request the backend will actually execute, as docs/middleware.md:31 promises.
Actual behavior
They see the request as it arrived. The two controls disagree silently, with nothing logged.
Severity
High as a correctness-and-contract defect with security consequences — deliberately not
"authorization can be bypassed".
A mutating webhook is operator-supplied trusted infrastructure, already executing
operator-authorized code in the request path. "A webhook rewrites read_file to delete_file and
gets read_file authorized" is not a privilege escalation for whoever wrote the webhook.
The realistic failure is an operator's two controls disagreeing without warning:
A benign webhook rewrites arguments for legitimate reasons (injecting a tenant ID, normalizing
a path, redacting a field) and the operator writes Cedar policy against the normalized value.
Policy evaluates the un-normalized value. This fails open or closed depending on the policy,
with no record of the divergence.
The audit trail names a request that was never executed, undermining the exact artifact an
operator reaches for during an incident.
Preconditions: mutating webhooks and authorization both configured, plus a webhook that
touches method or params.
Not affected
Batch smuggling is not possible.mcpRequestPathPrefix has a trailing slash
(pkg/webhook/mutating/patch.go:19), so a wholesale /mcp_request replacement fails the prefix
check, and RFC 6902 cannot otherwise turn a JSON object into an array. Any fix must keep this
closed, which is why the re-parse routes through the same IsBatchRequest guard.
vMCP never wires the mutating middleware, so pkg/vmcp/server/classification.go, call_gate.go and serve_handlers.go are unaffected today.
Additional context
Found by review, verified by reading each link in the chain against main @ 0a0cbd949.
A related but distinct gap is #6134: the tool-call filter enforces --tools against
the raw pre-mutation body from outside the parser, so republishing the parse does not fix it.
Authorization and audit evaluate the pre-mutation MCP request
Labels: bug
Bug description
When a mutating webhook and authorization are both configured, ToolHive authorizes the
pre-mutation MCP request while the backend executes the post-mutation body.
ParsingMiddleware(pkg/mcp/parser.go:87) parses the request body once and publishes theresulting
*ParsedMCPRequestin two places: into the request context underMCPRequestContextKey, and into aParsedRequestHolderso middleware that wraps the parser(audit) can observe it. It then deliberately refuses to parse again (
:91-94).The mutating webhook middleware (
pkg/webhook/mutating/middleware.go:133) replacesr.Bodywith the patched body but calls
next.ServeHTTP(w, r)with the request unchanged — no newcontext, no holder refresh. Because the mutating webhook sits inside the parser and outside
authorization (
pkg/runner/middleware.go:164/:177/:226, andpkg/runner/config_builder.go:691/:707/:723), every downstream consumer of the cached parsedecides on bytes the backend will not execute:
pkg/authz/middleware.go:196.ResourceID,.Argumentspkg/audit/auditor.go:361,373.Method,.ResourceIDtarget.namedescribe a request that never ranpkg/telemetry/middleware.go:174,227,300,405,417,687,723sanitizeArgumentsexports pre-redaction argument valuespkg/usagemetrics/middleware.go:39.MethodThe audit case is the one that ships in the default configuration: the event type and target name
are not gated on
includeRequestData(defaultfalse,pkg/audit/config.go:90). Disablingrequest-data capture does not make the audit trail accurate.
docs/middleware.md:31documents the opposite behavior:By contrast the validating webhook is correct — it re-reads
r.Bodyitself(
pkg/webhook/validating/middleware.go:104), so it genuinely sees post-mutation bytes.Steps to reproduce
Configure a mutating webhook that patches
/mcp_request/params/name(or anything under/mcp_request/params/arguments/) together with a Cedar authorization policy that refers to thetool name or argument values.
IsPatchScopedToMCPRequest(
pkg/webhook/mutating/patch.go:66) permits both paths, i.e. exactly the fields authorizationdecides on.
Covered by regression tests in
pkg/webhook/mutating/middleware_test.go(
TestMutatingMiddleware_ParsedRequestReflectsRenamedTool,...ReflectsPatchedArgument,...HolderReflectsMutatedResourceID), each of which fails without the fix.Expected behavior
Policy evaluation, audit and telemetry see the request the backend will actually execute, as
docs/middleware.md:31promises.Actual behavior
They see the request as it arrived. The two controls disagree silently, with nothing logged.
Severity
High as a correctness-and-contract defect with security consequences — deliberately not
"authorization can be bypassed".
A mutating webhook is operator-supplied trusted infrastructure, already executing
operator-authorized code in the request path. "A webhook rewrites
read_filetodelete_fileandgets
read_fileauthorized" is not a privilege escalation for whoever wrote the webhook.The realistic failure is an operator's two controls disagreeing without warning:
a path, redacting a field) and the operator writes Cedar policy against the normalized value.
Policy evaluates the un-normalized value. This fails open or closed depending on the policy,
with no record of the divergence.
operator reaches for during an incident.
Preconditions: mutating webhooks and authorization both configured, plus a webhook that
touches
methodorparams.Not affected
mcpRequestPathPrefixhas a trailing slash(
pkg/webhook/mutating/patch.go:19), so a wholesale/mcp_requestreplacement fails the prefixcheck, and RFC 6902 cannot otherwise turn a JSON object into an array. Any fix must keep this
closed, which is why the re-parse routes through the same
IsBatchRequestguard.pkg/runner/middleware.go:169, outside themutating webhook), though see Tool filtering and rate limiting are enforced against the pre-mutation request #6134 on per-tool quota semantics.
pkg/vmcp/server/classification.go,call_gate.goandserve_handlers.goare unaffected today.Additional context
Found by review, verified by reading each link in the chain against
main@0a0cbd949.A related but distinct gap is #6134: the tool-call filter enforces
--toolsagainstthe raw pre-mutation body from outside the parser, so republishing the parse does not fix it.