Repository navigation
fix(audit): detect JSON-RPC errors in responses larger than the detection buffer #6763
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 all commits
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 |
|---|---|---|
|
|
@@ -464,7 +464,13 @@ func (*Auditor) detectApplicationError(rw *responseWriter) *mcp.ParsedMCPRespons | |
| prefix = rw.errorDetectionBody.Bytes() | ||
| } | ||
| if len(prefix) > 0 && prefix[0] == '{' { | ||
|
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. Related, though it predates this PR: the |
||
| return mcp.ParseMCPResponse(prefix) | ||
| parsed := mcp.ParseMCPResponse(prefix) | ||
| // A full buffer may be a cut-off response, which does not unmarshal. | ||
| // Fall back to a token walk that only needs the bytes before the cut. | ||
| if !parsed.HasError && len(prefix) >= errorDetectionBufferSize { | ||
|
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. ParseMCPResponse has only this one caller, and the token walk gives the same answer on complete bodies. So I think detectApplicationError could just call the walker unconditionally (or make the walker the implementation of ParseMCPResponse) and drop the size check and the Unmarshal envelope. That also removes the case where a truncated prefix shorter than 512 bytes never reaches the fallback. The behavior differences (case-sensitive key match, the first duplicate |
||
| return mcp.ParseTruncatedMCPResponse(prefix) | ||
| } | ||
| return parsed | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| package mcp | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/json" | ||
| ) | ||
|
|
||
|
|
@@ -60,3 +61,72 @@ | |
| ErrorMessage: envelope.Error.Message, | ||
| } | ||
| } | ||
|
|
||
| // ParseTruncatedMCPResponse detects a top-level JSON-RPC "error" object in a | ||
| // response body that may have been cut off, such as a fixed-size prefix of a | ||
| // larger response. ParseMCPResponse cannot do this because a truncated JSON | ||
| // document does not unmarshal. | ||
| // | ||
| // It walks the top-level object token by token and stops at the "error" key, | ||
| // so only the bytes before the cut matter. "code" and "message" are filled in | ||
| // when they were read completely; otherwise they keep their zero values and | ||
| // HasError is still true. Values of other top-level keys are skipped, and | ||
| // nested "error" keys are ignored. Like ParseMCPResponse it is lenient and | ||
| // returns HasError=false when the body is not a JSON object or has no | ||
| // top-level "error" object before the cut. | ||
| func ParseTruncatedMCPResponse(body []byte) *ParsedMCPResponse { | ||
| dec := json.NewDecoder(bytes.NewReader(body)) | ||
| if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { | ||
| return &ParsedMCPResponse{} | ||
| } | ||
| for dec.More() { | ||
| keyTok, err := dec.Token() | ||
| if err != nil { | ||
| return &ParsedMCPResponse{} | ||
| } | ||
| key, ok := keyTok.(string) | ||
| if !ok { | ||
| return &ParsedMCPResponse{} | ||
| } | ||
| if key != "error" { | ||
| var skip json.RawMessage | ||
| if err := dec.Decode(&skip); err != nil { | ||
| return &ParsedMCPResponse{} | ||
| } | ||
| continue | ||
| } | ||
| if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { | ||
| // "error": null or a non-object value is not a JSON-RPC error. | ||
| return &ParsedMCPResponse{} | ||
| } | ||
| res := &ParsedMCPResponse{HasError: true} | ||
| for dec.More() { | ||
| fieldTok, err := dec.Token() | ||
| if err != nil { | ||
| return res | ||
| } | ||
| field, _ := fieldTok.(string) | ||
| switch field { | ||
| case "code": | ||
| var code int | ||
| if err := dec.Decode(&code); err != nil { | ||
| return res | ||
| } | ||
| res.ErrorCode = code | ||
|
Comment on lines
+111
to
+115
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. json.Decoder accepts a number that ends at EOF, so if the 512-byte cut lands inside the code digits this returns a truncated value. A prefix ending in Could you keep the code only if something follows it, e.g. |
||
| case "message": | ||
| var msg string | ||
| if err := dec.Decode(&msg); err != nil { | ||
| return res | ||
| } | ||
| res.ErrorMessage = msg | ||
| default: | ||
| var skip json.RawMessage | ||
| if err := dec.Decode(&skip); err != nil { | ||
| return res | ||
| } | ||
| } | ||
| } | ||
| return res | ||
| } | ||
| return &ParsedMCPResponse{} | ||
| } | ||
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.
Large errors are still missed when IncludeResponseData is on. In that case errorDetectionBody isn't allocated (L269) and detection reuses rw.body, but rw.body only takes a write if the whole thing fits under MaxDataSize (L141, 1024 by default). A single 1500-byte error write leaves rw.body empty, so the prefix is empty, the fallback never runs, and the event stays success. A response written in several chunks can also leave a truncated rw.body shorter than 512 bytes, which then skips the
len(prefix) >= errorDetectionBufferSizecheck below.I think the simplest fix is to always allocate errorDetectionBody regardless of IncludeResponseData (it's 512 bytes per request) and stop reusing rw.body for detection. A test with IncludeResponseData: true and a body larger than MaxDataSize would cover it. Since #6720 is about any error body over the buffer size, I'd like to see this fixed in this PR.