-
Notifications
You must be signed in to change notification settings - Fork 247
Stop SSE filter from leaking tools/list on undecodable lines #5304
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
f6f26ce
3984262
e37f687
8d39fde
eaa6c0d
644506a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -187,35 +187,42 @@ func (rfw *ResponseFilteringWriter) processSSEResponse(rawResponse []byte) error | |
| var written bool | ||
| if data, ok := bytes.CutPrefix(line, []byte("data:")); ok { | ||
| message, err := jsonrpc2.DecodeMessage(data) | ||
| if err != nil { | ||
| rfw.ResponseWriter.WriteHeader(rfw.statusCode) | ||
| _, err := rfw.ResponseWriter.Write(rawResponse) | ||
| return err | ||
| } | ||
|
|
||
| response, ok := message.(*jsonrpc2.Response) | ||
| if !ok { | ||
| rfw.ResponseWriter.WriteHeader(rfw.statusCode) | ||
| _, err := rfw.ResponseWriter.Write(rawResponse) | ||
| return err | ||
| } | ||
|
|
||
| filteredResponse, err := rfw.filterListResponse(response) | ||
| if err != nil { | ||
| return rfw.writeErrorResponse(response.ID, err) | ||
| } | ||
|
|
||
| filteredData, err := jsonrpc2.EncodeMessage(filteredResponse) | ||
| if err != nil { | ||
| return rfw.writeErrorResponse(response.ID, err) | ||
| switch { | ||
| case err != nil: | ||
| // 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Warning: WARN logs are only emitted for Both log conditions — The security fix itself is correct for all methods (the Consider replacing both method checks with
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed FIXED — all three branches in |
||
| slog.Warn("SSE data line could not be decoded as JSON-RPC; passing through unfiltered", | ||
| "method", rfw.method, "error", err) | ||
| } | ||
| default: | ||
| if response, ok := message.(*jsonrpc2.Response); ok { | ||
| filteredResponse, err := rfw.filterListResponse(response) | ||
| if err != nil { | ||
| return rfw.writeErrorResponse(response.ID, err) | ||
| } | ||
|
|
||
| filteredData, err := jsonrpc2.EncodeMessage(filteredResponse) | ||
| if err != nil { | ||
| return rfw.writeErrorResponse(response.ID, err) | ||
| } | ||
|
|
||
| _, err = rfw.ResponseWriter.Write([]byte("data: " + string(filteredData) + "\n")) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This hardcodes Drop the
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| if err != nil { | ||
| return fmt.Errorf("%w: %w", errBug, err) | ||
| } | ||
|
|
||
| written = true | ||
| } else if rfw.method == string(mcp.MethodToolsList) { | ||
| // Non-Response message (e.g. a notifications/* frame | ||
| // interleaved on the stream). Pass through unfiltered for | ||
| // this line only; the next data line may still be the real | ||
| // tools/list response and must reach the filter. | ||
| slog.Warn("SSE data line was not a JSON-RPC Response; passing through unfiltered", | ||
| "method", rfw.method) | ||
| } | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: this branch (genuine non-Response frame, e.g. an interleaved |
||
|
|
||
| _, err = rfw.ResponseWriter.Write([]byte("data: " + string(filteredData) + "\n")) | ||
| if err != nil { | ||
| return fmt.Errorf("%w: %w", errBug, err) | ||
| } | ||
|
|
||
| written = true | ||
| } | ||
|
|
||
| if !written { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -863,3 +863,126 @@ func TestOptimizerPassThroughToolsInResponseFilter(t *testing.T) { | |
| "admin_tool has no permit policy and is not a pass-through tool") | ||
| }) | ||
| } | ||
|
|
||
| // TestResponseFilteringWriter_SSE_PerLineFallthrough is a regression test for | ||
| // issue #5257: when an SSE upstream interleaves a non-Response data line (e.g. | ||
| // 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Warning: Regression test only covers The new test hardcodes A brief additional sub-test for
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed FIXED — |
||
| // stream. | ||
| func TestResponseFilteringWriter_SSE_PerLineFallthrough(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ | ||
| Policies: []string{ | ||
| `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, | ||
| }, | ||
| EntitiesJSON: `[]`, | ||
| }, "") | ||
| require.NoError(t, err) | ||
|
|
||
| identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ | ||
| Subject: "user1", | ||
| Claims: map[string]interface{}{"sub": "user1"}, | ||
| }} | ||
|
|
||
| // Real tools/list response. The caller is only authorized for "weather", | ||
| // so a working filter must drop "admin_tool" from the response. | ||
| toolsResult := mcp.ListToolsResult{ | ||
| Tools: []mcp.Tool{ | ||
| {Name: "weather", Description: "Get weather information"}, | ||
| {Name: "admin_tool", Description: "Sensitive admin operations"}, | ||
| }, | ||
| } | ||
| resultJSON, err := json.Marshal(toolsResult) | ||
| require.NoError(t, err) | ||
| encodedResp, err := jsonrpc2.EncodeMessage(&jsonrpc2.Response{ | ||
| ID: jsonrpc2.Int64ID(1), | ||
| Result: json.RawMessage(resultJSON), | ||
| }) | ||
| require.NoError(t, err) | ||
| respLine := "data: " + string(encodedResp) | ||
|
|
||
| testCases := []struct { | ||
| name string | ||
| precedingLines []string | ||
| }{ | ||
| { | ||
| name: "non-response data line before tools/list", | ||
| precedingLines: []string{ | ||
| // A notifications/* frame is a valid JSON-RPC notification | ||
| // (no id), so jsonrpc2.DecodeMessage returns a non-Response | ||
| // message. The buggy path treated this as a signal to dump | ||
| // rawResponse and return. | ||
| `data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"warming up"}}`, | ||
| }, | ||
| }, | ||
| { | ||
| name: "undecodable data line before tools/list", | ||
| precedingLines: []string{ | ||
| `data: this is not json at all`, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range testCases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| req, err := http.NewRequest(http.MethodPost, "/messages", nil) | ||
| require.NoError(t, err) | ||
| req = req.WithContext(auth.WithIdentity(req.Context(), identity)) | ||
|
|
||
| rr := httptest.NewRecorder() | ||
| rfw := NewResponseFilteringWriter(rr, authorizer, req, string(mcp.MethodToolsList), nil, nil) | ||
| rfw.ResponseWriter.Header().Set("Content-Type", "text/event-stream") | ||
|
|
||
| body := strings.Join(append(tc.precedingLines, respLine, ""), "\n") | ||
| _, err = rfw.Write([]byte(body)) | ||
| require.NoError(t, err) | ||
|
|
||
| require.NoError(t, rfw.FlushAndFilter()) | ||
|
|
||
| out := rr.Body.String() | ||
|
|
||
| // Each preceding line must still appear verbatim; pass-through | ||
| // is the whole point of the fix. | ||
| for _, pl := range tc.precedingLines { | ||
| assert.Contains(t, out, pl, "non-response/undecodable preceding line must pass through unchanged") | ||
| } | ||
|
|
||
| // The real tools/list response must have been filtered. Pull the | ||
| // last data line out and decode it. | ||
| var filteredLine string | ||
| for _, line := range strings.Split(out, "\n") { | ||
| if strings.HasPrefix(line, "data: {\"jsonrpc\"") && strings.Contains(line, `"result"`) { | ||
| filteredLine = line | ||
| } | ||
| } | ||
| require.NotEmpty(t, filteredLine, "no JSON-RPC Response data line found in output") | ||
|
|
||
| payload := strings.TrimPrefix(filteredLine, "data: ") | ||
| msg, err := jsonrpc2.DecodeMessage([]byte(payload)) | ||
| require.NoError(t, err) | ||
| resp, ok := msg.(*jsonrpc2.Response) | ||
| require.True(t, ok) | ||
|
|
||
| var filtered mcp.ListToolsResult | ||
| require.NoError(t, json.Unmarshal(resp.Result, &filtered)) | ||
|
|
||
| names := make([]string, len(filtered.Tools)) | ||
| for i, tool := range filtered.Tools { | ||
| names[i] = tool.Name | ||
| } | ||
| assert.Contains(t, names, "weather", "authorized tool must be retained") | ||
| assert.NotContains(t, names, "admin_tool", | ||
| "unauthorized tool must be filtered; presence indicates the cedar bypass from #5257 is back") | ||
|
|
||
| // And the raw unfiltered payload (the bug used to dump it) | ||
| // must not appear in the wire output. | ||
| assert.NotContains(t, out, `"admin_tool"`, | ||
| "unfiltered tools/list payload leaked into SSE output") | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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 plainif / else if / elsechain here. There's exactly one condition (err != nil) plus a type assertion — theswitchadds no dispatch value, and the happy path (decode ok, it's a Response, filter it) is buried in adefaultwith a nestedif, 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.There was a problem hiding this comment.
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.