Repository navigation
fix(api): bound decode and batch-observation request bodies before JSON parsing (#334) - #359
Conversation
…re JSON parsing POST /api/decode and POST /api/packets/observations handed r.Body straight to a json.Decoder with no byte cap. The batch endpoint's 200-hash limit is a cardinality check that runs after parsing, so neither endpoint bounded how much a single request could make the server allocate. Both now go through decodeLimitedJSONBody, which wraps the body in http.MaxBytesReader and reads it into a bounded buffer BEFORE unmarshalling: 4096 bytes for /api/decode, 65536 for /api/packets/observations (the limits upstream uses). Over the cap is 413 with the shared writeError body naming the limit; malformed JSON within the cap stays 400. Reading the bounded body first is what makes this a byte cap: the verdict depends only on bytes received, so it holds for a body whose bulk sits in an unknown field, and for a chunked request that declares no Content-Length or understates it. Enforced in the application, not in a proxy. Headroom was checked against real clients, and is asserted by tests: the largest frame DecodePacket can accept is a ~520-byte body (~7x), and a full 200-hash batch request is ~3.8 KiB (~17x), so the 200-hash limit still fires first and keeps its 400. Both caps are documented in the served OpenAPI spec (a typed 413 response referencing a new ApiError schema) and in docs/api-spec.md, which also gains the previously undocumented POST /api/packets/observations section. Limits are hardcoded for now; AGENTS.md rule 8 follow-up noted in the source. Relates to #334
The boundary tests are written against the constants, so they stay green if a cap is widened — only docs/api-spec.md pinned the actual numbers. Assert an upper bound on headroom too (<=32x the largest decodable frame, <=64x a full 200-hash batch), so a cap loosened to the point of bounding nothing fails in the Go suite as well, not only in the docs check. Relates to #334
Rapport — CS-Minimax PR#359 #334 — head e030447Status: Complete — every acceptance point of #334 implemented, each with a red-before/green-after test and at least one mutant; CI green on all jobs, first run, no reruns needed. Evidence tags: [T] automated test, [A] automated check against a built binary / live server, [K] known accepted gap. Acceptance criteria
Red baselineCaptured on this branch's The two documentation tests were red on the same baseline (no 413 in the spec, no caps section in the doc). All green after. 7 mutants run in total, each reverted after confirming red; the working tree was verified clean afterwards. CI (per job)
No reruns were needed — green on the first run. The known-unstable test tracked in #271 did not fail. Run locally in this worktree before pushing: Invariants
Rester (leftovers / out of scope)
|
Round 2 of PR #359 (#334). F1 (blocking): nothing in the suite distinguished "cap enforced while reading" from "unbounded read, size checked afterwards" — both answer the same 413, so no status assertion can tell them apart, and the second one is the memory regression #334 exists to prevent. Two new tests assert on the bytes the handler actually pulls off the body: an endless body (one '{' then spaces forever, with an 8 MiB hard stop) and a finite body 16x over the cap must both be cut off within limit+4096 bytes. F2: docs/api-spec.md claimed every body-taking endpoint answers 413. It does not. The caps table now names the real route (POST /api/paths/inspect, not /api/path-inspect), lists PUT /api/config/geo-filter with its 1 MiB cap, and gives the actual over-cap status and body of each row — 413 only for the two endpoints #334 touches, 400 for the three capped earlier. The one body-taking route with no cap at all (POST /api/admin/prune-geo-filter?confirm=true) is named as a known gap. info.description is scoped the same way. A new test pins every row against the constant the handler enforces, and fails if the non-existent /api/path-inspect reappears. F3: json.Unmarshal rejects trailing data where the old json.Decoder.Decode ignored it. That is intended (it matches decodeSingleJSONObject) and is now a decision: TestBodyLimitsRejectTrailingData covers junk and a second JSON object after the body on both endpoints, and trailing whitespace still passes. F4: hoisted the "responses" literal into a local variable, so emitting routeMeta.Errors no longer needs a map[string]interface{} type assertion. The non-test occurrence count in cmd/ + internal/ is back to master's 745. F5: the OpenAPI summary for POST /api/packets/observations said "Batch submit observations" next to a description saying it returns them; it is now "Get observations for several packets", pinned by a test. Relates to #334.
Measured while checking review finding F2: the limits that predate #334 wrap the body and hand it to a streaming json.Decoder, so the limit only bites when the JSON value itself runs past it. A 6019-byte POST /api/paths/inspect body whose object ends at byte 19 answers 200 despite the 4096-byte limit, while the same shape is 413 on POST /api/decode. Calling both of those "a byte cap" in docs/api-spec.md would still mislead a client, so the caps table now carries an "Enforced on" column, the two enforcement models are spelled out with that measurement as the example, and the weaker enforcement is listed as a known gap alongside the status-code mismatch. info.description says the same in one sentence. The table rows stay pinned to the constants the handlers enforce. Relates to #334.
…lead The caps section opened with "every endpoint that takes a request body bounds it", which the gaps list three paragraphs down immediately contradicts: POST /api/admin/prune-geo-filter?confirm=true has no bound. Point at the exception from the lead sentence instead of only from the list. Relates to #334.
Rapport — CS-Macmini PR#359 runde-2 — head 1e3eccfReview feedback addressed (commit Reviewed against "Review — CS-pve-agent1 PR#359" (REQUEST CHANGES, head Evidence tags: [T] automated test, [A] automated/manual check against a built binary or live server, [K] known accepted gap. Findings1. F1 (blocking) — no test distinguished "cap before parsing" from "size check after an unbounded read". Agreed, and the fix is exactly the property the reviewer named: assert on the bytes the handler pulls off the body, not on the status it answers. Two new tests in
The 4096-byte slack is justified in the test: Mutant A (the reviewer's) red → green [T]: Four subtests red on the mutant that survived the entire previous suite. Note the second assertion line: with no bound, the hard stop also turns the answer into a 400, so the mutant is wrong twice over. 2. F2 (should fix) —
The extra correction (commit So the table gained an "Enforced on" column (commit I did not align the three 3. F3 (nit) — trailing data. Agreed it should be a decision. 4. F4 (nit) — 5. F5 (nit) — numbers and summary. Agreed; 3812 B is right and my 4013 B was wrong. Corrected throughout the PR description: the headroom table ( MutantsEach applied to a clean tree, run, then reverted and the tree verified clean with
Mutant F also confirms the hoist did not quietly drop the 413 metadata it refactors. TestsLocal, in this worktree, on the merged tree:
E2E. A local Go server on port 13801 against
Live checks against the built binary [A], all on the same server: CI (per job)Run 37743709836, attempt 1, head
Neither known-unstable test (#271, #301) failed, so no job needed a second run. The intermediate run on Rester
|
Relates to #334
Summary
POST /api/decodeandPOST /api/packets/observationshandedr.Bodystraight to ajson.Decoderwith no byte cap. The batch endpoint's 200-hash limit is a cardinality check that runs after parsing, so neither endpoint bounded how much a single request could make the server allocate.Both handlers now go through one new helper,
decodeLimitedJSONBody(cmd/server/request_body_limits.go):Reading the bounded body first, rather than letting a
json.Decoderstream it, is what makes this a byte cap: the verdict depends only on how many bytes arrived, never on where in the JSON the decoder happened to stop. A streaming decoder would let an oversized body through whenever the value it wants happens to complete inside the firstlimitbytes (trailing padding, ignored fields). Exactlylimitbytes is accepted;limit+1is 413.Over the cap answers 413 via the existing
writeErrorshape (#266) —{"error":"request body too large (max N bytes)"},application/json— so there is one error contract across endpoints. Malformed JSON within the cap keeps its existing 400. No newmap[string]interface{};cmd/serverstays read-only (no SQL touched).Why these limits (upstream's 4 KiB / 64 KiB, checked against our clients)
Upstream's numbers hold for us, and both are now asserted by tests rather than asserted in prose:
POST /api/decodePOST /api/packets/observationspublic/packets.js, which posts{"hex": <stripped hex>}. The largest frameDecodePacketcan accept at all is 1 header + 4 transport codes + 1 path-length byte +MAX_PATH_SIZE(64) path bytes +MAX_PACKET_PAYLOAD(184) payload bytes = 254 bytes → 508 hex chars → a ~520-byte body. Anything bigger is a frame the decoder rejects anyway. 4 KiB also matches the limit already onPOST /api/paths/inspect(inspectBodyLimit).{"hashes": [...]}of 16-hex content hashes. A full 200-hash request measures 3812 bytes on the wire ({"hashes":[+ 200x18 + 199 commas +]}; a 201-hash request is 3831 bytes). That is the reason 64 KiB is the right number and 4 KiB would not be: a 4 KiB cap would leave only 284 bytes of slack on a request the handler is supposed to accept, so the byte cap would start masking the 200-hash limit. At 64 KiB every request inside the 200-hash limit passes and a 201-hash request still gets the established400 too many hashes, not a 413.TestDecodeBodyLimitFitsLargestDecodableFrameandTestBatchObservationsHashLimitPreservedassert the headroom on both sides (≥4x so no legitimate client is cut off, ≤32x / ≤64x so a cap loosened to the point of bounding nothing fails CI).Deliberate behaviour change: trailing data is now rejected
decodeLimitedJSONBodyfinishes withjson.Unmarshal, which rejects anything but whitespace after the JSON value. Thejson.Decoder.Decodethese two handlers used before silently ignored it, so three bodies that were 200 on master are 400 on this branch:This is intentional, not a side effect: it matches
decodeSingleJSONObjectonPOST /api/channel-proposals, and our only clients build their bodies withJSON.stringify, which cannot produce trailing data. Trailing whitespace is valid JSON and is still accepted — the boundary tests pad with it.TestBodyLimitsRejectTrailingDatapins all four rejections plus the whitespace case.Preserved behaviour
TestBatchObservationsHashLimitPreserved, plus the pre-existingTestHandleBatchObservations).{"hex":"0200"}still 200 withdecoded.{header,path,payload}; empty hex still400 hex is required; undecodable hex still 400 (TestDecodeNormalRequestUnchanged).hashesstill{"results":{}}; malformed JSON still400 invalid JSON body.Not a proxy concern
The caps are enforced in the application.
TestBodyLimitsOverRealChunkedRequestsdrives a realhttptest.Serverover a real connection with a body of unknown length, andTestChunkedRequestHasNoContentLengthproves that request really arrives asTransfer-Encoding: chunkedwithContentLength == -1. A separate case feeds aContent-Lengththat understates the real body and still gets 413. Verified again with curl against a built binary (below).Perf
No hot path. One
io.ReadAllinto a buffer that is now bounded where it previously was not (thejson.Decoderallocated from the same unbounded stream), so peak per-request allocation on these two endpoints goes from unbounded to ≤4 KiB / ≤64 KiB. Strictly a reduction in worst case; no change in the normal case.Docs
cmd/server/openapi.go:routeMetagains a typedErrors []routeError, emitted as a real413response object (typedopenAPIResponse/openAPIMediaType, per the refactor: drive map[string]interface{} out of API response layer + internal data (694 occurrences in cmd/) — typed structs, codegen for frontend Kpa-clawbot/CoreScope#1383 no-new-untyped-map-literals convention) referencing a newApiErrorcomponent schema. Both route descriptions andinfo.descriptionstate the caps. Verified on the served/api/spec.docs/api-spec.md: new Request-body byte caps section under Error Responses (table of all four body-capped endpoints, and that the cap is on bytes received, not on the parsed value or onContent-Length),413added to the shared error list and toPOST /api/decode, and a newPOST /api/packets/observationssection — that endpoint was undocumented.TestAPISpecDocumentsBodyLimitspins the documented numbers to the constants, so the two cannot drift.Config follow-up (AGENTS.md rule 8)
Both limits are hardcoded constants for now, with a
TODO(#334, AGENTS.md rule 8)incmd/server/request_body_limits.go: they should become operator-configurable (config.json, and the customizer where it makes sense) in a later milestone. Not done here to keep this change to the security fix.Tests
New
cmd/server/request_body_limits_test.go(16 top-level tests, 31 cases):TestDecodeBodyLimitBoundaries,TestBatchObservationsBodyLimitBoundaries(limit−1 / limit / limit+1)TestDecodeBodyLimitOversizedUnknownField,TestBatchObservationsBodyLimitOversizedUnknownFieldTestBodyLimitsOverRealChunkedRequests(real connection),TestChunkedRequestHasNoContentLength,TestDecodeBodyLimitUnknownLength(incl. an understatedContent-Length),TestBatchObservationsBodyLimitUnknownLengthTestDecodeBodyLimitMalformedJSON,TestBatchObservationsBodyLimitMalformedJSON(under the cap → 400; over it → 413, bytes before syntax; empty body → 400)TestDecodeNormalRequestUnchanged,TestBatchObservationsNormalRequestUnchangedTestDecodeBodyLimitFitsLargestDecodableFrame,TestBatchObservationsHashLimitPreservedTestOpenAPIDocuments413OnBodyLimitedRoutes,TestAPISpecDocumentsBodyLimitsEvery 413 assertion also checks the body is the shared
{"error": ...}shape withContent-Type: application/json.Red before, green after
Captured on this branch's
origin/masterstarting point with only the two constants stubbed in (so the tests compile and fail on behaviour, not on a missing symbol) — 12 subtests red:TestOpenAPIDocuments413OnBodyLimitedRoutesandTestAPISpecDocumentsBodyLimitswere red on the same baseline (no 413 in the spec, no caps section in the doc). All green after.Mutants (7, each reverted after confirming red)
http.MaxBytesReaderwrapper dropped from the helperhttp.StatusBadRequestMaxBytesReader(..., limit+1)json.NewDecodermaxDecodeBodyBytesraised to 1 MiBTestDecodeBodyLimitFitsLargestDecodableFrame(>32x headroom),TestAPISpecDocumentsBodyLimitsmaxBatchObservationsBodyByteslowered to 1 KiBTestBatchObservationsHashLimitPreserved(both cases), the pre-existingTestHandleBatchObservations/too_many_hashes_returns_400,TestAPISpecDocumentsBodyLimitsTestOpenAPIDocuments413OnBodyLimitedRoutesMutant 6 is the one that matters for "preserve the 200-hash limit": a too-tight byte cap masks the cardinality limit, and the suite says so.
Live verification against a built binary
cmd/serverbuilt and started on port 13701 against a copy oftest-fixtures/e2e-fixture.dbfreshened, migrated and seeded exactly as the deploy workflow does.The chunked cases were confirmed to send no
Content-Lengthat all (curl -vrequest headers:Transfer-Encoding: chunked, no length header).Served spec on the same binary:
CI (per job, run locally in this worktree)
go build ./...,go vet ./...incmd/serverandcmd/ingestor— clean.gofmt -lon every touched/new file (request_body_limits.go,request_body_limits_test.go,routes.go,openapi.go) — no output. (Many pre-existing files incmd/serverare not gofmt-clean; none of them are touched here.)cd cmd/server && go test ./...— ok, 54.7s, no regressions.cd cmd/ingestor && go test ./...— ok, 112.6s. (Nointernal/*module touched.)sh test-all.sh— 233/233 files passed.node test-frontend-helpers.js— 709 passed, 0 failed.scripts/check-xss-sinks.sh --diff origin/master— clean (nopublic/**changes to scan).test-e2e-playwright.js132/135 passed, 3 skipped (the 3 skips are pre-existing and baked into the test),test-issue-1487-byop-modal-layout-e2e.js4/4,test-map-modal-fluid-e2e.js10/10 (includes the BYOP decode modal),test-issue-1648-m2-icons-e2e.js21/21,test-issue-147-packets-url-obs-e2e.js12/12,test-packet-detail-sender-hash-size-obs-e2e.js3/3,test-packets.js161/161,test-issue-1415-packets-layout.js26/26.deploy.yml9,release-fast-path.yml1 — neither file touched.GitHub Actions on this PR is checked after push; see the report comment.
Rester (leftovers / out of scope)
public/packets.jscan build a batch request larger than the handler accepts. The obs-sort path doespackets.filter(...).map(p => p.hash)with no cap, andPACKET_LIMITis 50000 on desktop, so a page with more than 200 multi-observation groups posts more than 200 hashes. That request already fails today with400 too many hashes(the response is swallowed byif (resp.ok)), so this PR does not introduce the problem — but above ~3400 hashes the answer changes from that 400 to a 413. Chunking the client into 200-hash batches is the real fix; it is new client logic with its own tests and belongs in its own issue, not in a server-side body-limit change.400.POST /api/paths/inspect(4096 B),POST /api/channel-proposals(1024 B) andPUT /api/config/geo-filter(1 MiB) wrap the body inhttp.MaxBytesReaderand hand it to a streaming decoder, so the limit only bites when the JSON value runs past it — a 6019-byte/api/paths/inspectbody whose object ends at byte 19 answers200[A], where the same shape is413on/api/decode.POST /api/admin/prune-geo-filter?confirm=true(API key) has no limit at all.docs/api-spec.mdnow states each endpoint's limit, how it is enforced, and its over-limit status, and lists all three as known gaps. Tightening them is API-visible, touches handlers this issue does not, and is deliberately not bundled here.