Skip to content

Stop SSE filter from leaking tools/list on undecodable lines#5304

Open
saivedant169 wants to merge 5 commits into
stacklok:mainfrom
saivedant169:fix/authz-sse-line-fallthrough
Open

Stop SSE filter from leaking tools/list on undecodable lines#5304
saivedant169 wants to merge 5 commits into
stacklok:mainfrom
saivedant169:fix/authz-sse-line-fallthrough

Conversation

@saivedant169

Copy link
Copy Markdown

Summary

ResponseFilteringWriter.processSSEResponse used to abort filtering of the entire SSE stream whenever a single data: line failed jsonrpc2.DecodeMessage or decoded to a non-*jsonrpc2.Response message. The fallback wrote the entire buffered upstream payload and returned, so any subsequent data: line containing the real tools/list reply reached the client unfiltered, bypassing the cedar authorization filter. The fallback also re-called WriteHeader after the reverse proxy's first Flush() had already emitted headers, producing the http: superfluous response.WriteHeader call from … response_filter.go:191 warning that surfaced the bug.

This PR:

  • Treats an undecodable or non-Response data: line as pass-through for that line only: fall through to the existing line writer (the loop's if !written branch) rather than dumping rawResponse and returning.
  • Drops the explicit WriteHeader(rfw.statusCode) calls in the fallback branches, which removes the double-header warning at line 191.
  • Emits a WARN log when a tools/list reply contains a data: 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 npm mcp-proxy case from the issue reproduction).

Closes #5257

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Added TestResponseFilteringWriter_SSE_PerLineFallthrough as a table-driven regression test covering both branches (decode error and non-Response decode). It builds an SSE stream that interleaves a notification (or an undecodable garbage line) with a real tools/list response, then asserts:

  1. The preceding line is preserved verbatim on the wire.
  2. The tools/list response is filtered to only authorized tools.
  3. The literal string "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_tool reaching 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, and resources/list responses now respect the configured cedar authorization filter even when the upstream interleaves notifications or sends an undecodable data: line. Previously such streams returned the unfiltered tool/prompt/resource catalog to the caller. Filtered behavior is what operators already get on application/json upstreams; this brings SSE in line.

Special notes for reviewers

The change is intentionally minimal. I considered also auditing processJSONResponse for 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.

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
@jhrozek

jhrozek commented May 18, 2026

Copy link
Copy Markdown
Contributor

This PR looks like it's targetting issue #5292 ?

@jhrozek

jhrozek commented May 18, 2026

Copy link
Copy Markdown
Contributor

cc @amirejaz

@amirejaz

Copy link
Copy Markdown
Contributor

Not quite — #5304 closes #5257, which is an authorization bypass in the Cedar SSE response filter (pkg/authz/response_filter.go): when a data: line fails to decode as a *jsonrpc2.Response (e.g. an interleaved notification), the filter was dumping the entire raw unfiltered upstream payload, bypassing Cedar entirely.

Our issue #5292 is a separate concern in a different layer: per-event JSON-RPC frame validation in the transparent proxy (pkg/transport/proxy/transparent/), analogous to what #5288 did for streamable-HTTP. #5292 is about the proxy rejecting/closing-stream on structurally malformed frames, not about the Cedar filter.

The two are complementary — #5304 fixes a more serious auth bypass, #5292 is about frame validation in the non-auth transparent proxy path.

@yrobla yrobla left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The direction is correct and the core fix is sound — removing the whole-payload dump and letting the existing if !written branch handle per-line passthrough is exactly right. Two warnings below about logging coverage and test coverage.

Comment thread pkg/authz/response_filter.go Outdated
// Pass this line through unfiltered. Earlier revisions wrote
// rawResponse and returned here, which leaked every subsequent
// data line on the stream past the filter (issue #5257).
if rfw.method == string(mcp.MethodToolsList) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: WARN logs are only emitted for tools/list, but the bypass applies to all four filtered methods.

Both log conditions — err != nil branch here, and the else if for non-*jsonrpc2.Response in the default: branch below — are gated on rfw.method == string(mcp.MethodToolsList). However, requiresResponseFiltering covers four methods: tools/list, prompts/list, resources/list, and find_tool. An SSE backend serving prompts/list or resources/list with interleaved MCP notifications would silently hit the same passthrough path — no log line at all.

The security fix itself is correct for all methods (the written flag controls passthrough regardless of method), but the observability goal from the issue — "future bypasses surface in audit logs rather than going silent" — is only fulfilled for tools/list.

Consider replacing both method checks with requiresResponseFiltering(rfw.method), which already encodes the right set.

Comment thread pkg/authz/response_filter_test.go Outdated
// an MCP notification) or an undecodable data line with a real tools/list
// response, the filter previously wrote the entire raw upstream payload and
// returned, leaking the unfiltered tools/list past Cedar. It must instead pass
// only the offending line through and continue filtering the rest of the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: Regression test only covers MethodToolsList; the same bypass applies to prompts/list and resources/list over SSE.

The new test hardcodes string(mcp.MethodToolsList). The same code path in processSSEResponse runs for any method where requiresResponseFiltering is true — including MethodPromptsList and MethodResourcesList. A notification-interleaved SSE response on those paths is not currently exercised.

A brief additional sub-test for MethodPromptsList or MethodResourcesList with an interleaved notification would confirm parity and guard against a future regression on those paths.

Address review feedback on stacklok#5304:

