fix: enforce a per-string length cap on tool arguments (#562) - #570
Open
Yatsuiii wants to merge 2 commits into
Open
fix: enforce a per-string length cap on tool arguments (#562)#570Yatsuiii wants to merge 2 commits into
Yatsuiii wants to merge 2 commits into
Conversation
) docs/spec/proxy-security.md's Fuzzing Definition of Done specs MAX_STRING_LENGTH at 1MB per string field. It was not implemented anywhere in src/ or scripts/. A single oversized string sits inside an otherwise shallow, low-key-count payload and passes the depth and key-count caps from agentrust-io#556/agentrust-io#561 unbounded, up to the whole-body byte cap. Extends the existing _arg_shape_violation walk in both files with a UTF-8 byte length check, covering string values and object keys. Keys are checked because the key-count cap bounds how many there are, not how large each one is, so one huge key would otherwise pass everything. The cap is not the spec's literal 1MB. That equals MAX_REQUEST_BYTES in both files today, so a 1MB string plus any surrounding JSON already exceeds the whole-body cap and the check could never fire before DOS-001's size rejection already had. It would have been dead code. Set to half the whole-body cap instead, derived from a named constant rather than a restated literal so raising the default carries this along with it. Verified reachable: an over-cap string is 500,101 bytes on the wire against a 1,000,000 byte body cap. The spec's 10MB MAX_REQUEST_BYTES versus the 1MB implemented in both files is left alone. Picking a number there is a maintainer call, not one to make inside this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDXJ4ghkW6v56W8St2w5kg
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The list branch of _arg_shape_violation returns None explicitly once the loop finds nothing, so that every branch terminates on its own rather than depending on an earlier branch's return for the string check below it to be reachable. That return had no test. The only list coverage was the rejection path, which would still pass if the walk wrongly rejected every list it saw. Adds the accepting case to both files so the two stay in step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDXJ4ghkW6v56W8St2w5kg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #562.
docs/spec/proxy-security.md's Fuzzing Definition of Done specsMAX_STRING_LENGTH = 1 * 1024 * 1024 # 1MB per string field. It is not implemented anywhere insrc/orscripts/. The depth and key-count caps that landed in #556 and #561 bound how deep and how wide a payload is, not how large any one piece of it is, so a single oversized string sitting inside an otherwise shallow, low-key-countargumentsobject passes both of them unbounded, up to whatever the whole-body byte cap happens to be.This extends the existing
_arg_shape_violationwalk in both files with a UTF-8 byte length check.Why the cap is not the spec's literal 1MB
This is the part I would look at first if I were reviewing it.
The spec's
MAX_STRING_LENGTHis 1MB.MAX_REQUEST_BYTESis also 1MB in both files today. A 1MB string plus the JSON structure around it already exceeds the whole-body cap, so a check at the literal spec value could never fire before DOS-001's size rejection already had. Implemented at the spec's number, this would have been dead code that reads like a control.So the cap is half the whole-body limit instead, and it is derived from a named constant rather than a restated literal:
Hoisting the constructor default into
_DEFAULT_MAX_REQUEST_BYTESis what makes that derivation possible without stating1_000_000twice. Raising the default now carries the string cap along with it instead of silently leaving it behind at a stale absolute number.Verified reachable rather than assumed: an over-cap string is 500,101 bytes on the wire against a 1,000,000 byte body cap, and an over-cap key is 500,099. Both land inside the window where the string check is the thing that rejects them.
This is scoped against the default
max_request_bytes. A deployment that configures something smaller just has the whole-body cap bind first, which is a safe direction to fail in rather than a gap.Object keys, not only values
_MAX_ARG_KEYSbounds how many keys an object has, not how large each one is. A single huge key would otherwise pass every check in this function, so keys are measured against the same cap._object_shape_violationwas extracted to hold that without pushing_arg_shape_violationpast the complexity limit.Byte length, not character count
Measured as UTF-8 bytes. A codepoint count understates the real memory and processing cost of multi-byte text, and it is the byte length that the whole-body cap is already denominated in, so the two caps now speak the same unit. There is a test for a string that is under the cap by characters and over it by bytes.
What I am deliberately not doing here
MAX_REQUEST_BYTES, 10MB in the spec versus 1MB in both implementations. Flagged on #562 rather than resolved. It is not obvious whether 1MB was a deliberate tightening or spec drift, and picking a number is a maintainer call, not one to make inside a change scoped to a different constant. This PR works around the mismatch rather than resolving it.MAX_PARSE_TIME_MS = 100. Also unenforced anywhere, also left open on #562. A real wall-clock bound onjson.loadsneeds either a signal-based timeout, which does not work on the Windows CI legs this repo runs, or an executor or subprocess. That is a design decision rather than a missing check.params.nameis not shape-checked. It is a string field, so the spec'sMAX_STRING_LENGTHarguably covers it, but it sits outside theargumentswalk this PR extends and is bounded by the whole-body cap today. Naming it rather than widening the scope of a change that already touches two files.server.pystill does not requireargumentsto be an object, while the mock does. That gap was raised on #561 and left alone deliberately. One behaviour note for the record: a bare oversized string passed asargumentsnow gets rejected by the string cap on the gateway side, where before it fell through the shape walk untouched. Stricter, in the safe direction, but it is a change on a path that was previously discussed.On the duplication
Worth stating plainly, since it was noted on #561 that the caps are kept in sync by comment reference rather than by shared code. This change makes that worse, not better: it is now three constants and three functions duplicated across
scripts/mock_upstream.pyandsrc/cmcp_runtime/mcp/server.py.The reason is that
scripts/mock_upstream.pyimports stdlib only. Giving it a shared module to import fromcmcp_runtimewould cost it the standalone property that lets it run as demo scaffolding fromdocker-compose.ymlanddocs/quickstart.mdwithout the package installed. I did not think that trade was mine to make unilaterally, so I kept the duplication and the comment-reference sync. If the preference is a shared module and the mock taking a dependency on the package, that is a small follow-up and I am happy to do it.The two copies are byte-identical, which is at least checkable.
Verification
tests/unit/test_mock_upstream_gate.pyandtests/unit/test_mcp_server_auth.py: 90 passed, 9 of them new. Both suites drive the real handler over a socket or the real ASGI app rather than reimplementing the rules.tests/unit: 1338 passed. The 6test_startup.pyfailures I see locally areFileNotFoundError: 'tpm2_pcrread', reproduce identically on a cleanorigin/mainwith this diff stashed, and are a missing tpm2-tools binary on my machine rather than anything from this change.ruff checkon all four touched files is clean apart from one pre-existingT201 printatscripts/mock_upstream.py:241, confirmed pre-existing on main the same way. Left alone as unrelated.Based on
origin/mainat a2893da, after #556 and #561 landed, so the diff here is only this change.