Close authz response filter bypasses via media type and status - #6092
Merged
Conversation
The authz response filter only filtered list results labeled exactly "application/json" or "text/event-stream" on HTTP 200/202. RFC 9110 makes media types case-insensitive and allows whitespace before parameters, and fetch-based MCP clients accept any 2xx status, so a backend could return "Application/JSON", "application/json ; charset=utf-8", or HTTP 201 and deliver the complete unfiltered inventory to the caller, silently. Both bypasses were confirmed by an executed test harness. Normalize the media type before matching, filter the whole 2xx range, and sniff unrecognized media types on filtered methods for JSON-RPC results so a mislabeled list fails closed, mirroring the hardened sibling filter in pkg/mcp/tool_filter.go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
JAORMX
requested review from
ChrisJBurns,
jhrozek,
rdimitrov and
tgrunnagle
as code owners
July 28, 2026 10:17
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6092 +/- ##
==========================================
+ Coverage 72.26% 72.32% +0.06%
==========================================
Files 728 728
Lines 75523 75541 +18
==========================================
+ Hits 54575 54637 +62
+ Misses 17045 16982 -63
- Partials 3903 3922 +19 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
amirejaz
approved these changes
Jul 28, 2026
10 tasks
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
Main gained CRD-affecting changes (#6092, #6046, #6086) after the previous merge, so the Generate CRD Docs check fails against the merge result until docs/operator/crd-api.md is regenerated on top. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
10 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The authz response filter (
pkg/authz/response_filter.go,FlushAndFilter) is what enforces Cedar policy ontools/list,prompts/list,resources/list, andfind_toolresults — it strips items the caller may not see. The untrusted MCP server behind the gateway (the primary actor in this codebase's threat model) could bypass it entirely in two ways, both confirmed by an executed test harness during a security review, not merely reasoned about. In both cases the caller receives the complete unfiltered inventory, including items Cedar denied, and nothing is logged.Content-Typeheader exactly againstapplication/json/text/event-streamafter splitting on;. RFC 9110 §8.3.1 makes media types case-insensitive and permits whitespace before the parameter separator, soContent-Type: Application/JSONandContent-Type: application/json ; charset=utf-8are both legal, both parsed as JSON by clients, and both fell to thedefault:branch — raw, unfiltered, silent passthrough.tools/listwith HTTP 201 sailed past the filter; fetch-based MCP clients (including the reference TypeScript transport) gate onresponse.ok, which accepts all of 200–299, so the client parses the 201 body as a normal JSON-RPC response and the caller acts on it.The in-repo precedent is
pkg/mcp/tool_filter.go, the sibling tool filter: it already switches on media type before consulting the status code, and itsprocessUnrecognizedMimeTypesniffs a mislabeled 2xx body under both supported shapes and fails closed. Its docstring names this exact attack — "a backend deliberately omitting/mislabeling it to smuggle an unfiltered tools list past the filter (the same disguised-result concern handled in pkg/authz for #5257)". The authz filter is simply the one that got left behind; this PR mirrors the sibling's structure and failure posture.What changed:
;-segment) before the switch, so casing and whitespace games can no longer select the passthrough branch.data:lines, reusing the existingcarriesResultfail-closed helper). A body carrying a JSON-RPC result is processed exactly as if it had been labeled correctly — filtered when it is a clean frame, dropped (fail closed, with a WARN log) when it is not. Only a body carrying no result under either shape passes through.Status-handling decision
200 <= status < 300), not just 200/202. The client-side reality isresponse.ok, which is the whole 2xx range; any 2xx can deliver a list to the caller, so any 2xx must be filtered. TherequiresResponseFiltering(method)gate and theContent-Lengthdeletion on the filtering paths are preserved unchanged.len(rawResponse) == 0early return passes it through untouched (verified: that check runs before the media-type switch).sseCarriesResult) plus adefault:branch that routes to the existing, untouched JSON/SSE processors. Leaving it out would have fixed the two named header spellings while leavingContent-Type: text/plainas a working bypass.One existing test needed updating as a direct consequence:
TestResponseFilteringWriter_ErrorResponsebuilt its "error response" withjson.Marshalon thejsonrpc2.Responsestruct (producing Go field names —{"Result":null,"Error":...}, not a JSON-RPC wire frame) and set noContent-Type. That is precisely the mislabeled-body-with-a-result shape that now fails closed (Go's case-insensitive field matching sees"Result":nullas a carried result). The test's intent — a JSON-RPC error response passes through unchanged — is preserved by encoding a real wire frame viajsonrpc2.EncodeMessageand declaringapplication/json.Deliberately not fixed here
processSSEResponse/writeSSEDataLineare untouched; Parse SSE responses per event, not per line #6088 rewrites that parsing wholesale and owns the territory (Frame SSE filter-failure errors as SSE events #6087 is adjacent). This PR changes zero lines in those functions — the newdefault:branch only calls the existing SSE processor.PaginatedResult(and thusnextCursor) verbatim from the backend response. A caller denied everything on a page still receives an empty page with a cursor, revealing that hidden items exist and roughly how many pages of them there are. Noting it here so it is not lost; it needs its own design discussion (dropping the cursor would break legitimate pagination through mixed pages).Type of change
Test plan
Unit tests (
task test)Linting (
task lint)Manual testing (describe below)
New regression test
TestResponseFilteringWriter_FilterBypassAttempts(table-driven,t.Parallel(), testify), using a Cedar policy that permits onlyweatherand asserting the deniedadmin_toolis absent from the client-visible body for:Content-Type: Application/JSON,Content-Type: application/json ; charset=utf-8, HTTP 201 with correct JSON labeling,text/plainlabeling over a JSON list body, and a missingContent-Typeover an SSE-shaped list body — plus passthrough cases pinning that a resultless unlabeled body and a non-2xx error body remain untouched.Fail-before proof: with the production change stashed (tests kept), all five bypass cases fail with "denied tool leaked to the client" and both passthrough cases pass; with the fix restored,
go test ./pkg/authz/...is fully green.task testran to completion in my environment (the gotestfmt v2.5.0 panic did not reproduce this run) but reports three failures —TestCompositeProvider_RealProviders,TestNewServer_ReadTimeoutConfigured,TestSecurityHeaders— that are sandbox-environment issues (no keyring/network access) and fail identically on a cleanorigin/maincheckout;go test ./pkg/authz/...was used as the targeted verification.task lintwith an isolatedGOLANGCI_LINT_CACHE: 0 issues;go vet ./...andgo build ./...clean.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.No operator API surface is touched.
Changes
pkg/authz/response_filter.gosseCarriesResulthelper)pkg/authz/response_filter_test.goTestResponseFilteringWriter_ErrorResponseto write a real JSON-RPC wire frame with a declaredContent-TypeDoes this introduce a user-facing change?
Yes — this is a behavior change, and the point of the PR: backend responses that previously passed through the authz gateway unfiltered are now filtered. In particular, a backend that legitimately returns a non-200 2xx (e.g. 201) for a list method will now have that list filtered by Cedar policy, and a 2xx list response with a missing or unrecognized
Content-Typeis now filtered or rejected instead of passed through raw. Compliant backends (200/202, correctly labeled) see no change.Special notes for reviewers
< 200 || >= 300) rather than an allowlist; 204/empty-body behavior is unchanged because the pre-existing empty-body early return runs first.default:branch intentionally routes sniffed bodies into the existingprocessJSONResponse/processSSEResponse— no changes to SSE per-line logic,writeSSEDataLine, orerrorResponseBody, to stay out of the way of Parse SSE responses per event, not per line #6088's SSE rewrite (which changes zero production lines inFlushAndFilter, the status check, or this switch).processSSEResponsereturn an error; the middleware then drops the response and logs a warning — fail closed, which is the intended posture for a backend violating the spec on two axes at once.🤖 Generated with Claude Code
https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n