Strip leading UTF-8 BOM before filtering list responses - #6304
Conversation
A client strips a leading BOM per the WHATWG UTF-8 decode algorithm before parsing, but the response filter decoded and sniffed the raw bytes. Go's encoding/json treats EF BB BF as a syntax error, so a BOM-prefixed list response failed every decode and smuggled-result sniff and passed through unfiltered, leaking policy-denied tools/prompts/resources. The SSE processing path already stripped the BOM; the JSON decode path, the JSON/SSE sniffs in FlushAndFilter, and the tool filter's unrecognized-media-type sniff did not. Strip the BOM once in FlushAndFilter before the media-type dispatch (covering the JSON path and both default-branch sniffs) and once at the top of processUnrecognizedMimeType in the tool filter. Add regression tests mirroring the existing SSE BOM test.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6304 +/- ##
==========================================
+ Coverage 72.82% 73.01% +0.18%
==========================================
Files 742 742
Lines 77762 78401 +639
==========================================
+ Hits 56632 57241 +609
+ Misses 17155 17153 -2
- Partials 3975 4007 +32 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
reyortiz3
left a comment
There was a problem hiding this comment.
Thanks for the thorough writeup and tests here — this is a solid fix for the #6301 bypass class.
One correctness issue found on review:
pkg/authz/response_filter.go: the BOM strip in FlushAndFilter breaks Content-Length on the fallback passthrough path
The new bytes.TrimPrefix at line ~101 unconditionally strips the BOM from rawResponse before the media-type switch. Every branch that goes on to write a body already deletes Content-Length first (application/json, text/event-stream, and both "carries result" branches in default) — but the final fallback in default, reached when neither carriesResult nor sseCarriesResult matches, writes rawResponse directly with no Content-Length deletion:
rfw.ResponseWriter.WriteHeader(rfw.statusCode)
_, err := rfw.ResponseWriter.Write(rawResponse)
return errBefore this PR, that passthrough never altered bytes, so Content-Length stayed accurate. Now, any body that starts with a BOM but doesn't carry a recognizable JSON-RPC result under either sniff (a non-list response, or a legitimately BOM-prefixed body that isn't smuggling anything) gets 3 bytes silently trimmed while Content-Length still reflects the original size — which, per the comments elsewhere in this file, causes Go's HTTP server to detect the mismatch and tear down the connection.
Suggest deleting Content-Length before that final Write too, same as the other branches.
Minor: the BOM literal is now duplicated three times
"\xEF\xBB\xBF" appears at the pre-existing processSSEResponse (line 258), and the two new strips here and in pkg/mcp/tool_filter.go. Since this is a security-relevant magic value, worth hoisting into a single exported constant — pkg/mcp/utils.go looks like a natural home (already the place for small cross-cutting JSON-RPC helpers), something like:
// UTF8BOM is the 3-byte UTF-8 byte order mark. Clients that follow the
// WHATWG UTF-8 decode algorithm strip a leading BOM before parsing; any
// code decoding or sniffing a JSON-RPC body from the wire must strip it
// first to match that behavior.
var UTF8BOM = []byte("\xEF\xBB\xBF")pkg/authz already imports pkg/mcp as mcpparser, so this doesn't need a new dependency. Not blocking, just reduces the risk of a future edit fixing only two of the three copies.
b1c6c18 to
404241d
Compare
|
Thanks for the review — addressed both points.
The PR has been updated. |
|
Thanks @Yanhaoxi !! Approved. |
Summary
A client strips a leading UTF-8 BOM per the WHATWG UTF-8 decode algorithm before parsing, but the response filters decoded and sniffed the raw bytes. Go's
encoding/jsontreatsEF BB BFas a syntax error (not whitespace), so a BOM-prefixed list response failed every decode and smuggled-result sniff and passed through unfiltered — leaking tools/prompts/resources that the Cedar policy (authz) or tool filter (mcp) was supposed to remove. This is the same #5257-class leak the SSE processing path was already hardened against; the JSON decode path, bothFlushAndFilterdefault-branch sniffs, and the tool filter's unrecognized-media-type sniff were not.WHAT changed:
FlushAndFilterbefore the media-type dispatch (pkg/authz/response_filter.go), covering theapplication/jsondecode path plus the JSON and SSE smuggled-result sniffs in the default branch.processUnrecognizedMimeType(pkg/mcp/tool_filter.go), covering both its JSON sniff andsniffSSEToolsList.Fixes #6301
Type of change
Test plan
task test)task test-e2e)task lint-fix)Run:
go test ./pkg/authz/... ./pkg/mcp/.... New tests:TestResponseFilteringWriter_JSON_LeadingBOMBypass(JSON path + JSON sniff),TestResponseFilteringWriter_Sniff_SSE_LeadingBOMBypass(SSE sniff),TestNewListToolsMappingMiddleware_BOMCannotSmuggleToolsList(tool filter, JSON + SSE). Verified the new tests FAIL without the source fix and PASS with it; fullpkg/authz/...andpkg/mcp/...suites pass.Lint:
task lintreports only 3 pre-existinggciissues in files this PR does not touch (pkg/authz/annotation_cache.go,pkg/authz/authorizers.go,pkg/authz/config.go), confirmed present on cleanmain; none of this PR's 4 files are flagged.go vetclean,gofmtclean.API Compatibility
v1beta1API.Changes
pkg/authz/response_filter.goFlushAndFilterbefore media-type dispatchpkg/authz/response_filter_test.gopkg/mcp/tool_filter.goprocessUnrecognizedMimeTypepkg/mcp/tool_filter_test.goDoes this introduce a user-facing change?
Yes: a misbehaving/untrusted upstream that prepends a UTF-8 BOM to a list response can no longer bypass list filtering; the denied items are filtered as before.
Implementation plan
Approved implementation plan
Four code points share one root cause: no leading-BOM strip before decode/sniff.
processJSONResponsedecode + fail-closed check, JSON sniff (carriesResult), SSE sniff (sseCarriesResult). Fixed with onebytes.TrimPrefixinFlushAndFilterbefore the media-type switch.processUnrecognizedMimeTypeJSON sniff +sniffSSEToolsList. Fixed with onebytes.TrimPrefixat the function entry (covers both sniffs).TestResponseFilteringWriter_SSE_LeadingBOMBypassper route.Special notes for reviewers
processSSEResponse) is retained;bytes.TrimPrefixis idempotent so the redundant strip is harmless.carriesResult/sseCarriesResultwould previously have returnedfalseon the BOM — the fix restores the fail-closed behavior those branches were built for (authz: SSE response filter leaks unfiltered tools/list on undecodable / non-Response data lines (bypasses cedar) #5257).processUnrecognizedMimeType) — the BOM fix does not alter that.