Skip to content

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

Open
saivedant169 wants to merge 6 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 6 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.

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.

Confirmed FIXED — all three branches in processSSEResponse (decode-error, carriesResult-drop, genuine-non-Response) now slog.Warn(..., "method", rfw.method) unconditionally. Since this function only runs for the four requiresResponseFiltering methods, bypasses on all of them now log.

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.

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.

Confirmed FIXED — TestResponseFilteringWriter_SSE_PerLineFallthrough is a genuine 3 methods × 2 scenarios table (tools/list, prompts/list, resources/list × notification-interleaved, undecodable-line), each with its own Cedar policy and authorized/unauthorized fixture. Not a shallow method-name parameterization.

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.

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.

Partially fixed. The exact PoC shape you gave (method+result, valid jsonrpc/id) is now caught — carriesResult (response_filter.go:278-283) does a proper json.Unmarshal into a struct, not substring matching, and TestResponseFilteringWriter_SSE_DisguisedResponseFrame genuinely exercises it.

But the fix is incomplete: carriesResult is only consulted in the err == nil && !isResponse branch (line 217). jsonrpc2.DecodeMessage returns an error — not a benign non-Response — for several upstream-controlled shapes (missing/invalid jsonrpc version tag, a response-shaped frame with no id, a non-scalar id, or a batch/array payload), and all of those land in the if err != nil branch at line 191, which still passes the line through unfiltered without ever checking for a smuggled result. That's the same bypass class, just relocated to an easier-to-reach branch. Left a detailed comment with concrete exploit frames on line 191.

Also: processJSONResponse (line 132) has the identical unpatched shape for the application/json transport — the PR's 'Special notes for reviewers' says that path is 'already correct', but the disguised-frame issue is transport-independent, not stream-specific, so that note isn't right.

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.

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.

Confirmed FIXED — the filtered-write path no longer appends a hardcoded terminator; the loop's shared linesep write at the bottom handles termination for every branch. No more \r\n desync.

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.

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.

Confirmed FIXED — this is now a flat if err != nil {...} else if isResponse {...} else if carriesResult(...) {...} else {...} chain at one indentation level. Happy path is no longer buried in a default case.

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

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

Re-reviewed against head commit eaa6c0d. Items 1, 2, and 4 from the two review rounds are genuinely fixed with real regression tests that fail without the fix — good work there. Item 3 (the method+result frame-smuggling bypass) is only partially closed: carriesResult correctly catches the exact PoC shape via a proper JSON unmarshal, but it's only consulted in the err == nil && !isResponse branch. jsonrpc2.DecodeMessage returns an error — not a benign non-Response — for several upstream-controlled cases (missing/invalid jsonrpc version tag, a response-shaped frame with no id, a non-scalar id, or a batch/array payload), and all of those land in the err != nil branch, which still passes the line through unfiltered without ever checking for a smuggled result. That's the exact #5257 bypass this PR exists to close, just relocated to an easier-to-reach branch — and it fails open on a component whose entire job is enforcing an authz boundary against an untrusted upstream.

The sibling processJSONResponse has the identical unpatched shape for the application/json transport. The PR's 'Special notes for reviewers' dismisses this as 'already correct', but that's not right — the disguised-frame issue is transport-independent, not stream-specific.

Blocking:

  • Make the result-smuggling check run regardless of DecodeMessage's error outcome — fail closed on any data: line under a filtered method that either can't be classified as a benign non-response or carries a result — and handle array/batch payloads too.
  • Apply the same guard to processJSONResponse, or explicitly track it as a fast-follow and correct the PR notes (don't leave 'already correct' standing).
  • Extend TestResponseFilteringWriter_SSE_DisguisedResponseFrame with the additional smuggled shapes (missing jsonrpc tag, missing id, batch array) — all three currently leak admin_tool per this review.

Non-blocking:

  • carriesResult has no standalone unit test independent of the SSE integration test.
  • find_tool isn't covered by the new fallthrough test table (only the three list methods are).

Replied inline on the specific threads, plus two new comments below.

Comment thread pkg/authz/response_filter.go Outdated
if data, ok := bytes.CutPrefix(line, []byte("data:")); ok {
message, err := jsonrpc2.DecodeMessage(data)
response, isResponse := message.(*jsonrpc2.Response)
if err != nil {

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 is the actual hole. jsonrpc2.DecodeMessage returns a non-nil err — landing here, not in the carriesResult branch below — for several frame shapes a malicious/compromised upstream fully controls:

  • missing or invalid jsonrpc version tag
  • a response-shaped frame (no method) with no valid id
  • an id of a non-scalar type (object/array/bool)
  • a JSON array (batch) payload

All of these hit this branch and get written through unfiltered, e.g.:

data: {"id":1,"result":{"tools":[{"name":"admin_tool"}]}}

(missing jsonrpc tag → decode error → unfiltered passthrough), or

data: [{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"admin_tool"}]}}]

