Stop SSE filter from leaking tools/list on undecodable lines#5304
Stop SSE filter from leaking tools/list on undecodable lines#5304saivedant169 wants to merge 1 commit into
Conversation
processSSEResponse used to write the entire raw upstream payload and return whenever a single SSE data line failed jsonrpc2.DecodeMessage or decoded to a non-Response message (e.g. a notifications/* frame). On a tools/list reply that meant every subsequent data line, including the real Response, reached the client unfiltered, silently bypassing the cedar authorization filter and producing the superfluous WriteHeader warning at response_filter.go:191. Treat undecodable or non-Response data lines as pass-through for that line only: fall through to the existing line writer instead of dumping rawResponse. The explicit WriteHeader calls go away with them, which also removes the double-header warning that surfaced the bug. Skipped filtering on tools/list now emits a WARN so future bypasses are visible in audit logs. Adds a table-driven regression test covering both branches (decode error and non-Response). It fails on the old code with the unfiltered admin_tool entry reaching the recorder. Closes stacklok#5257
|
This PR looks like it's targetting issue #5292 ? |
|
cc @amirejaz |
|
Not quite — #5304 closes #5257, which is an authorization bypass in the Cedar SSE response filter ( Our issue #5292 is a separate concern in a different layer: per-event JSON-RPC frame validation in the transparent proxy ( The two are complementary — #5304 fixes a more serious auth bypass, #5292 is about frame validation in the non-auth transparent proxy path. |
Summary
ResponseFilteringWriter.processSSEResponseused to abort filtering of the entire SSE stream whenever a singledata:line failedjsonrpc2.DecodeMessageor decoded to a non-*jsonrpc2.Responsemessage. The fallback wrote the entire buffered upstream payload and returned, so any subsequentdata:line containing the realtools/listreply reached the client unfiltered, bypassing the cedar authorization filter. The fallback also re-calledWriteHeaderafter the reverse proxy's firstFlush()had already emitted headers, producing thehttp: superfluous response.WriteHeader call from … response_filter.go:191warning that surfaced the bug.This PR:
Responsedata:line as pass-through for that line only: fall through to the existing line writer (the loop'sif !writtenbranch) rather than dumpingrawResponseand returning.WriteHeader(rfw.statusCode)calls in the fallback branches, which removes the double-header warning at line 191.tools/listreply contains adata:line that bypasses the filter, so future bypasses are visible in audit logs instead of silent.Notifications interleaved on a response stream are explicitly allowed by the MCP spec, so this case can be hit by fully spec-compliant upstreams (
notifications/message, progress updates, etc.) as well as by upstreams fronted by an SSE bridge (the npmmcp-proxycase from the issue reproduction).Closes #5257
Type of change
Test plan
task test)task lint-fix)Added
TestResponseFilteringWriter_SSE_PerLineFallthroughas a table-driven regression test covering both branches (decode error and non-Responsedecode). It builds an SSE stream that interleaves a notification (or an undecodable garbage line) with a realtools/listresponse, then asserts:tools/listresponse is filtered to only authorized tools."admin_tool"(the unauthorized tool from the unfiltered upstream payload) does not appear anywhere in the output.The test fails on the prior code with
admin_toolreaching the recorder, and passes on the patched code.Verified on
pkg/authz/...with-race:go test -ldflags=-extldflags=-Wl,-w -race ./pkg/authz/...passes (including the existing JSON-path tests and the new SSE test).golangci-lint run ./pkg/authz/...reports 0 issues.Does this introduce a user-facing change?
Yes. On SSE upstreams (
Content-Type: text/event-stream),tools/list,prompts/list, andresources/listresponses now respect the configured cedar authorization filter even when the upstream interleaves notifications or sends an undecodabledata:line. Previously such streams returned the unfiltered tool/prompt/resource catalog to the caller. Filtered behavior is what operators already get onapplication/jsonupstreams; this brings SSE in line.Special notes for reviewers
The change is intentionally minimal. I considered also auditing
processJSONResponsefor the same shape (return rawResponse on per-line failure) but it processes a single message, not a stream, so the same code shape there is already correct. Happy to extend if you want a defensive log added on that side too.