The WARN log paths in processSSEResponse were gated on the
tools/list method, but the underlying bypass applies to every
method routed through requiresResponseFiltering (tools/list,
prompts/list, resources/list, find_tool). Drop the gating so
the WARN fires for any of them.

The regression test only exercised tools/list. Restructure it
into a method-by-preceding-line table so the same notification
and undecodable line cases run against prompts/list and
resources/list as well. Verified the WARN now logs for all
three methods and the unauthorized entry never reaches the
recorder.
@saivedant169

Copy link
Copy Markdown
Author

Both points addressed in 3984262.

The WARN logs no longer gate on tools/list, so any method that hits processSSEResponse via requiresResponseFiltering (tools/list, prompts/list, resources/list, find_tool) now WARNs when a data line is undecodable or non-Response.

The regression test is now a method-by-preceding-line table over all three list methods. Each method has its own cedar policy and a one-authorized + one-unauthorized fixture; both the notification and undecodable-line cases run against tools/list, prompts/list, and resources/list. Confirmed the unauthorized entry never reaches the recorder for any of the six combinations.

@yrobla ready for another look when you have a moment.

@amirejaz amirejaz requested a review from yrobla May 20, 2026 15:42
@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label May 20, 2026
@codecov

codecov Bot commented May 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.42105% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.40%. Comparing base (bd73817) to head (8d39fde).

Files with missing lines Patch % Lines
pkg/authz/response_filter.go 68.42% 3 Missing and 3 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #5304   +/-   ##
=======================================
  Coverage   68.40%   68.40%           
=======================================
  Files         624      624           
  Lines       63442    63442           
=======================================
+ Hits        43397    43400    +3     
+ Misses      16807    16805    -2     
+ Partials     3238     3237    -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels May 21, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Core fix is correct — per-line fallthrough closes the accidental bypass from #5257, and the regression test locking all three list methods is solid. Three points below before we merge.

Comment thread pkg/authz/response_filter.go Outdated
}

written = true
} else {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pass-through gate here is still keyed on the JSON-RPC message type, which the upstream controls. DecodeMessage only classifies a frame as *Response when it has no method field. So a frame carrying both method (e.g. notifications/initialized) and result (with the real tools list) decodes to a *Request, fails the *Response assertion, and passes through unfiltered via the if !written branch below.

Same bypass class as #5257, just narrowed from "any non-Response line" to "a deliberately framed one." Since the proxy's threat model treats upstream list responses as untrusted (that's the whole reason requiresResponseFiltering exists), I think this residual is worth closing.

Cheapest fix: for filtered methods, if the raw data contains a result field, route it through filterListResponse regardless of message type — or drop the line as a protocol violation. A regression test with a frame like {"jsonrpc":"2.0","method":"notifications/initialized","id":1,"result":{"tools":[...]}} would lock it.

Happy to file this as a follow-up if you'd rather ship the accidental-bypass fix now and track it separately — your call.

Comment thread pkg/authz/response_filter.go Outdated
return rfw.writeErrorResponse(response.ID, err)
}

_, err = rfw.ResponseWriter.Write([]byte("data: " + string(filteredData) + "\n"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcodes "\n", but the loop already writes the detected linesep at line 228. On a \r\n stream that produces data: <json>\n\r\n — a stray \n a strict SSE parser might read as an empty event. The passthrough branch writes line + linesep (no extra \n), so the two paths produce structurally different output frames.

Drop the "\n" here and let linesep terminate. One-line change.

Comment thread pkg/authz/response_filter.go Outdated
filteredData, err := jsonrpc2.EncodeMessage(filteredResponse)
if err != nil {
return rfw.writeErrorResponse(response.ID, err)
switch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The switch { default: if/else } is harder to read than a plain if / else if / else chain here. There's exactly one condition (err != nil) plus a type assertion — the switch adds no dispatch value, and the happy path (decode ok, it's a Response, filter it) is buried in a default with a nested if, three levels deep.

An if err != nil { warn } else if response, ok := ...; ok { filter } else { warn } chain keeps the happy path at one indentation level and makes the three outcomes symmetric. No behavior change, just clarity.

A non-Response SSE frame carrying both a method field and a result (for
example notifications/initialized smuggling a tools/list result) was
classified as a request by DecodeMessage, failed the Response check, and
passed through unfiltered, a narrowed form of stacklok#5257. carriesResult now
detects a result field on such frames and drops the line as a protocol
violation, with a regression test.

Also drop the hardcoded "\n" after the filtered data line: the loop writes
the detected line separator, so the literal "\n" desynced \r\n streams.
Flatten the decode switch into an if/else chain for readability.

Signed-off-by: Saivedant Hava <saivedant169@gmail.com>
@saivedant169

Copy link
Copy Markdown
Author

Pushed eaa6c0d.

@JAORMX:

  • Disguised result frame: added carriesResult, so a non-Response frame that still carries a result is dropped as a protocol violation instead of passing through. Regression test with the notifications/initialized + result frame, confirmed it leaks without the drop. Fixed here rather than as a follow-up.
  • Line termination: dropped the hardcoded \n on the filtered write; the loop's linesep terminates now, so \r\n streams stay consistent.
  • Readability: flattened the decode switch into the if / else if / else chain.

@yrobla: your two points are already in on the current branch, the WARN logs are unconditional (so every filtered method logs), and the SSE fallthrough test exercises tools/list, prompts/list, and resources/list.

@saivedant169 saivedant169 requested a review from JAORMX June 24, 2026 19:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

authz: SSE response filter leaks unfiltered tools/list on undecodable / non-Response data lines (bypasses cedar)

5 participants