(batch array → decode error; note carriesResult's struct-based probe would also return false on this shape even if it ran here, since it doesn't handle arrays).

Any client lenient about the jsonrpc tag, a missing id, or JSON-RPC batching (which MCP supported prior to 2025-06-18) will read the smuggled result as the real response — exactly the Cedar bypass this PR sets out to close, just relocated here. Recommend restructuring so the smuggled-result check runs independent of whether DecodeMessage succeeded, and extending it to detect a result key inside array-shaped payloads too. This is the same class as #5257 and should fail closed, not open, on malformed/adversarial input.

// response and must reach the filter.
slog.Warn("SSE data line was not a JSON-RPC Response; passing through unfiltered",
"method", rfw.method)
}

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.

Minor: this branch (genuine non-Response frame, e.g. an interleaved notifications/* message) is spec-legitimate, routine SSE traffic, but it logs at Warn — the same level as the two actually-suspicious branches (undecodable line, smuggled-result drop). Warning on every routine notification will train operators to filter the WARN level, which then buries the two branches that actually indicate something adversarial. Consider demoting this one to Debug so severity tracks suspiciousness.

The SSE and JSON response filters only checked for a smuggled result on
frames that decoded as a non-Response. Frames that DecodeMessage rejects
outright (missing or invalid jsonrpc tag, a response frame with no or a
non-scalar id, a batch array) landed in the decode-error branch and were
written through unfiltered, the same Cedar bypass as stacklok#5257 relocated to an
easier-to-reach branch.

Run the smuggled-result check independent of decode success on both the
SSE and application/json paths, and extend carriesResult to detect a
result inside a batch array. Fail closed on these frames. Demote the
genuine-notification SSE branch to Debug so the two suspicious branches
are not buried under routine traffic.

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

Copy link
Copy Markdown
Author

Pushed 644506a. You were right on both counts.

The smuggled-result check now runs independent of DecodeMessage's outcome. I reordered the SSE branch so carriesResult sits between the clean-Response filter and the decode-error passthrough, so a result on any frame that is not a clean Response gets dropped, whether DecodeMessage returned a non-Response or errored. That covers the four shapes you listed: missing/invalid jsonrpc tag, no id, non-scalar id, and batch arrays.

carriesResult handles arrays now too: it probes the object form, and for a [-prefixed payload it unmarshals into a slice and drops the frame if any element carries a result.

processJSONResponse had the same shape, so I applied the same fix there: if the frame is not a clean Response but carries a result, it fails closed (writes {}) instead of passing the raw body through. My "already correct" note was wrong, the bug is transport-independent like you said.

Demoted the genuine-notification SSE branch to Debug so the two branches that actually indicate something adversarial (undecodable line, smuggled-result drop) keep Warn to themselves.

Tests: TestResponseFilteringWriter_SSE_DisguisedResponseFrame is now a table over all five frame shapes, and there is a new TestResponseFilteringWriter_JSON_DisguisedResponseFrame for the application/json path. I checked them against the pre-fix source first: batch, missing-jsonrpc, no-id, and the JSON transport all leaked admin_tool there and pass after the fix.

One judgment call worth flagging: on the JSON path I fail closed by writing {} rather than synthesizing a JSON-RPC error, since a rejected or batch frame may not carry a usable id. Can switch it to an error response if you would prefer that shape.

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