Repository navigation
Conversation
…tion buffer The audit middleware parsed a 512-byte prefix of the response with json.Unmarshal. A cut-off JSON object does not unmarshal, so any error body over 512 bytes was recorded as outcome=success with no error code. When the prefix fills the buffer and the normal parse finds no error, walk the top-level object token by token and stop at the "error" key. Only the bytes before the cut matter, nested "error" keys are ignored, and a truncated "result" is still a success. The error code is kept when it was read before the cut; the message is kept only when it was read completely. Fixes stacklok#6720 Signed-off-by: breken-ai <312387581+breken-ai@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6763 +/- ##
==========================================
+ Coverage 79.64% 79.66% +0.01%
==========================================
Files 816 816
Lines 83168 83208 +40
==========================================
+ Hits 66242 66285 +43
+ Misses 16921 16918 -3
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jhrozek
left a comment
There was a problem hiding this comment.
Thanks for picking this up. The token walk approach looks right to me and the tests are thorough. There are a few things to sort out before this goes in, details inline. The main ones are the truncated code value and the IncludeResponseData path, which still logs large errors as success.
One more I couldn't leave inline because the file isn't in the diff: docs/middleware.md (around line 531) lists outcome as success/failure/denied/error and doesn't mention application_error. This PR makes that outcome a lot more common, so please add it there. Something like "a 2xx response whose JSON-RPC body has a top-level error, controlled by detectApplicationErrors", plus a note about the jsonrpc_error_code / jsonrpc_error_message metadata.
| var code int | ||
| if err := dec.Decode(&code); err != nil { | ||
| return res | ||
| } | ||
| res.ErrorCode = code |
There was a problem hiding this comment.
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 ..."error":{"code":-320 (really -32603) gives HasError=true, ErrorCode=-320, and that gets logged as if it were the real code. It also contradicts the doc comment above, which says code is only filled in when it was read completely.
Could you keep the code only if something follows it, e.g. dec.InputOffset() < int64(len(body)), and add a table case with a cut in the middle of the number that expects ErrorCode 0? You can only hit this when the id or other fields push code up to the edge, but when it happens the audit record is quietly wrong.
| @@ -464,7 +464,13 @@ func (*Auditor) detectApplicationError(rw *responseWriter) *mcp.ParsedMCPRespons | |||
| prefix = rw.errorDetectionBody.Bytes() | |||
| } | |||
| if len(prefix) > 0 && prefix[0] == '{' { | |||
There was a problem hiding this comment.
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) >= errorDetectionBufferSize check 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.
| @@ -464,7 +464,13 @@ func (*Auditor) detectApplicationError(rw *responseWriter) *mcp.ParsedMCPRespons | |||
| prefix = rw.errorDetectionBody.Bytes() | |||
| } | |||
| if len(prefix) > 0 && prefix[0] == '{' { | |||
There was a problem hiding this comment.
Related, though it predates this PR: the prefix[0] == '{' check rejects bodies with leading whitespace, which is still valid JSON. Something like \n{"jsonrpc":"2.0",...,"error":{...}} is never inspected, whatever its size. The new walker would handle it fine since the decoder skips whitespace; it's only this guard that rejects it. Trimming with bytes.TrimLeft(prefix, " \t\r\n") before the check would fix it. A follow-up is fine if you'd rather keep this PR narrow.
| 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 { |
There was a problem hiding this comment.
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 error key wins, a non-integer code gives HasError with code 0) look fine to me, arguably closer to the spec. If you want to keep both parsers, a comment explaining why there are two would help.
Summary
Audit error detection parsed a 512-byte prefix of the response with
json.Unmarshal. A cut-off JSON object does not unmarshal, so any JSON-RPC error body over 512 bytes was recorded asoutcome: successwith no error code or message.detectApplicationErrornow falls back to a token walk of the top-level object that stops at theerrorkey. It only needs the bytes before the cut.mcp.ParseTruncatedMCPResponsedoes the walk. The code is kept if it was read before the cut; the message is kept only if it was read completely. Nestederrorkeys are ignored, a truncatedresultis still a success, and"error": nullis not an error.Fixes #6720
Type of change
Test plan
I ran these with
godirectly.taskis not installed in my environment, so thetasktargets and CI are not run yet.task test)task test-e2e)task lint-fix)go test -race -count=1 ./pkg/mcp/ ./pkg/audit/,go vetandgofmtare clean. New tests: an 11-case table for the walk (complete error, cut inside the message, cut after the code, cut inside the key name, cut right after"error":{,databeforemessage, truncated result, nested error key, null error, non-object, empty), and a middleware test with bodies of 511, 512, 513 and 2048 bytes that expectsapplication_errorand the code. The middleware test fails without theauditor.gochange.API Compatibility
v1beta1API. No field is added to the event; only the existing outcome and metadata are set.Changes
pkg/mcp/response.goParseTruncatedMCPResponsepkg/audit/auditor.gopkg/mcp/response_test.go,pkg/audit/auditor_test.goDoes this introduce a user-facing change?
Yes. Large JSON-RPC error responses are now audited as
application_errorwith the error code, instead ofsuccess.Open question
If the cut lands before the
errorkey (for example a very largeid), the record still sayssuccess. Do you want an explicit marker for "body was cut off, detection incomplete" as well, or is that out of scope here?(Found, fixed and tested by an AI coding agent working for the breken-ai account.)