Parse SSE responses per event, not per line - #6088
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6088 +/- ##
==========================================
+ Coverage 72.34% 72.43% +0.08%
==========================================
Files 731 733 +2
Lines 75748 75853 +105
==========================================
+ Hits 54799 54943 +144
+ Misses 17047 17018 -29
+ Partials 3902 3892 -10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Three HTTP denial paths each carry a private copy of the same block: encode a *jsonrpc2.Response via jsonrpc2.EncodeMessage before writing any header, fall back to a hardcoded conformant body on the unreachable encode failure, then set Content-Type, write status, write body. That block exists because of #5950 — encoding/json on a jsonrpc2 type reflects Go-capitalized keys, drops the mandatory "jsonrpc":"2.0" tag, and renders every id as {} — so the only thing preventing a fourth hand-rolled copy from reintroducing that bug has been a greppable convention. #6055's review rejected a map-building shared helper, because an any-typed id parameter is exactly how a caller reintroduces the {} id bug, but noted a narrower helper taking a jsonrpc2 message avoids the objection. Add exactly that: mcp.EncodeJSONRPCError and mcp.WriteJSONRPCError take a typed *jsonrpc2.Response, so no id can reach them untyped. Scope is deliberately three sites, not four. The fourth carrier of this block, pkg/authz/response_filter.go, is being rewritten by #6087 and again by #6088; collapsing it here would guarantee a conflict and duplicate that work, so it keeps its private copy for now and can adopt the helper once those land. Behavior is unchanged except the per-site fallback bodies, which were unreachable and now uniformly report -32603 rather than echoing a code whose integrity is unknown once encoding has failed. Each site keeps its own status and its own code via #6066's mcp.JSONRPCCodeForStatus. The 'never json.Marshal a jsonrpc2 type' convention is also recorded in .claude/rules/go-style.md. A lint gate was considered and deliberately not added: forbidigo matches call text and cannot see argument types, so it cannot distinguish marshaling a jsonrpc2 value from any other json.Marshal call, and wiring up gocritic/ruleguard for one rule is not worth the infrastructure. The helper plus the recorded rule is the enforcement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
Three HTTP denial paths each carry a private copy of the same block: encode a *jsonrpc2.Response via jsonrpc2.EncodeMessage before writing any header, fall back to a hardcoded conformant body on the unreachable encode failure, then set Content-Type, write status, write body. That block exists because of #5950 — encoding/json on a jsonrpc2 type reflects Go-capitalized keys, drops the mandatory "jsonrpc":"2.0" tag, and renders every id as {} — so the only thing preventing a fourth hand-rolled copy from reintroducing that bug has been a greppable convention. #6055's review rejected a map-building shared helper, because an any-typed id parameter is exactly how a caller reintroduces the {} id bug, but noted a narrower helper taking a jsonrpc2 message avoids the objection. Add exactly that: mcp.EncodeJSONRPCError and mcp.WriteJSONRPCError take a typed *jsonrpc2.Response, so no id can reach them untyped. Scope is deliberately three sites, not four. The fourth carrier of this block, pkg/authz/response_filter.go, is being rewritten by #6087 and again by #6088; collapsing it here would guarantee a conflict and duplicate that work, so it keeps its private copy for now and can adopt the helper once those land. Behavior is unchanged except the per-site fallback bodies, which were unreachable and now uniformly report -32603 rather than echoing a code whose integrity is unknown once encoding has failed. Each site keeps its own status and its own code via #6066's mcp.JSONRPCCodeForStatus. The 'never json.Marshal a jsonrpc2 type' convention is also recorded in .claude/rules/go-style.md. A lint gate was considered and deliberately not added: forbidigo matches call text and cannot see argument types, so it cannot distinguish marshaling a jsonrpc2 value from any other json.Marshal call, and wiring up gocritic/ruleguard for one rule is not worth the infrastructure. The helper plus the recorded rule is the enforcement. Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JAORMX
left a comment
There was a problem hiding this comment.
Reviewed with the security posture first, since this is the authorization filter and the threat actor is the MCP server behind it.
Short version: this is a clear improvement on what's in main, and it fixes more than the description claims — see §4. It also introduces one bypass that I think needs closing before it lands, which is why I'm leading with that rather than because the PR is weak.
Everything marked CONFIRMED I reproduced by running code against this branch, fix-sse-filter-error-framing and origin/main. PLAUSIBLE is reasoning only.
1. Leading non-JSON Unicode whitespace bypasses the filter — CONFIRMED, must-fix
pkg/authz/response_filter.go:294 (assembly) with :399 (carriesResult).
The carriesResult rewrite moved bytes.TrimSpace inside valueCarriesResult, where it only runs on values json.Decoder has already accepted. encoding/json's scanner treats only SP, TAB, CR and LF as whitespace; bytes.TrimSpace / unicode.IsSpace also strip \v, \f, U+0085, U+00A0, U+1680 and U+3000. So for an event whose assembled payload begins with one of those:
jsonrpc2.DecodeMessagefails on the first byte → not theisResponsebranchcarriesResultfails on the same byte → not the fail-closed branch- control reaches
:357case decodeErr != nil→ the event's lines are forwarded verbatim, unfiltered
Meanwhile the go-sdk client parses that frame fine, because its own SSE scanner does exactly
case bytes.Equal(before, dataKey):
data := bytes.TrimSpace(after)— modelcontextprotocol/go-sdk mcp/event.go:139-146.
Scenario. Upstream answers tools/list with data: \v{"jsonrpc":"2.0","id":1,"result":{…full unfiltered list…}}\n\n. The proxy forwards it; a go-sdk client strips the \v and receives every tool, including the ones Cedar denies.
| leading byte | main |
#6087 | this PR | go-sdk client accepts |
|---|---|---|---|---|
\v \f U+0085 U+00A0 U+1680 U+3000 |
filtered | filtered | leaks | yes |
This is a regression introduced here rather than a pre-existing gap: main and #6087 catch all six, because the old carriesResult ran bytes.TrimSpace(data) before json.Unmarshal. It's the same disagreement-with-the-client class the PR exists to close, just relocated.
Fix, verified — one line at :294:
assembled := bytes.TrimSpace(bytes.Join(dataValues, []byte("\n")))This aligns assembly with the client's own normalisation. All six then come out properly filtered rather than merely dropped — weather survives, admin_tool gone — and the whole pkg/authz suite stays green. The mirror case (trailing \v/\f/NBSP) is already caught, because the decoder consumes the value before hitting it, so the gap is specifically leading bytes.
A strict WHATWG client is unaffected: JSON.parse rejects U+000B et al., since the JSON grammar's whitespace is only TAB/LF/CR/SP. That part is PLAUSIBLE — reasoned from the grammar, not executed — so the exposure I can demonstrate is Go clients.
A regression test is offered at the bottom of this review.
2. Per-event assembly gives up the per-line safety net — CONFIRMED, must-fix or disclose
response_filter.go:294-303 with :357, and carriesResult's stop-at-first-bad-value at :399.
One malformed data: value anywhere in an event now makes the whole event skip filtering.
Scenario. data: garbage followed by data: {"jsonrpc":"2.0","id":1,"result":{…list with admin_tool…}} inside one event (no blank line between). Assembled: garbage\n{…}. The decoder stops at garbage, carriesResult returns false, and both lines are forwarded unfiltered. On main and #6087 the second line was filtered independently. 9 of the 10 trivial prefixes I tried work (garbage, //comment, {, }, [, NaN, +1, 'quoted', an embedded NUL); only 01 fails.
To be fair to the code as written: TestCarriesResult pins this with a "don't fix this into scanning past bad input" comment, and its reasoning — no strict client can read past the bad value — is correct. My concern is narrower than the reasoning: it justifies not catching the result, whereas the consequence is forwarding the unfiltered list verbatim. That's a strictly weaker posture than the per-line loop it replaces, and it puts denied tool names in plaintext on the wire. The "Special notes" invariant is true as worded, but it doesn't surface that the rewrite trades away coverage main has.
Fix, verified: when the assembled payload as a whole is undecodable, probe each LF-separated data value on its own before passing through — linear, no recursion, no new amplification handle, and it leaves carriesResult itself (and its test) untouched. All ten prefixes then fail closed with the suite green.
Equally acceptable, and entirely your call: leave the behaviour and say so explicitly in the body and in the comment at :357. Right now the comment reads as "this is fine" rather than "we accept losing coverage here", which is the bit a future reader needs.
3. Vector 2 didn't leak before the fix — body only
Minor and worth one line: the second bullet's shape (two JSON-RPC messages as two data: fields of one event) measures FILTERED on both main and #6087 — the per-line loop filtered the second line independently. It's a hazard the event-based rewrite creates and then closes, which is exactly what TestResponseFilteringWriter_SSE_ConcatenatedEventBypass's doc comment says. Only the summary's "Each one was reproduced leaking before the fix" overclaims. The other four genuinely leaked.
4. Checked and found sound
The five vectors, each reproduced against main, #6087 and this branch on real wire bytes:
| # | vector | leaked pre-fix | closed here |
|---|---|---|---|
| 1 | payload split across two data: lines |
yes | yes, and correctly filtered |
| 2 | two messages as two data: fields |
no (see §3) | yes, fails closed with a correlatable envelope |
| 3 | mixed CR / LF / CRLF terminators | yes | yes, filtered |
| 4 | leading UTF-8 BOM | yes | yes, filtered, and the BOM is dropped rather than re-emitted |
| 5 | Response carrying both error and result |
yes, on SSE and JSON | yes on both paths |
Fail-closed posture preserved. With a body of failing-event, good-event, failing-event: neither failing event's payload reaches the wire, the good event is still filtered and delivered, two correlatable error envelopes go out, and all three event boundaries survive. Worth noting that the new error+result check makes the filter-failure path reachable for the first time, so SSE_ErrorAndResultBypass genuinely exercises the #5257 drop rather than testing cosmetics — that property had no coverage anywhere before this PR.
Every input path reaches a decision. I walked and tested events with no data: field, event: / id: / retry: / comment (:) lines, data: with no space, tab after colon, bare data, single-space "blank" lines, consecutive blank lines, blank-lines-only bodies, lone trailing CR, CR-only bodies, and an unterminated final event (filtered, no fabricated terminator). All correct. The idx+1 < len(rawResponse) bounds check at :221 holds for a lone trailing CR, and bytes.Join(dataValues, "\n") matches WHATWG's "append value then LF, strip the final LF" exactly.
Residual pass-throughs that are not new and that I could not turn into a leak: mid-payload BOM, data: data: {…}, DATA:, data :, and nested batch [[{…result…}]]. All byte-identical to main, and in each case a conformant client either ignores the field or can't parse the buffer. The data : comment is precisely right, by the way — I checked it against the go-sdk's bytes.Cut(line, ':') + bytes.Equal(before, dataKey), and data really does miss.
Things this PR fixes without claiming them, which is the part I'd most want a second reader to notice:
maincorrupts every multi-event SSE body. Two events in, one event with two concatenateddata:fields out — the blank line between them is eaten by the old separator reconciliation. The client assembles{notif}\n{response}, can't parse it, and loses the tool list entirely. Any server that emits a progress notification before itstools/listresponse hits this today. Fixed here and pinned bySSE_MultiEventBody.mainwrites nothing at all for a body with no line separator —unsupported SSE line separator in N-byte response, client gets an empty 200. This branch reproduces such a body byte-exactly.- The reachable half of #6037 is fixed here, not in #6087: on
mainthecarriesResultdrop path emits a bare separator and no data, which I measured as a full response of literally"\n\n".
Test quality. Assertions are on real wire bytes and additionally decode the fail-closed envelope to check the id correlates — that's the difference between "didn't leak" and "didn't leak and didn't hang", and it's the right assertion. SSE_FilteredEventTerminators exists specifically to cover a hole SSE_StructuralRoundTrip leaves open (a hardcoded "\n" in the replacement branch would otherwise pass the entire suite); that's careful self-review. Deliberate behaviours carry "don't fix this into X" comments that will actually stop a future regression. errorResponseBody being the single place both transports build their body is a structural guarantee rather than a convention, as the rebase note says. Production diff is 340 lines in one file, inside the 400-line guidance despite the +905 headline.
5. Nits
:213—outputLinesbuffers the body a second time as slice headers: 48 bytes per line, against 24 for the oldbytes.Split. Measured 200k blank lines → ~9.6 MB of headers for 200 KB of body, and the line count is upstream-controlled. Pre-existing in kind, doubled here; only worth a bound if you care. My taste, not a documented convention.:246/:249— twoWritecalls per line, includingWrite(nil)for every blank line's text. Harmless behindbufio; thetermwrite could be skipped when nil. Taste.filterListResponse:465—errorpresent together with"result":nullis now a violation, so a legitimate upstream error carrying"result":nullhas its code and message replaced by our internal-error envelope. Fail-closed direction and defensible; a!bytes.Equal(result, []byte("null"))refinement or a comment would avoid surprising that upstream.- Undisclosed behaviour change: an upstream legitimately packing two messages into one event now gets a hard error envelope instead of a filtered list. Off-spec for MCP, so failing closed is right — just worth a line in the body.
6. Verdict
needs-work → merge once §1 is fixed (one line, verified) and §2 is either fixed or explicitly disclosed. §1 is the only thing I'd actually block on; everything else is sound, and the rewrite is better reasoned than the code it replaces.
On sequencing: I'd land this together with #6087 rather than separately. I byte-compared #6087 against origin/main across a 25-shape matrix and the multi-event framing cases and it is identical on every reachable body — so it's safe alone, but it delivers no reachable behaviour change either, and the mis-framing its comment flags is already on main rather than introduced by it. All the value is in this PR: four real leaks and two functional bugs. (#6087 also currently doesn't compile — details in a comment over there.)
7. What I did not verify
- TS-SDK client exploitability for §1 and for the residual pass-throughs is reasoned from the WHATWG event-stream grammar and the JSON whitespace production, not executed. The go-sdk side is executed.
- I tested at the
ResponseFilteringWriterseam withhttptest, not end-to-end through a real proxy and MCP server. pkg/mcp/tool_filter.go— which the comment at:202says this routine was adapted from — almost certainly carries the same class of bug and I did not review it. Out of scope here, but probably worth its own pass.- I did not run
task lint/task test; I usedgo test ./pkg/authz/directly. CI is green on this branch, so lint is covered.
Offered regression test for §1
Adapted to this branch's helpers and style. I ran it here: it fails on 6 of 7 cases against this branch as-is (the no-prefix baseline passes) and passes with the one-line :294 fix, with the rest of pkg/authz green. Take it, rewrite it, or drop it — a permanent guard seemed worth more than the prose above.
Each case asserts the authorized tool survives, deliberately: a NotContains check alone would also be satisfied by failing closed, which would silently degrade every legitimate response that happens to carry leading whitespace.
pkg/authz/response_filter_test.go
// TestResponseFilteringWriter_SSE_LeadingUnicodeSpaceBypass is a regression
// test for a #5257-class leak: the filter and the client must agree on where
// an event's data payload starts.
//
// bytes.TrimSpace (and therefore unicode.IsSpace) treats \v, \f, U+0085,
// U+00A0, U+1680 and U+3000 as whitespace; encoding/json's scanner accepts
// only SP, TAB, CR and LF. So a payload whose first byte is one of those is
// undecodable to us -- filterSSEEventData's decodeErr branch passes the event
// through unfiltered -- while the go-sdk client strips it and parses the frame
// fine, because its own SSE scanner does exactly
//
// data := bytes.TrimSpace(after)
//
// (modelcontextprotocol/go-sdk mcp/event.go:139-146). carriesResult doesn't
// catch it either: json.Decoder fails on the same first byte, and the
// bytes.TrimSpace inside valueCarriesResult only runs on values the decoder
// already accepted.
//
// The fix is to normalise the assembled payload the same way the client does,
// so these are filtered rather than forwarded -- which is why each case
// asserts the authorized tool SURVIVES: merely failing closed would also
// satisfy a NotContains check, and would silently degrade every legitimate
// response that happens to carry leading whitespace.
func TestResponseFilteringWriter_SSE_LeadingUnicodeSpaceBypass(t *testing.T) {
t.Parallel()
authorizer := newWeatherOnlyAuthorizer(t)
resultJSON, err := json.Marshal(mcp.ListToolsResult{
Tools: []mcp.Tool{
{Name: "weather", Description: "Get weather information"},
{Name: "admin_tool", Description: "Sensitive admin operations"},
},
})
require.NoError(t, err)
encoded, err := jsonrpc2.EncodeMessage(&jsonrpc2.Response{
ID: jsonrpc2.Int64ID(1),
Result: json.RawMessage(resultJSON),
})
require.NoError(t, err)
testCases := []struct {
name string
prefix string
}{
{name: "no prefix (baseline)", prefix: ""},
{name: "vertical tab U+000B", prefix: "\v"},
{name: "form feed U+000C", prefix: "\f"},
{name: "next line U+0085", prefix: "�"},
{name: "no-break space U+00A0", prefix: " "},
{name: "ogham space mark U+1680", prefix: " "},
{name: "ideographic space U+3000", prefix: " "},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := newUser1Request(t)
rr := httptest.NewRecorder()
rfw := NewResponseFilteringWriter(rr, authorizer, req, string(mcp.MethodToolsList), nil, nil)
rfw.ResponseWriter.Header().Set("Content-Type", "text/event-stream")
_, err := rfw.Write([]byte("data: " + tc.prefix + string(encoded) + "\n\n"))
require.NoError(t, err)
require.NoError(t, rfw.FlushAndFilter())
out := rr.Body.String()
assert.NotContains(t, out, "admin_tool",
"a leading byte that bytes.TrimSpace strips but encoding/json rejects must not bypass the filter")
assert.Contains(t, out, "weather",
"the event must be filtered, not merely failed closed: the authorized tool must survive")
// The replacement payload must be a frame the client can decode.
// Asserting on the wire bytes rather than on substrings pins that
// the prefix was normalised away, not merely tolerated.
firstLine := strings.TrimSuffix(strings.SplitN(out, "\n", 2)[0], "\r")
msg, err := jsonrpc2.DecodeMessage([]byte(strings.TrimPrefix(firstLine, "data: ")))
require.NoError(t, err, "the emitted frame must itself be valid JSON-RPC")
resp, ok := msg.(*jsonrpc2.Response)
require.True(t, ok, "the emitted frame must be a clean Response")
assert.Equal(t, jsonrpc2.Int64ID(1), resp.ID,
"the upstream response id must be preserved")
})
}
}587fd35 to
081a768
Compare
1d9eab3 to
f6cd378
Compare
Both are regressions the per-event rewrite introduced, found by review on #6088 and reproduced before fixing. Assembling the event's data values left leading whitespace on the payload. Go's JSON scanner treats only SP, TAB, CR and LF as whitespace, but unicode.IsSpace also covers U+000B, U+000C, U+0085, U+00A0, U+1680 and U+3000 — and the go-sdk client trims its data buffer with exactly bytes.TrimSpace. So a payload led by any of those six was rejected by both DecodeMessage and carriesResult while the client parsed it happily, and the event fell through to the undecodable branch with the unfiltered list intact. Trim on assembly instead, which aligns us with the client's own normalization: all six now filter correctly rather than merely failing closed. The second is a coverage regression rather than a new hole. Processing per event means one malformed data value makes the whole event skip filtering, where the old per-line loop filtered each line independently. A result-bearing value sitting after a malformed one in the same event therefore passed through raw. Probe each data value on its own before conceding the event needs no filtering. This only ever turns a pass-through into a fail-closed envelope, never the reverse, so it cannot reintroduce the split-payload bypass the rewrite closed. Tests for both, plus the two the review asked for: a strict WHATWG-grammar SSE parser so the error-path assertions test delivery rather than bytes — conformant payloads that no client dispatches are the whole of #6037 — and the fail-closed invariant for the rest of the stream after a mid-stream failure, which nothing pinned before. That last test asserts an authorized tool survives the second event, not just that the denied one is absent. Absence alone would also hold if the loop truncated the stream, which is the regression the test exists to catch; verified against that mutation specifically. Refs #6037 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both must-fixes are in, each reproduced before fixing and revert-proven after. Pushed as a separate commit rather than amended, so the delta against what you reviewed is visible. §1 — leading non-JSON whitespace: CONFIRMED, fixedReproduced exactly as you described. All six prefixes leaked Your one-line fix at the assembly point, with the reasoning in a comment. After it, all six come out filtered — You're right that this is a regression this PR introduced rather than a pre-existing gap, and right about why: moving §2 — per-event assembly giving up the per-line net: fixed, not just disclosedFixed. When the assembled payload as a whole is undecodable, each Your distinction is what made me fix rather than disclose: §3 — body correctedCorrected. Vector 2 (two messages as two Your three offered test assets — all taken
Nits
One interaction from the rebase worth flagging#6092's fail-closed On Finally — on |
Both are regressions the per-event rewrite introduced, found by review on #6088 and reproduced before fixing. Assembling the event's data values left leading whitespace on the payload. Go's JSON scanner treats only SP, TAB, CR and LF as whitespace, but unicode.IsSpace also covers U+000B, U+000C, U+0085, U+00A0, U+1680 and U+3000 — and the go-sdk client trims its data buffer with exactly bytes.TrimSpace. So a payload led by any of those six was rejected by both DecodeMessage and carriesResult while the client parsed it happily, and the event fell through to the undecodable branch with the unfiltered list intact. Trim on assembly instead, which aligns us with the client's own normalization: all six now filter correctly rather than merely failing closed. The second is a coverage regression rather than a new hole. Processing per event means one malformed data value makes the whole event skip filtering, where the old per-line loop filtered each line independently. A result-bearing value sitting after a malformed one in the same event therefore passed through raw. Probe each data value on its own before conceding the event needs no filtering. This only ever turns a pass-through into a fail-closed envelope, never the reverse, so it cannot reintroduce the split-payload bypass the rewrite closed. Tests for both, plus the two the review asked for: a strict WHATWG-grammar SSE parser so the error-path assertions test delivery rather than bytes — conformant payloads that no client dispatches are the whole of #6037 — and the fail-closed invariant for the rest of the stream after a mid-stream failure, which nothing pinned before. That last test asserts an authorized tool survives the second event, not just that the denied one is absent. Absence alone would also hold if the loop truncated the stream, which is the regression the test exists to catch; verified against that mutation specifically. Refs #6037 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f6cd378 to
f2eb6c1
Compare
|
Correction to my previous comment: the §2 fix was not actually in the commit I pushed, despite the commit message claiming it. Fixed and force-pushed as What happened: I ran mutation experiments on My process error is the more useful part: I committed on the strength of a full-suite run that preceded the final file state, rather than verifying the committed content. So the green result I reported was real but stale. The commit shipped with its own guard test failing, which I have now confirmed directly against Two things worth noting, since they bound the damage:
I have changed how I verify: every commit in the stack is now checked out into its own worktree and tested there, not at the tip. All four pass: And I verified the pushed object contains the loop, not just my local one. One correction to my own claim in the previous comment, from the same pass: I said test 4 (the fail-closed-rest-of-stream invariant) also guards the §2 fix. It does not. Its failing frame carries |
The response filter processed a text/event-stream body line by line, but SSE semantics are per event: within one event all "data:" field values concatenate, and a blank line is structural. Processing lines meant we parsed the body differently than the client does, and every such disagreement is a way to smuggle an unfiltered list past the filter (#5257). Five were reachable: - A payload split across two "data:" lines. Each half failed to decode on its own, so both passed through and the client reassembled the full list. - Two JSON-RPC messages as two "data:" fields of one event. Assembling produced two concatenated values, which json.Unmarshal rejects as trailing data, so carriesResult missed it. - Mixed line terminators. SSE permits CR, LF and CRLF interchangeably within one stream; sniffing one convention for the whole body left entire events buried inside what we treated as a single line. - A leading UTF-8 BOM. Clients strip it per the WHATWG decode algorithm before parsing lines, so our "data:" prefix match failed where theirs succeeded. - A Response carrying both error and result. filterListResponse returned early on any error, and the result went out untouched. The reference client's schema strips the stray error and accepts the result. Scan (line, terminator) pairs and group them into events, assemble each event's data payload, then decode and filter once. Every line is re-emitted with the terminator it was scanned with, so the body is reproduced structurally and the old separator reconciliation — which wrote back at most one separator however many it dropped, collapsing multi-event bodies — is deleted rather than patched. A frame that fails closed now carries a correlatable JSON-RPC error instead of an empty data buffer, which per WHATWG is never dispatched and left the client hanging on its own timeout. At the SSE framing layer, every payload a strict client can parse and correlate as this request's Response is now assembled and decoded before filtering. The residual pass-through paths carry only payloads no strict parser accepts: a split inside a string literal leaves a raw control character in a JSON string, which the client's own parser rejects. So the gap is closed by construction rather than by luck. The per-method result-shape fallbacks in filterToolsResponse and filterFindToolResponse are a separate matter, untouched here. Refs #6037 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
External review of the two preceding commits found no correctness regressions but surfaced a comment that would have led a future contributor to loosen a security guard, and a test hole that let a real regression pass the whole suite. The comment: objectCarriesResult was documented as reporting a non-null "result" field. RawMessage's UnmarshalJSON runs even for a JSON null, so probe.Result holds the 4 bytes "null" whenever the key exists at all and an explicit null counts as carrying a result. That is the fail-closed direction and is deliberate, but anyone adding a null check to match the old wording would have widened the smuggling surface. Say what the code does and why. The test hole: every StructuralRoundTrip row asserts byte-identity for a body needing no filtering, and every bypass test used LF bodies, so nothing exercised a filtered event on a CRLF stream. Hardcoding the replacement line's terminator as "\n" passed the entire suite. TestResponseFilteringWriter_SSE_FilteredEventTerminators covers that and the unterminated trailing event, which also pins the deliberate choice not to fabricate a terminator. Also: carriesResult now has direct coverage, including the null case above and the two deliberate false results (an undecodable prefix, and a nested array we do not recurse into) so neither gets "fixed" into a regression. Three verb-free fmt.Errorf calls become errors.New, the duplicated result probe is extracted, and the cedar-authorizer and request boilerplate repeated across ten SSE tests moves into helpers that assert the parsed request actually reached the context. Deliberately unchanged: the JSON-RPC error code stays 500 (#6039), and an absent id stays omitted rather than null, matching #6068. Refs #6037 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are regressions the per-event rewrite introduced, found by review on #6088 and reproduced before fixing. Assembling the event's data values left leading whitespace on the payload. Go's JSON scanner treats only SP, TAB, CR and LF as whitespace, but unicode.IsSpace also covers U+000B, U+000C, U+0085, U+00A0, U+1680 and U+3000 — and the go-sdk client trims its data buffer with exactly bytes.TrimSpace. So a payload led by any of those six was rejected by both DecodeMessage and carriesResult while the client parsed it happily, and the event fell through to the undecodable branch with the unfiltered list intact. Trim on assembly instead, which aligns us with the client's own normalization: all six now filter correctly rather than merely failing closed. The second is a coverage regression rather than a new hole. Processing per event means one malformed data value makes the whole event skip filtering, where the old per-line loop filtered each line independently. A result-bearing value sitting after a malformed one in the same event therefore passed through raw. Probe each data value on its own before conceding the event needs no filtering. This only ever turns a pass-through into a fail-closed envelope, never the reverse, so it cannot reintroduce the split-payload bypass the rewrite closed. Tests for both, plus the two the review asked for: a strict WHATWG-grammar SSE parser so the error-path assertions test delivery rather than bytes — conformant payloads that no client dispatches are the whole of #6037 — and the fail-closed invariant for the rest of the stream after a mid-stream failure, which nothing pinned before. That last test asserts an authorized tool survives the second event, not just that the denied one is absent. Absence alone would also hold if the loop truncated the stream, which is the regression the test exists to catch; verified against that mutation specifically. Refs #6037 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f2eb6c1 to
3fed560
Compare
Panel review — recommendation: REQUEST-CHANGESThe per-event rewrite is the right design and closes real #5257 bypasses (concatenated events, split payloads within one event, error+result smuggling, silent-drop → fail-closed envelopes). But it introduces one new bypass (a regression vs Root cause: the rewrite adopts strict WHATWG EventSource line semantics, but the reference client it cites — go-sdk Gating[1] CRITICAL — lone CR is a line terminator for the proxy but JSON whitespace for the go-sdk client. Full authz bypass, regression vs Upstream returns
[2] HIGH — A Fix: [3] MEDIUM (gating subset) — the test oracle shares the implementation's bug. Non-gating follow-ups (fine as a second PR)
What the PR gets right — please don't churn theseThe core per-event thesis and All three gating fixes are small and can only convert pass-throughs into filtered or fail-closed frames, so none can create new exposure — this should be one revision cycle, not a redesign. 🤖 Multi-lens panel review (SSE framing · security-bypass · JSON-RPC/protocol · Go correctness · test coverage), each finding adversarially verified. Generated with Claude Code. |
Panel review on #6088 found the rewrite parses less than the client that consumes these streams, and where the two disagree an upstream can put a list through the gap. go-sdk's scanEvents — used by ToolHive's own vMCP and mcpcompat stacks — reads lines with ReadBytes('\n'), so a lone CR is not a terminator, and it applies TrimSpace to each data value rather than to the assembled buffer. The scanner here did the opposite on both counts, following the WHATWG grammar instead. A bare CR inside a data payload therefore split a line for us and not for the client: our second piece lost its "data:" prefix, the assembled payload was truncated, nothing decoded, and the body went out verbatim with the list intact. main filtered that body, so this was a regression against it. Separately, one of the six whitespace bytes Go's JSON scanner rejects but unicode.IsSpace accepts, sitting at an interior value boundary, survived our single whole-buffer trim while the client stripped it per value. main leaked that one too. Scan on LF only and trim each value before joining. That parses a superset of what we saw before, so it can only decode and filter more, never less. A browser EventSource does treat a lone CR as a terminator and will mis-frame a pass-through body carrying one — but identically to how it would mis-frame the backend's own bytes, since the output stays byte-preserving, so the worst case is an unparseable payload rather than a disclosure. Under LF-only scanning a CRLF line's CR lives in the line text, not the terminator, so replacing a data line would have silently downgraded it to bare LF. The CR is re-attached when a line is replaced. The test oracle had the same bug as the code: it reimplemented WHATWG line splitting, so the suite agreed with the implementation by construction and could not have caught any of this. It now models the go-sdk client, which is the reading that decides whether a payload reaches a caller. Also from the review: the fail-closed envelope inherited the offending event's name, and go-sdk skips events not named "message", so on a named event the #6037 hang survived the fix that was meant to end it. And an upstream error carrying an explicit "result":null lost its real code to our generic envelope; a null result cannot carry a list, so that check now exempts it. Three comments asserted things about go-sdk that reading it disproves, including the one that made the trim granularity look safe. Corrected. Refs #6037 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All three gating findings fixed in The invariant is the finding"The filter must never parse less than any real client" is the right frame and I had the wrong one. I built the scanner to the WHATWG grammar; the reading that decides whether a payload reaches a caller is go-sdk's [1] CRITICAL — confirmed, including the regressionReproduced end-to-end, with a client model built from
So [1] regresses Fix: LF-only scan, per-value trimWent with matching the client rather than failing closed on stray CR, because it can only ever decode and filter more than before, never less. Reasoning now in the code: a browser One subtlety the LF-only port introduces that wasn't in your writeup: a CRLF line's [3] — the sharpest finding, and it indicts a test I acceptedYou're right that the oracle shares the implementation's bug and so structurally cannot detect [1]. Worth recording how it got there: Both regressions now have tests, each asserting the authorized tool survives as well as the denied one being absent — a Non-gating: [4], [5], [7] taken now; [6] left
On the two wrong citationsBoth were mine and both were load-bearing: I wrote that go-sdk "trims its data buffer with exactly this function" (it trims per value — that is [2]) and that a spec-compliant client never dispatches an unterminated trailing event. Corrected, and I've kept the practice of citing Remaining test gaps you listed are closed except the Four commits, each verified in its own worktree so the stack stays bisectable. |
Panel review on #6088 found the rewrite parses less than the client that consumes these streams, and where the two disagree an upstream can put a list through the gap. go-sdk's scanEvents — used by ToolHive's own vMCP and mcpcompat stacks — reads lines with ReadBytes('\n'), so a lone CR is not a terminator, and it applies TrimSpace to each data value rather than to the assembled buffer. The scanner here did the opposite on both counts, following the WHATWG grammar instead. A bare CR inside a data payload therefore split a line for us and not for the client: our second piece lost its "data:" prefix, the assembled payload was truncated, nothing decoded, and the body went out verbatim with the list intact. main filtered that body, so this was a regression against it. Separately, one of the six whitespace bytes Go's JSON scanner rejects but unicode.IsSpace accepts, sitting at an interior value boundary, survived our single whole-buffer trim while the client stripped it per value. main leaked that one too. Scan on LF only and trim each value before joining. That parses a superset of what we saw before, so it can only decode and filter more, never less. A browser EventSource does treat a lone CR as a terminator and will mis-frame a pass-through body carrying one — but identically to how it would mis-frame the backend's own bytes, since the output stays byte-preserving, so the worst case is an unparseable payload rather than a disclosure. Under LF-only scanning a CRLF line's CR lives in the line text, not the terminator, so replacing a data line would have silently downgraded it to bare LF. The CR is re-attached when a line is replaced. The test oracle had the same bug as the code: it reimplemented WHATWG line splitting, so the suite agreed with the implementation by construction and could not have caught any of this. It now models the go-sdk client, which is the reading that decides whether a payload reaches a caller. Also from the review: the fail-closed envelope inherited the offending event's name, and go-sdk skips events not named "message", so on a named event the #6037 hang survived the fix that was meant to end it. And an upstream error carrying an explicit "result":null lost its real code to our generic envelope; a null result cannot carry a list, so that check now exempts it. Three comments asserted things about go-sdk that reading it disproves, including the one that made the trim granularity look safe. Corrected. Refs #6037 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c0e6092 to
fefd7b4
Compare
networking.FindOrUsePort probes a port by binding then immediately releasing it, so anything else on the machine can steal the number before the real bind happens later. Under sharded CI this is lost often enough to flake (observed on PR #6088 as "OIDC mock server error: listen tcp :49278: bind: address already in use"). Two fixes depending on where the real bind happens: - In-process mock servers (OIDCMockServer, LLMGatewayMock) now open their own listener at construction time instead of accepting a pre-selected port, closing the window entirely. - Subprocess-launched proxies (thv serve, thv llm proxy start) get a shared RetryOnPortConflict helper that retries once on a detected bind conflict, generalizing the pattern already used for Docker workload port races in the vMCP dual-era test helpers. thv proxy needed a different approach: it silently substitutes a different port on conflict for a nonzero request (no error to retry on) and its real bind can lag port selection by a full OAuth exchange, so those tests instead pass --port 0 and read the real bound port back from the subprocess's own stdout. Production callers with the same root cause (Docker port bindings, in-process OAuth/proxy binders) are tracked separately in #6141. Generated with Claude Code (https://claude.com/claude-code)
Summary
processSSEResponseprocessed a bufferedtext/event-streambody line by line. SSE semantics are per event: within one event alldata:field values concatenate, and a blank line is structural. Processing lines meant we parsed the body differently than the client does — and every such disagreement is a way for an upstream MCP server to smuggle an unfiltered tool list past the filter (#5257). Five were reachable. Four were reproduced leaking before the fix and verified filtered after; the second below did not leak beforehand — the per-line loop filtered that line independently, so it is a hazard this rewrite creates and then closes:data:lines. Each half failed to decode on its own, so both passed through and the client reassembled the full list.data:fields of one event. Assembling produced two concatenated values, whichjson.Unmarshalrejects as trailing data, so the result-detector missed it. This one did not leak onmain— it is created and closed within this PR.data:prefix match failed where theirs succeeded.errorandresult.filterListResponsereturned early on any error and the result went out untouched. The reference client's schema strips the strayerrorand accepts the result. Transport-independent — this one affected the JSON path too.The fix scans
(line, terminator)pairs and groups them into events, assembles each event's data payload, then decodes and filters once. Every line is re-emitted with the terminator it was scanned with, so the body is reproduced structurally and the old separator reconciliation — which wrote back at most one separator however many it dropped, collapsing multi-event bodies — is deleted rather than patched.A frame that fails closed now carries a correlatable JSON-RPC error instead of an empty data buffer, which per WHATWG is never dispatched and left the client hanging on its own timeout.
Refs #6037
Type of change
Test plan
task test)task test-e2e)task lint-fix)go test -count=1 ./pkg/authz/...green.task testpanics locally on agotestfmtbug that reproduces on a clean tree, so the underlyinggo testwas used, always with-count=1after the cache masked a result once.Each of the five bypasses has a dedicated regression test asserting on real wire bytes, and each was proven to fail against the pre-fix code. Beyond that:
{CR, LF, CRLF}² × {none, LF, CRLF, CR}.Linting not run locally, per a standing preference; leaving it to CI.
Does this introduce a user-facing change?
Yes, though it is a security fix rather than a feature. Tool, prompt and resource lists that an upstream MCP server could previously smuggle past authorization filtering — by framing the response in ways a client parses differently than the proxy did — are now filtered correctly.
Special notes for reviewers
The security invariant, worded carefully. At the SSE framing layer, every payload a strict client can parse and correlate as this request's Response is now assembled and decoded before filtering. The residual pass-through paths carry only payloads no strict parser accepts: a split inside a string literal leaves a raw control character in a JSON string, which the client's own parser rejects. So the gap is closed by construction rather than by luck — measured both ways.
This deliberately does not claim more. It is not true of the per-method filters underneath, which still fall back to unfiltered pass-through on a result shape they cannot unmarshal. That is pre-existing and out of scope here.
Two deliberate non-recursions / non-fixes, both load-bearing:
carriesResultwalks top-level JSON values but bails at the first undecodable one, and the batch probe scans one level without recursing. Recursing was implemented and then backed out: it is O(d²) againstencoding/json's own 10000 nesting cap, an easy amplification handle from the same adversarial upstream, bought to protect against nested-batch flattening no client performs. Tests pin bothfalseresults so neither gets "fixed" into a regression.Two disclosed behaviour changes. An upstream legitimately packing two JSON-RPC messages into one SSE event now receives a fail-closed error envelope rather than a filtered list; that shape is off-spec for MCP, so failing closed is right, but it is a change. And a Response carrying
errortogether with an explicit"result":nullis now treated as the both-fields protocol violation, so its code and message are replaced by our envelope — the fail-closed direction, andobjectCarriesResulttreats a null result as carrying one deliberately, with a test row pinning it.Rebase note. #6066 landed on
mainmid-review and scrubs the client-visible error text, since it can name tools the caller is not authorized to see. The conflict resolution keeps that scrub and moves it into the one place both transports build their error body, so neither can leak it — a structural guarantee rather than a convention.Review round applied. Two confirmed regressions this rewrite introduced are fixed in the final commit: leading non-JSON Unicode whitespace (U+000B, U+000C, U+0085, U+00A0, U+1680, U+3000) bypassing the filter because Go's JSON scanner and
unicode.IsSpacedisagree about what whitespace is, and the loss of the per-line safety net when one malformeddata:value made a whole event skip filtering. Both reproduced before fixing and revert-proven after. Also added, from the reviewer: a strict WHATWG-grammar SSE parser so error-path assertions test delivery rather than bytes, and the fail-closed invariant for the remainder of the stream after a mid-stream failure.Not folded in: #6092's
sseCarriesResultremains line-based inside this now-per-event file. It only decides whether to filter and errs toward filtering, so it is a readability inconsistency rather than a correctness one, and its author offered to leave it.Stacked on #6087; review that first.
Generated with Claude Code