Skip to content

fix(api): bound decode and batch-observation request bodies before JSON parsing (#334) - #359

Merged
dborup merged 6 commits into
masterfrom
codex/issue-334-request-body-limits
Oct 8, 2026
Merged

dborup merged 6 commits into
masterfrom
codex/issue-334-request-body-limits

Conversation

@dborup

@dborup dborup commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Relates to #334

Summary

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 handlers now go through one new helper, decodeLimitedJSONBody (cmd/server/request_body_limits.go):

r.Body = http.MaxBytesReader(w, r.Body, limit)
raw, err := io.ReadAll(r.Body)      // bounded buffer, before any parsing
// *http.MaxBytesError -> 413 ; any other read error / bad JSON -> 400
json.Unmarshal(raw, v)

Reading the bounded body first, rather than letting a json.Decoder stream 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 first limit bytes (trailing padding, ignored fields). Exactly limit bytes is accepted; limit+1 is 413.

Over the cap answers 413 via the existing writeError shape (#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 new map[string]interface{}; cmd/server stays 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:

Endpoint Cap Largest legitimate body Headroom
POST /api/decode 4096 B ~520 B ~7.9x
POST /api/packets/observations 65536 B 3812 B ~17.2x
  • decode. The only client is the BYOP modal in public/packets.js, which posts {"hex": <stripped hex>}. The largest frame DecodePacket can 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 on POST /api/paths/inspect (inspectBodyLimit).
  • batch observations. The only client is the packets table's obs-sort path, which posts {"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 established 400 too many hashes, not a 413.

TestDecodeBodyLimitFitsLargestDecodableFrame and TestBatchObservationsHashLimitPreserved assert 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

decodeLimitedJSONBody finishes with json.Unmarshal, which rejects anything but whitespace after the JSON value. The json.Decoder.Decode these two handlers used before silently ignored it, so three bodies that were 200 on master are 400 on this branch:

POST /api/decode                {"hex":"0200"} trailing      200 -> 400
POST /api/decode                {"hex":"0200"}{"hex":"zz"}   200 -> 400
POST /api/packets/observations  {"hashes":[]} junk           200 -> 400

This is intentional, not a side effect: it matches decodeSingleJSONObject on POST /api/channel-proposals, and our only clients build their bodies with JSON.stringify, which cannot produce trailing data. Trailing whitespace is valid JSON and is still accepted — the boundary tests pad with it. TestBodyLimitsRejectTrailingData pins all four rejections plus the whitespace case.

Preserved behaviour

  • 200-hash limit unchanged, and still what a client meets first (TestBatchObservationsHashLimitPreserved, plus the pre-existing TestHandleBatchObservations).
  • decode behaviour unchanged: {"hex":"0200"} still 200 with decoded.{header,path,payload}; empty hex still 400 hex is required; undecodable hex still 400 (TestDecodeNormalRequestUnchanged).
  • Empty hashes still {"results":{}}; malformed JSON still 400 invalid JSON body.

Not a proxy concern

The caps are enforced in the application. TestBodyLimitsOverRealChunkedRequests drives a real httptest.Server over a real connection with a body of unknown length, and TestChunkedRequestHasNoContentLength proves that request really arrives as Transfer-Encoding: chunked with ContentLength == -1. A separate case feeds a Content-Length that understates the real body and still gets 413. Verified again with curl against a built binary (below).

Perf

No hot path. One io.ReadAll into a buffer that is now bounded where it previously was not (the json.Decoder allocated 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: routeMeta gains a typed Errors []routeError, emitted as a real 413 response object (typed openAPIResponse/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 new ApiError component schema. Both route descriptions and info.description state 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 on Content-Length), 413 added to the shared error list and to POST /api/decode, and a new POST /api/packets/observations section — that endpoint was undocumented.
  • TestAPISpecDocumentsBodyLimits pins 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) in cmd/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):

Area Tests
Exact boundaries ±1 TestDecodeBodyLimitBoundaries, TestBatchObservationsBodyLimitBoundaries (limit−1 / limit / limit+1)
Oversized unknown fields TestDecodeBodyLimitOversizedUnknownField, TestBatchObservationsBodyLimitOversizedUnknownField
Chunked / unknown length TestBodyLimitsOverRealChunkedRequests (real connection), TestChunkedRequestHasNoContentLength, TestDecodeBodyLimitUnknownLength (incl. an understated Content-Length), TestBatchObservationsBodyLimitUnknownLength
Malformed JSON TestDecodeBodyLimitMalformedJSON, TestBatchObservationsBodyLimitMalformedJSON (under the cap → 400; over it → 413, bytes before syntax; empty body → 400)
Normal requests TestDecodeNormalRequestUnchanged, TestBatchObservationsNormalRequestUnchanged
Limits vs. real clients TestDecodeBodyLimitFitsLargestDecodableFrame, TestBatchObservationsHashLimitPreserved
Documented 413 TestOpenAPIDocuments413OnBodyLimitedRoutes, TestAPISpecDocumentsBodyLimits

Every 413 assertion also checks the body is the shared {"error": ...} shape with Content-Type: application/json.

Red before, green after

Captured on this branch's origin/master starting point with only the two constants stubbed in (so the tests compile and fail on behaviour, not on a missing symbol) — 12 subtests red:

--- FAIL: TestDecodeBodyLimitBoundaries/one_byte_over_the_limit_is_413
        status: want 413, got 200
--- FAIL: TestDecodeBodyLimitOversizedUnknownField
        status: want 413, got 200
--- FAIL: TestDecodeBodyLimitUnknownLength/oversized_with_no_Content-Length_is_413
--- FAIL: TestDecodeBodyLimitUnknownLength/a_lying_Content-Length_cannot_raise_the_cap
--- FAIL: TestDecodeBodyLimitMalformedJSON/malformed_over_the_cap_is_413,_not_400
        status: want 413, got 400
--- FAIL: TestBatchObservationsBodyLimitBoundaries/one_byte_over_the_limit_is_413
--- FAIL: TestBatchObservationsBodyLimitOversizedUnknownField
--- FAIL: TestBatchObservationsBodyLimitUnknownLength/oversized_with_no_Content-Length_is_413
--- FAIL: TestBatchObservationsBodyLimitMalformedJSON/malformed_over_the_cap_is_413,_not_400
--- FAIL: TestBodyLimitsOverRealChunkedRequests/decode_over_the_cap
--- FAIL: TestBodyLimitsOverRealChunkedRequests/batch_over_the_cap

TestOpenAPIDocuments413OnBodyLimitedRoutes and TestAPISpecDocumentsBodyLimits were 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)

# Mutant Killed by
1 http.MaxBytesReader wrapper dropped from the helper 12 subtests across both endpoints
2 413 downgraded to http.StatusBadRequest the same 12
3 Off-by-one: MaxBytesReader(..., limit+1) 7 subtests (every exact-boundary and unknown-length case)
4 Batch handler left on the old unbounded json.NewDecoder 5 batch subtests; decode cases stayed green (correctly scoped)
5 maxDecodeBodyBytes raised to 1 MiB TestDecodeBodyLimitFitsLargestDecodableFrame (>32x headroom), TestAPISpecDocumentsBodyLimits
6 maxBatchObservationsBodyBytes lowered to 1 KiB TestBatchObservationsHashLimitPreserved (both cases), the pre-existing TestHandleBatchObservations/too_many_hashes_returns_400, TestAPISpecDocumentsBodyLimits
7 413 removed from the decode route's OpenAPI metadata TestOpenAPIDocuments413OnBodyLimitedRoutes

Mutant 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/server built and started on port 13701 against a copy of test-fixtures/e2e-fixture.db freshened, migrated and seeded exactly as the deploy workflow does.

# decode
POST /api/decode {"hex":"0200"}                      -> 200  {"decoded":{"header":{...
POST /api/decode  4096-byte body                     -> 200
POST /api/decode  4097-byte body                     -> 413  {"error":"request body too large (max 4096 bytes)"}
POST /api/decode  4097 bytes, Transfer-Encoding: chunked (no Content-Length)
                                                     -> 413  {"error":"request body too large (max 4096 bytes)"}
POST /api/decode  {"hex":"0200","unknownField":"A*5000"}
                                                     -> 413
POST /api/decode  {"hex":                            -> 400  {"error":"invalid JSON body"}
POST /api/decode  malformed + over the cap           -> 413

# batch observations
POST /api/packets/observations  3 real fixture hashes -> 200  {"results":{"013c8e48966eb4f7":[{...
POST /api/packets/observations  200 hashes (3812 B)   -> 200
POST /api/packets/observations  201 hashes (4033 B)   -> 400  {"error":"too many hashes (max 200)"}
POST /api/packets/observations  65536-byte body       -> 200
POST /api/packets/observations  65537-byte body       -> 413  {"error":"request body too large (max 65536 bytes)"}
POST /api/packets/observations  65537 B, chunked      -> 413
POST /api/packets/observations  oversized unknown field -> 413
POST /api/packets/observations  'not json'            -> 400
POST /api/packets/observations  {"hashes":[]}         -> 200  {"results":{}}

The chunked cases were confirmed to send no Content-Length at all (curl -v request headers: Transfer-Encoding: chunked, no length header).

Served spec on the same binary:

/api/decode                -> responses ['200', '413']
/api/packets/observations  -> responses ['200', '413']
components/schemas/ApiError present; info.description names the 413

CI (per job, run locally in this worktree)

  • go build ./..., go vet ./... in cmd/server and cmd/ingestor — clean.
  • gofmt -l on every touched/new file (request_body_limits.go, request_body_limits_test.go, routes.go, openapi.go) — no output. (Many pre-existing files in cmd/server are 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. (No internal/* 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 (no public/** changes to scan).
  • E2E against the live build on port 13701: test-e2e-playwright.js 132/135 passed, 3 skipped (the 3 skips are pre-existing and baked into the test), test-issue-1487-byop-modal-layout-e2e.js 4/4, test-map-modal-fluid-e2e.js 10/10 (includes the BYOP decode modal), test-issue-1648-m2-icons-e2e.js 21/21, test-issue-147-packets-url-obs-e2e.js 12/12, test-packet-detail-sender-hash-size-obs-e2e.js 3/3, test-packets.js 161/161, test-issue-1415-packets-layout.js 26/26.
  • Fork guards unchanged: deploy.yml 9, release-fast-path.yml 1 — neither file touched.

GitHub Actions on this PR is checked after push; see the report comment.

Rester (leftovers / out of scope)

  • [K] public/packets.js can build a batch request larger than the handler accepts. The obs-sort path does packets.filter(...).map(p => p.hash) with no cap, and PACKET_LIMIT is 50000 on desktop, so a page with more than 200 multi-observation groups posts more than 200 hashes. That request already fails today with 400 too many hashes (the response is swallowed by if (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.
  • [K] The two caps are hardcoded, see the config follow-up above (AGENTS.md rule 8).
  • [K] The other body-taking endpoints are bounded on the JSON decoder, not on bytes, and answer 400. POST /api/paths/inspect (4096 B), POST /api/channel-proposals (1024 B) and PUT /api/config/geo-filter (1 MiB) wrap the body in http.MaxBytesReader and hand it to a streaming decoder, so the limit only bites when the JSON value runs past it — a 6019-byte /api/paths/inspect body whose object ends at byte 19 answers 200 [A], where the same shape is 413 on /api/decode. POST /api/admin/prune-geo-filter?confirm=true (API key) has no limit at all. docs/api-spec.md now 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.
  • Node-list cardinality and Reach concurrency limits are explicitly out of scope per the issue.

dborup added 2 commits October 7, 2026 21:29
…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
@dborup

dborup commented Oct 7, 2026

Copy link
Copy Markdown
Owner Author

Rapport — CS-Minimax PR#359 #334 — head e030447

Status: 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

# Criterion (#334) Implementation Test (red before → green after) Mutant
1 Body limits applied before parsing on both endpoints decodeLimitedJSONBody in new cmd/server/request_body_limits.go: http.MaxBytesReader → bounded io.ReadAll → json.Unmarshal. Wired into handleDecode and handleBatchObservations. A streaming decoder was deliberately not used: it lets an oversized body through whenever the value completes inside the first limit bytes. [T] TestDecodeBodyLimitBoundaries, TestBatchObservationsBodyLimitBoundaries M1 MaxBytesReader wrapper dropped → 12 subtests red. M4 batch handler left on the old unbounded decoder → 5 batch subtests red, decode cases correctly stayed green.
2 Exact boundaries, ±1 byte Exactly limit accepted, limit+1 rejected, independent of JSON shape [T] both boundary tests cover limit−1 / limit / limit+1. [A] curl: 4096 → 200, 4097 → 413; 65536 → 200, 65537 → 413 M3 MaxBytesReader(..., limit+1) → 7 subtests red (every exact-boundary and unknown-length case)
3 Oversized unknown fields Cap is on bytes received, so bulk in an unread field cannot buy room [T] TestDecodeBodyLimitOversizedUnknownField, TestBatchObservationsBodyLimitOversizedUnknownField. [A] curl with "unknownField":"A"*5000 → 413 M1, M2 (both kill these)
4 Chunked / unknown-length requests No Content-Length precheck at all — one path, so a chunked or understated-length request is cut at the same byte count [T] TestBodyLimitsOverRealChunkedRequests (real httptest.Server, real connection), TestChunkedRequestHasNoContentLength (proves the request arrives as Transfer-Encoding: chunked, ContentLength == -1), TestDecodeBodyLimitUnknownLength incl. an understated Content-Length, TestBatchObservationsBodyLimitUnknownLength. [A] curl -H 'Transfer-Encoding: chunked' → 413 on both endpoints; curl -v confirms no length header was sent M1, M2, M3
5 Malformed JSON Under the cap → existing 400; over the cap → 413 (bytes win over syntax); empty body → 400 [T] TestDecodeBodyLimitMalformedJSON, TestBatchObservationsBodyLimitMalformedJSON. [A] curl {"hex": → 400, truncated-and-oversized → 413 M2 413 downgraded to 400 → 12 subtests red
6 Normal requests unaffected Unchanged happy paths and unchanged existing 400s [T] TestDecodeNormalRequestUnchanged (valid frame → 200 with decoded.{header,path,payload}; empty hex → 400 hex is required; bad hex → 400), TestBatchObservationsNormalRequestUnchanged, plus the pre-existing TestHandleBatchObservations and the decode cases in routes_test.go. [A] curl against the fixture DB returned real observations for 3 fixture hashes M6 (below) also trips the pre-existing TestHandleBatchObservations
7 Documented 413 with a consistent JSON error (same writeError shape as #266) 413 via writeError → {"error":"request body too large (max N bytes)"}, application/json. openapi.go: typed routeMeta.Errors []routeError emitted as a real 413 response object referencing a new ApiError component schema; both route descriptions and info.description state the caps. docs/api-spec.md: new Request-body byte caps section, 413 in the shared error list, 413 on POST /api/decode, and a new POST /api/packets/observations section (it was undocumented). [T] TestOpenAPIDocuments413OnBodyLimitedRoutes (413 present on both routes, with an ApiError ref), TestAPISpecDocumentsBodyLimits (the doc's numbers are pinned to the constants). Every 413 assertion in the suite also checks the shared {"error": ...} shape + content type. [A] served /api/spec on the built binary: both routes → ['200','413'], ApiError present M7 413 removed from the decode route metadata → TestOpenAPIDocuments413OnBodyLimitedRoutes red
8 4 KiB / 64 KiB evaluated against our legitimate clients Upstream's numbers hold. decode: only client is the BYOP modal in public/packets.js; the largest frame DecodePacket can accept at all is 254 bytes (1 header + 4 transport codes + 1 path-length byte + MAX_PATH_SIZE 64 + MAX_PACKET_PAYLOAD 184) → a ~520-byte body → ~7.9x headroom. batch: only client is the packets obs-sort path; a full 200-hash request measures 4013 bytes → ~16.3x headroom. That measurement is also why 4 KiB would be wrong for the batch endpoint: it would leave 83 bytes of slack on a request the handler must accept. [T] TestDecodeBodyLimitFitsLargestDecodableFrame and TestBatchObservationsHashLimitPreserved assert headroom on both sides — ≥4x so no legitimate client is cut off, ≤32x / ≤64x so a cap loosened past usefulness fails. [A] measured on the live server: 200-hash body = 4013 B → 200 M5 decode cap raised to 1 MiB → TestDecodeBodyLimitFitsLargestDecodableFrame + TestAPISpecDocumentsBodyLimits red
9 200-hash limit and decode behaviour preserved maxHashes = 200 untouched and still what a client meets first; decode logic untouched below the body read [T] TestBatchObservationsHashLimitPreserved (200 hashes → 200 and fit inside the cap; 201 hashes → 400 too many hashes, not 413). [A] curl: 200 hashes (4013 B) → 200; 201 hashes (4033 B) → 400 too many hashes (max 200) M6 batch cap lowered to 1 KiB → both TestBatchObservationsHashLimitPreserved cases, the pre-existing TestHandleBatchObservations/too_many_hashes_returns_400, and TestAPISpecDocumentsBodyLimits red. This is the mutant that proves a too-tight cap would mask the cardinality limit.
10 Do not rely on proxy limits — verify the application directly Enforced in the handlers [A] cmd/server built and run on a local port against a copy of test-fixtures/e2e-fixture.db freshened / migrated / seeded exactly as the deploy workflow does; all 16 request shapes above exercised with curl straight at the Go server, no proxy in front. [T] TestBodyLimitsOverRealChunkedRequests does the same over a real connection in-suite. M1–M4

Red baseline

Captured on this branch's origin/master starting point with only the two constants stubbed in, so the tests compile and fail on behaviour rather than on a missing symbol — 12 subtests red, e.g.:

--- FAIL: TestDecodeBodyLimitBoundaries/one_byte_over_the_limit_is_413   status: want 413, got 200
--- FAIL: TestDecodeBodyLimitUnknownLength/a_lying_Content-Length_cannot_raise_the_cap
--- FAIL: TestDecodeBodyLimitMalformedJSON/malformed_over_the_cap_is_413,_not_400   status: want 413, got 400
--- FAIL: TestBatchObservationsBodyLimitOversizedUnknownField   status: want 413, got 200
--- FAIL: TestBodyLimitsOverRealChunkedRequests/decode_over_the_cap
--- FAIL: TestBodyLimitsOverRealChunkedRequests/batch_over_the_cap

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)

Job Result
✅ Go Build & Test pass, 22m46s — every step green, incl. go test for server + ingestor, the ingestor race-check, internal/anomaly with -race, CSS-var lint, test-all.sh, the preflight XSS gate (--diff), eslint, proto syntax
🎭 Playwright E2E Tests pass, 27m36s — full fail-fast E2E run plus the Kpa-clawbot#1616 slide-over flake-gate (--repeat-each=3)
🏗️ Build & Publish Docker Image pass, 57s
📦 Release Artifacts skipping (not a tag)
🚀 Deploy Staging skipping
📝 Publish Badges & Summary skipping

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: go build/go vet clean in cmd/server and cmd/ingestor; cd cmd/server && go test ./... ok (54.7s); cd cmd/ingestor && go test ./... ok (112.6s); 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; gofmt -l empty on all four touched files. E2E against the live local build: test-e2e-playwright.js 132/135 passed + 3 pre-existing skips, test-map-modal-fluid-e2e.js 10/10 (covers the BYOP decode modal), test-issue-1487-byop-modal-layout-e2e.js 4/4, test-issue-147-packets-url-obs-e2e.js 12/12, test-packet-detail-sender-hash-size-obs-e2e.js 3/3, test-issue-1648-m2-icons-e2e.js 21/21, test-packets.js 161/161, test-issue-1415-packets-layout.js 26/26.

Invariants

Rester (leftovers / out of scope)

  • [K] public/packets.js can build a batch request larger than the handler accepts. The obs-sort path does packets.filter(...).map(p => p.hash) with no cap, and the page limit is 50000 on desktop, so a page with more than 200 multi-observation groups posts more than 200 hashes. That request already fails today with 400 too many hashes and the response is swallowed by if (resp.ok), so this PR does not introduce the problem — but above roughly 3400 hashes the answer changes from that 400 to a 413. The real fix is chunking the client into 200-hash batches; that is new client logic with its own tests and belongs in its own issue.
  • [K] Both caps are hardcoded constants, with a TODO(#334, AGENTS.md rule 8) in the source: they should become operator-configurable (config.json, and the customizer where it makes sense) in a later milestone. Kept out of this change to keep it to the security fix.
  • [K] POST /api/path-inspect and POST /api/channel-proposals already cap their bodies but answer 400, not 413, when the cap is hit. They are now listed as capped in docs/api-spec.md; aligning their status code is an API-visible change and is deliberately not bundled here.
  • Node-list cardinality and Reach concurrency limits are explicitly out of scope per the issue.
  • The PR is left as a draft; nothing merged, no issue closed.

@dborup-agent

Copy link
Copy Markdown
Collaborator

Review — CS-pve-agent1 PR#359 — head e030447

Dom: REQUEST CHANGES

The fix itself is correct and I measured it working (a 50 MB body no longer moves RSS), but the property the issue is about, "limit applied before parsing", has no test that pins it: a mutant that reads the whole body and checks its size afterwards survives the entire suite. That fix is small, and there are a few doc inaccuracies to correct while you are in there.

Evidence tags: [T] automated test I ran, [A] manual check I ran against a built binary or browser, [K] known or accepted gap / not verified.

Reviewed: head e030447a and the merged tree git merge-tree --write-tree origin/master e030447a… = 03f72e19 against origin/master ff9a501c (master only gained #357 config changes since the merge base; no overlap with this diff). Head ref unchanged before and after the review (git ls-remote).

Findings

# Severity Where Finding
F1 Blocking cmd/server/request_body_limits_test.go No test distinguishes "cap before parsing" from "size check after an unbounded read". Mutant A (below) survives every test in the suite while a 50 MB body takes the server's RSS high-water mark from 49 MB to 165 MB — exactly the regression #334 exists to prevent. The client-visible 413 is identical, so status-code assertions cannot catch it.
F2 Should fix docs/api-spec.md "Request-body byte caps", openapi.go info.description The new section says "Every endpoint that takes a request body caps it … and answers 413". That is not true: (a) POST /api/path-inspect does not exist (404); the route is POST /api/paths/inspect; (b) over its 4096-byte cap it answers 400 {"error":"invalid JSON"} with Content-Type: text/plain (http.Error), not 413/ApiError; (c) POST /api/channel-proposals over its cap answers 400 invalid request body; (d) PUT /api/config/geo-filter has a 1 MiB cap and is not listed. The PR's own Rester section acknowledges (b)/(c), so the doc contradicts it. info.description ("Endpoints that take a request body … answer 413") has the same overclaim. Either scope the wording to these two endpoints or list the others with their actual status.
F3 Nit request_body_limits.go Unannounced behaviour change: json.Unmarshal rejects trailing data, while the old json.Decoder.Decode ignored it. {"hex":"0200"} trailing, {"hex":"0200"}{"hex":"zz"} and {"hashes":[]} junk were 200 on master, 400 on this PR [A]. It is stricter, it matches decodeSingleJSONObject, and our frontend (JSON.stringify) is unaffected, so I am fine keeping it. Please name it in the PR description and add a test case so it is a decision, not an accident.
F4 Nit openapi.go:993 op["responses"].(map[string]interface{}) adds a textual map[string]interface{} occurrence (openapi.go 281 → 282; non-test total in cmd/ + internal/ 745 → 746). It is a type assertion, not a new structure, but the Kpa-clawbot#1383 rule counts occurrences. Hoisting the existing "responses": map[string]interface{}{…} literal into a local responses variable and writing responses[e.Status] = … removes it at no cost.
F5 Nit PR body / OpenAPI The numbers drift. A 200-hash body from json.Marshal or the browser's JSON.stringify is 3812 B [A], which matches the doc's "~3.8 KB / ~17x", but the PR body says 4013 B / 16.3x and "83 bytes of slack" under a 4 KiB cap (it is 284 B). The OpenAPI summary for POST /api/packets/observations still says "Batch submit observations" next to a description you rewrote to say it returns observations.

Answers to the review points

1. Is the limit applied before parsing? Yes. [A]

  • decodeLimitedJSONBody wraps r.Body in http.MaxBytesReader first, then io.ReadAll (bounded to limit+1), then json.Unmarshal. Both handlers go through it.

  • RSS measured myself (/proc/<pid>/status VmHWM; 50 MB body padded into an unknown field):

    Server Endpoint Result Bytes the client managed to send VmHWM before → after
    origin/master /api/decode 200 52,428,821 48.9 MB → 157.0 MB
    origin/master /api/packets/observations 200 52,428,820 157.0 MB → 287.7 MB
    PR (merged) /api/decode (Content-Length) 413 393,216 52.7 MB → 52.9 MB
    PR (merged) /api/decode (chunked) 413 262,128 52.9 MB → 52.9 MB
    PR (merged) /api/packets/observations (Content-Length) 413 393,216 52.9 MB → 52.9 MB
    PR (merged) /api/packets/observations (chunked) 413 458,724 52.9 MB → 52.9 MB

    Net growth over all four PR requests was about 200 kB, and the server closed the connection after under 0.5 MB of the upload. But see F1: nothing in the suite would notice if this regressed.

2. 413 + JSON error, documented? Yes for both endpoints. [T][A] The response is 413, Content-Type: application/json, {"error":"request body too large (max N bytes)"} through the shared writeError, the same shape as #266. The served /api/spec has ['200','413'] on both routes, and the ApiError component is present [A]. docs/api-spec.md documents both routes and adds the missing POST /api/packets/observations section. The over-broad general claims are covered in F2.

3. Limits. [A] Both are justified against our actual clients.

  • decode, 4096 B. The only caller is the BYOP modal. The largest frame DecodePacket accepts is 254 B (maxPathSize 64 + maxPacketPayload 184, and the decoder rejects anything larger: decoder.go:595), so the body is about 520 B, giving roughly 7.9x headroom.
  • batch, 65536 B. The only caller is the obs-sort path. A full 200-hash body is 3812 B, roughly 17x headroom.
  • 200-hash limit preserved. 200 hashes give 200; 201 hashes (3831 B) give 400 too many hashes (max 200), not 413 [A][T]. maxHashes is untouched.
  • Browser. Obs-sort on the seeded multi-observation group sent POST /api/packets/observations {"hashes":["fae0c9e6d357a814"]} and got 200 with results. BYOP 0200 decoded normally (200). A 5000-hex paste (5010 B body) got 413, and the modal shows "request body too large (max 4096 bytes)" in its existing error style [A].

4. Tests. Boundaries ±1, oversized unknown fields, chunked and unknown-length requests (including an understated Content-Length), malformed JSON under and over the cap, the empty body, and the normal paths are all present and meaningful [T]. My curl run agreed: 4095/4096 give 200, 4097 gives 413, and the same with chunked encoding; 65536 gives 200, 65537 gives 413, also with chunked [A]. Gaps: F1 (no consumption bound) and F3 (no trailing-data case).

5. cmd/server read-only, no new map[string]interface{}. [A] The diff adds no SQL, writes and write helpers. On map[string]interface{}, see F4: there is one textual occurrence, a type assertion. No new map literal or untyped structure is added; the OpenAPI additions use typed openAPIResponse/openAPIMediaType.

Other checks. [A]

  • No public/** changes, so there are no hardcoded colours. scripts/check-xss-sinks.sh --diff origin/master is clean (nothing to scan).
  • Fork guards: == 'Kpa-clawbot/CoreScope' appears 9× in deploy.yml and 1× in release-fast-path.yml on the merged tree; neither workflow is touched.
  • No closing keywords in the PR body or commits ("Relates to security(api): bound decode and batch-observation request bodies before JSON parsing #334"); closingIssuesReferences is empty.
  • Both commits have author and committer set to the expected dborup identity (name and project email as required).
  • gofmt -l is clean on the four touched Go files.

Mutants (mine, each run on a copy of the merged tree, then reverted and checked with diff -r)

# Mutant Result
A Limit applied after the read and parse: MaxBytesReader removed, unbounded io.ReadAll + json.Unmarshal, then if len(raw) > limit → 413 SURVIVES. The whole targeted suite is green (-run 'BodyLimit|BatchObservations|Decode|Chunked|413|APISpecDocuments|HandleBatch', ok). A built binary answers the 50 MB body with the correct 413 while VmHWM goes 49.4 MB → 165.4 MB. → F1
B Streaming json.NewDecoder(MaxBytesReader(...)).Decode(v) (the path_inspect.go pattern) Killed, 7 subtests (all over-the-cap boundary, unknown-length and real-chunked cases). This confirms the PR's argument for reading into a bounded buffer first.
C Only a Content-Length precheck (if r.ContentLength > limit → 413), unbounded read otherwise Killed, 5 subtests (…UnknownLength/*, TestBodyLimitsOverRealChunkedRequests/*_over_the_cap)

Suggested fix for F1 (I wrote and ran it locally; it is not pushed). Feed each endpoint an endless body through a counting reader that errors after 8 MiB, then assert the 413 and that the handler pulled no more than limit + a small slack:

type countingEndless struct{ n, hardStop int64 }
func (c *countingEndless) Read(p []byte) (int, error) { /* '{' then spaces; c.n += len(p); error after hardStop */ }
// for each (path, limit): ServeHTTP with ContentLength=-1, then
//   assert w.Code == 413 && body.n <= limit+64<<10

It passes on the PR code. Against mutant A it fails on both endpoints: "handler read 8673600 bytes of an endless body; cap is 4096 / 65536".

Tests run

All on the merged tree (03f72e19, base ff9a501c):

  • cd cmd/server && go test ./... -count=1 -timeout 45m: ok (1282 s on this 4-core box; a first run with the default -timeout 10m hit the deadline in Test1690_BackgroundLoadHonesty while the ingestor suite ran in parallel. No test failed, it was just slow) [T]
  • cd cmd/ingestor && go test ./... -count=1 -timeout 45m: ok (2018 s; same note: the default-timeout parallel run hit the 10 m deadline in TestConcurrentWrites) [T] (untouched by this PR)
  • sh test-all.sh: 233 passed, 0 failed (233 files) [T]
  • node test-frontend-helpers.js: 709 passed, 0 failed [T]
  • E2E setup. A local Go server ran against e2e-fixture.db prepared as in CI: freshen, the seed SQL block, corescope-migrate, seeds 2073, 199 (and 245, as CI does). It was stopped through its port's pid.
  • E2E results.
    • test-issue-1487-byop-modal-layout-e2e.js 4/4 [T]
    • test-map-modal-fluid-e2e.js 10/10 (BYOP modal) [T]
    • test-issue-1648-m2-icons-e2e.js 21/21 [T]
    • test-e2e-playwright.js: 131/135 passed, 4 skipped [T]. It fails fast on "Version info lives on Perf dashboard, not in navbar" (the #navStats wait times out) here, identically against an origin/master binary, so it is environmental and not this PR. I re-ran with only that test skipped; the other 3 skips are pre-existing.
    • The PR adds no E2E of its own.
  • CI on the head commit (run 37683987831, attempt 1): ✅ Go Build & Test, ✅ Playwright E2E, ✅ Docker. Release, Deploy and Badges were skipped as expected. No reruns; neither flaky: #226 Hash Stats adopters sort E2E — server sorts multiByteNodes on packets only, ties come out in random order #256 nor flaky: TestResolvedPathBackfill_WriteHoldUnderBudget_188 — wall-clock write-hold budget fails under CI load #267 appears.

Not verified

  • [K] I did not measure behaviour behind a reverse proxy, or with HTTP/2 or keep-alive reuse after a 413. MaxBytesReader asks net/http to close the connection, and the client upload was indeed cut short, but I did not test that a follow-up request on the same connection behaves.
  • [K] The full Playwright job was not run locally: only the files above, and CI covers the rest.
  • [K] The public/packets.js obs-sort caller can send more than 200 hashes (it already gets a 400, which is swallowed, and above ~3450 hashes it now gets a 413). I agree with the author that this is pre-existing and belongs in a separate client-side chunking change. I did not reproduce it in the browser; the fixture has a single multi-observation group.
  • [K] No staging or prod access was used.

dborup added 4 commits October 8, 2026 09:02
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.
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Rapport — CS-Macmini PR#359 runde-2 — head 1e3eccf

Review feedback addressed (commit 1e3eccf2).

Reviewed against "Review — CS-pve-agent1 PR#359" (REQUEST CHANGES, head e030447a). Three commits on top of a merge of origin/master (195ec4c3, which already contained ff9a501c): 196a368a (the five findings), dfb9a515 (a correction to F2 found while verifying it) and 1e3eccf2 (the same section's lead sentence). No rebase, amend or force-push; only explicit git add. PR stays a draft.

Evidence tags: [T] automated test, [A] automated/manual check against a built binary or live server, [K] known accepted gap.

Findings

1. 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 cmd/server/request_body_limits_test.go:

  • TestBodyLimitsStopReadingAtTheCap feeds each endpoint a countingEndlessBody — one '{' then spaces forever, counting every byte handed out, with an 8 MiB hard stop so a runaway handler fails instead of hanging — with ContentLength == -1, and asserts both the 413 and read <= limit + 4096.
  • TestBodyLimitsDoNotDrainAnOversizedBody does the same on a finite body 16x over the cap through a countingReader, so a handler that buffers the whole request before measuring it is caught even without an endless body.

The 4096-byte slack is justified in the test: http.MaxBytesReader trims each Read to the remaining budget plus one byte, so it reads at most limit+1 from the underlying body. One page of slack is generous and still ~2000x below the hard stop.

Mutant A (the reviewer's) red → green [T]:

# Mutant A applied: MaxBytesReader dropped, unbounded io.ReadAll, size checked afterwards
--- FAIL: TestBodyLimitsStopReadingAtTheCap//api/decode
    handler read 8388608 bytes of an endless body; the cap is 4096 (allowed at most 8192).
    status: want 413, got 400
--- FAIL: TestBodyLimitsStopReadingAtTheCap//api/packets/observations
    handler read 8388608 bytes of an endless body; the cap is 65536 (allowed at most 69632).
    status: want 413, got 400
--- FAIL: TestBodyLimitsDoNotDrainAnOversizedBody//api/decode
    handler read 65544 bytes of a 65544-byte body; the cap is 4096 (allowed at most 8192)
--- FAIL: TestBodyLimitsDoNotDrainAnOversizedBody//api/packets/observations
    handler read 1048584 bytes of a 1048584-byte body; the cap is 65536 (allowed at most 69632)
FAIL

# Mutant reverted (diff clean):
ok  github.com/corescope/server

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) — docs/api-spec.md and info.description overclaimed. Agreed on every point, and verifying it turned up one more. Fixed rather than scoped away, because the table is the useful part:

  • the route is POST /api/paths/inspect; POST /api/path-inspect 404s [A]. Corrected in the doc, in the maxDecodeBodyBytes comment, and guarded by a test that fails if the non-existent path reappears;
  • PUT /api/config/geo-filter is now listed with its 1048576-byte (1 MiB) limit. Its inline 1<<20 became a named geoFilterBodyLimit so the doc row is pinned to the value the handler enforces, as the other rows already were;
  • per-endpoint status, not a blanket claim. The table now carries the exact over-limit status and body of each row: 413 + ApiError for the two #334 endpoints, 400 {"error":"invalid JSON"} as text/plain for /api/paths/inspect, 400 {"error":"invalid request body"} for /api/channel-proposals, 400 {"error":"invalid JSON"} for PUT /api/config/geo-filter;
  • POST /api/admin/prune-geo-filter?confirm=true (API key) decodes a JSON body with no limit at all — the fourth counterexample to "every endpoint", found while auditing the route table. Listed as a known gap;
  • info.description is scoped the same way and names docs/api-spec.md for the detail.

The extra correction (commit dfb9a515). Writing "POST /api/paths/inspect | 4096 bytes" would still have misled a client, because the three pre-#334 limits are not byte caps. They wrap the body and hand it to a streaming json.Decoder, so the limit only bites when the JSON value runs past it. Measured against the live binary [A]:

POST /api/paths/inspect   {"prefixes":["aa"]} + 6000 B of trailing space (6019 B)  -> 200
POST /api/decode          {"hex":"0200"}      + 6000 B of trailing space (6019 B)  -> 413
POST /api/paths/inspect   {"prefixes":["aa"],"pad":"A"*5000} (5028 B)              -> 400, text/plain

So the table gained an "Enforced on" column (commit dfb9a515) (bytes received, before parsing vs the streaming decoder), both models are spelled out with that measurement as the worked example, and the weaker enforcement is a known gap in its own right next to the status-code mismatch. This is also the clearest statement yet of why this PR reads into a bounded buffer instead of streaming — the reviewer's mutant B measured the same weakness from the other side.

I did not align the three 400 endpoints on 413: it changes three handlers this issue does not own, two of them behind an API key, and it is API-visible for any client that distinguishes the codes. Documented honestly instead, and listed under Rester.

3. F3 (nit) — trailing data. Agreed it should be a decision. TestBodyLimitsRejectTrailingData covers junk after the object and a second JSON object, on both endpoints (4 cases), plus trailing whitespace still returning 200. The PR description now has a "Deliberate behaviour change: trailing data is now rejected" section with the three 200→400 bodies the reviewer measured and the reason (matches decodeSingleJSONObject; JSON.stringify cannot produce trailing data).

4. F4 (nit) — map[string]interface{} occurrence. Fixed as suggested: the "responses" literal is hoisted into a local responses variable, routeMeta.Errors is applied to it before the operation map is built, and the type assertion is gone. Counts [A]: cmd/server/openapi.go 282 → 281, matching master; non-test total across cmd/ + internal/ 746 → 745, matching master exactly.

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 (3812 B, ~17.2x), the justification prose (284 bytes of slack under a 4 KiB cap, not 83), and the curl log. Recomputed independently [A]: {"hashes":[ (11) + 200x18 + 199 commas + ]} (2) = 3812; 201 hashes = 3831; 4096 − 3812 = 284; 65536 / 3812 = 17.2x. The OpenAPI summary for POST /api/packets/observations is now "Get observations for several packets" (was "Batch submit observations" next to a description saying it returns them), pinned by a test.

Mutants

Each applied to a clean tree, run, then reverted and the tree verified clean with git diff.

# Finding Mutant Result
A F1 MaxBytesReader dropped; unbounded io.ReadAll + json.Unmarshal, size checked afterwards (the reviewer's surviving mutant) Killed — 4 subtests across both new tests; read 8388608 B of an endless body against a 4096 B cap
B F2 Caps table reverted to POST /api/path-inspect / blanket 413 Killed — TestAPISpecDocumentsBodyLimits + TestAPISpecCapsTableMatchesRealBehaviour, incl. the "non-existent route" guard
C F2 info.description restored to "Endpoints that take a request body … answer 413" Killed — TestOpenAPIDescriptionDoesNotOverclaim413, 3 assertions
D F5 Summary restored to "Batch submit observations" Killed — TestBatchObservationsSummaryMatchesBehaviour
E F3 json.Unmarshal swapped back for a json.Decoder that ignores trailing data Killed — all 4 trailing-data subtests, 200 instead of 400
F F4 responses[e.Status] = jsonErrorResponse(...) loop removed after the hoist Killed — TestOpenAPIDocuments413OnBodyLimitedRoutes, both routes down to [200]

Mutant F also confirms the hoist did not quietly drop the 413 metadata it refactors.

Tests

Local, in this worktree, on the merged tree:

  • cd cmd/server && go test ./... -count=1 -timeout 45m — ok, 40.2 s [T]
  • cd cmd/ingestor && go test ./... -count=1 -timeout 45m — ok, 105.1 s [T] (untouched by this PR; run because of the master merge)
  • sh test-all.sh — 234 passed, 0 failed (234 files) [T]
  • node test-frontend-helpers.js — 712 passed, 0 failed [T]
  • scripts/check-xss-sinks.sh --diff origin/master — clean, no public/** changes to scan [A]
  • gofmt -l on the five touched files (request_body_limits.go, request_body_limits_test.go, openapi.go, routes.go + the doc) — empty. Many pre-existing cmd/server files are not gofmt-clean; none of them are touched here. go vet ./... clean [A]

E2E. A local Go server on port 13801 against test-fixtures/e2e-fixture.db prepared exactly as CI does: tools/freshen-fixture.sh, the #1486/#1791 seed SQL block, corescope-migrate, then seeds 2073, 199 and 245, with scripts/instrument-frontend.sh and -public public-instrumented. Stopped afterwards; the fixture DB was restored with git checkout and the build artifacts removed, so the tree is clean.

  • test-e2e-playwright.js — 132/135 passed, 3 skipped [T] (the 3 skips are pre-existing)
  • test-issue-1487-byop-modal-layout-e2e.js — 4/4 [T]
  • test-map-modal-fluid-e2e.js — 10/10 [T] (covers the BYOP decode modal, the only /api/decode client)
  • test-issue-147-packets-url-obs-e2e.js — 12/12 [T]
  • test-packet-detail-sender-hash-size-obs-e2e.js — 3/3 [T]

Live checks against the built binary [A], all on the same server:

POST /api/decode  4096 B                     -> 200
POST /api/decode  4097 B                     -> 413 application/json
                                                {"error":"request body too large (max 4096 bytes)"}
POST /api/decode  {"hex":"0200"} trailing    -> 400
POST /api/decode  {"hex":"0200"}{"hex":"zz"} -> 400
POST /api/packets/observations {"hashes":[]} junk -> 400
POST /api/path-inspect                       -> 404   (the route the old doc named)
POST /api/paths/inspect                      -> 200
GET  /api/spec  summary   = "Get observations for several packets"
                responses = ['200','413'] on both routes; ApiError present
                info.description scoped to the two #334 endpoints

CI (per job)

Run 37743709836, attempt 1, head 1e3eccf2. Green on the first attempt; no reruns.

Job Result
✅ Go Build & Test success, 23m18s — go test for server + ingestor, the ingestor race-check, internal/anomaly with -race, CSS-var lint, test-all.sh, the preflight XSS gate (--diff), eslint, proto syntax
🎭 Playwright E2E Tests success, 28m28s — full fail-fast E2E run plus the Kpa-clawbot#1616 slide-over flake-gate
🏗️ Build & Publish Docker Image success, 54s
📦 Release Artifacts skipped (not a tag)
🚀 Deploy Staging skipped
📝 Publish Badges & Summary skipped

Neither known-unstable test (#271, #301) failed, so no job needed a second run. The intermediate run on dfb9a515 (37742364857) shows as cancelled: the 1e3eccf2 push superseded it mid-flight via the workflow's concurrency group, not a failure.

Rester

  • [K] The three streaming-bounded endpoints are not tightened. POST /api/paths/inspect, POST /api/channel-proposals and PUT /api/config/geo-filter are bounded on the decoder, not on bytes, and answer 400 rather than 413 + ApiError. Both gaps are now stated in docs/api-spec.md with the measurement above. Converting them to decodeLimitedJSONBody is a small code change but an API-visible one across three handlers this issue does not own, so it belongs in its own change with its own clients checked.
  • [K] POST /api/admin/prune-geo-filter?confirm=true (API key) reads a JSON body with no limit at all. Found while auditing the route table for F2. Documented as a gap; capping it is the same separate change.
  • [K] PUT /api/config/geo-filter's over-limit 400 was verified by reading the handler, not against a live request — it is behind requireAPIKey and I did not use an API key. The limit value itself is pinned to geoFilterBodyLimit by a test, and POST /api/channel-proposals's over-limit 400 is covered by the pre-existing TestChannelProposalSubmitValidatesAndQueues [T].
  • [K] public/packets.js can build a batch request larger than the handler accepts — unchanged from round 1 and from the reviewer's own assessment: pre-existing, already a swallowed 400 too many hashes, and the real fix is client-side chunking in its own issue.
  • [K] Both #334 caps are still hardcoded constants with a TODO(#334, AGENTS.md rule 8); making them operator-configurable is a later milestone.
  • [K] Not verified: behaviour behind a reverse proxy, or keep-alive reuse after a 413 — same gap the reviewer noted. No staging or prod access was used; no API key was used.

@dborup-agent

Copy link
Copy Markdown
Collaborator

Review — CS-pve-agent1 PR#359 — head 1e3eccf

Dom: APPROVE med nits

Re-review after round 2. The blocking finding (F1) is fixed properly: the new tests measure the bytes the handler pulls off the body, not just the status code. My Mutant A is now red even in a variant that still answers the correct 413. F2–F5 are addressed. What is left is wording drift in the PR description and the docs, plus one doc test whose name promises more than it checks. None of it blocks a merge.

Evidence tags: [T] automated test I ran, [A] manual check I ran against a built binary or local server, [K] known or accepted gap / not verified.

Reviewed: head 1e3eccf2 and the merged tree git merge-tree --write-tree origin/master 1e3eccf2… = ec74c445, against origin/master 1f423e06. The merge is clean, and the merged diff against master touches only the PR's five files (openapi.go, request_body_limits.go, request_body_limits_test.go, routes.go, docs/api-spec.md). git ls-remote showed the head unchanged before and after the review.

Findings

# Severity Where Finding
R1 Nit PR description Some numbers and text are still stale despite the round-2 note "corrected throughout": (a) the curl log still says 201 hashes (4033 B); it is 3831 B [A] (the prose two sections up already says 3831); (b) "16 top-level tests, 31 cases" is now 22 top-level Test* functions; (c) the Tests table does not list the six new round-2 tests (TestBodyLimitsStopReadingAtTheCap, …DoNotDrainAnOversizedBody, …RejectTrailingData, TestAPISpecCapsTableMatchesRealBehaviour, TestOpenAPIDescriptionDoesNotOverclaim413, TestBatchObservationsSummaryMatchesBehaviour); (d) the Docs bullet still says "table of all four body-capped endpoints, and that the cap is on bytes received". The table now has five rows and says the opposite for three of them.
R2 Nit docs/api-spec.md "Enforced on the streaming decoder"; openapi.go info.description The prose says that for the streaming rows "a body that is over the limit only because of data that follows the value is still accepted". That holds for POST /api/paths/inspect (6019 B → 200 [A]) and PUT /api/config/geo-filter (value + 2 MiB of trailing spaces gets past decoding to validation [T, probe]). It does not hold for POST /api/channel-proposals: decodeSingleJSONObject reads to EOF, so {"name":"#ok1"} + 2000 spaces (2015 B) → 400 invalid request body, while the same with 500 spaces → 202 [T, probe]. That row is in effect a byte cap that reports 400. Separately, info.description says "the other body-taking endpoints bound their body on the JSON decoder" without the unbounded prune-geo-filter exception that api-spec.md does name. Both are one-clause fixes.
R3 Nit request_body_limits_test.go TestAPISpecCapsTableMatchesRealBehaviour The name promises a behaviour check, but the test only compares doc text with constants. It never calls a handler. Two mutants of mine survive the whole targeted suite: M3 makes /api/paths/inspect answer 413 + ApiError over its cap, so the doc row "400 … text/plain" becomes false; M4 makes the geo-filter handler use an inline 4<<20 instead of geoFilterBodyLimit, so the doc row "1048576 bytes" becomes false. The round-2 report's "the doc row is pinned to the value the handler enforces" is therefore true only while the handler keeps using the constant. These endpoints are outside #334, so this does not block. Either rename the test (e.g. …MatchesConstants) or add one over-cap request per row.

Answers to the review points

F1 (was blocking): fixed. [T][A]

  • TestBodyLimitsStopReadingAtTheCap feeds an endless { + spaces body (ContentLength = -1) through a counting reader with an 8 MiB hard stop. It asserts read <= limit + 4096 and the 413.

  • TestBodyLimitsDoNotDrainAnOversizedBody does the same on a finite body 16× over the cap.

  • Both assert consumption first, so they measure what security(api): bound decode and batch-observation request bodies before JSON parsing #334 is about.

  • My Mutant A, rerun: MaxBytesReader removed, unbounded io.ReadAll, then if len(raw) > limit → 413. I deliberately put the size check before the error branch, so the mutant still answers a correct-looking 413. Result: red, 4 subtests, e.g. "handler read 8388608 bytes of an endless body; the cap is 4096 (allowed at most 8192)" and "handler read 1048584 bytes of a 1048584-byte body; the cap is 65536". The failures come from the bytes-read assertion alone, not from the status.

  • The slack is sound. MaxBytesReader reads at most limit+1 from the underlying body. 4096 B of slack is far below both the hard stop and the 50 MB attack size.

  • RSS, 50 MB body padded into an unknown field (/proc/<pid>/status VmHWM, same fixture, one server per binary):

    Binary Endpoint (Content-Length / chunked) Status Bytes the client managed to upload VmHWM before → after
    origin/master /api/decode CL / chunked 200 / 200 52.4 MB / 52.4 MB 51.5 → 207.7 → 319.6 MB
    origin/master /api/packets/observations CL / chunked 200 / 200 52.4 MB / 52.4 MB 319.6 → 404.3 → 500.7 MB
    Mutant A /api/decode CL / chunked 413 / 413 52.4 MB / 52.4 MB 50.0 → 164.3 → 164.3 MB
    Mutant A /api/packets/observations CL / chunked 413 / 413 52.4 MB / 52.4 MB 164.3 → 187.8 → 187.8 MB
    PR (merged) /api/decode CL / chunked 413 / 413 393 kB / 524 kB 51.4 → 51.6 → 51.8 MB
    PR (merged) /api/packets/observations CL / chunked 413 / 413 852 kB / 655 kB 51.8 → 52.1 → 52.4 MB

    Mutant A and the PR give the client the same status, but Mutant A grows the high-water mark by about 138 MB and the PR by about 1 MB. The new tests now tell the two apart.

F2: docs and info.description are now true for the two #334 endpoints and for every row I could check. [A][T]

  • Route. POST /api/path-inspect → 404 and POST /api/paths/inspect → 200 [A]. The doc names only the real route, and a test guards against the old one.

  • 413 vs 400 per endpoint:

    • decode and batch: 413, application/json, {"error":"request body too large (max N bytes)"} [A];
    • paths/inspect over its cap (5028 B value): 400 {"error":"invalid JSON"} as text/plain; charset=utf-8 [A];
    • channel-proposals over its cap: 400 {"error":"invalid request body"}, application/json [T, probe; the fixture config has the feature disabled, so live it answers 403 first];
    • geo-filter with a 1.2 MB polygon: 400 {"error":"invalid JSON"}, application/json [T, probe with a test key in the unit harness; I used no server API key, and live without a key it answers 403].

    All of these match the table.

  • PUT /api/config/geo-filter is listed. The 1<<20 → geoFilterBodyLimit rename is value-identical, so it is not a behaviour change.

  • prune-geo-filter is correctly reported as having no limit. I confirmed routes.go decodes r.Body with no MaxBytesReader, and the other admin POSTs (approve/reject/revoke, perf/reset) read no body. So "every endpoint that takes a body bounds it, with one exception" holds.

  • No alignment. The endpoints were not aligned on 413. I accept the author's reasoning: it would be API-visible across three handlers this issue does not own. The served /api/spec has ['200','413'] on both routes with $ref ApiError, and its info.description is the scoped text [A]. Wording nits in R2.

F3: done. TestBodyLimitsRejectTrailingData has four rejection cases and a whitespace-still-accepted case. All are red against master-equivalent handlers [T] and green on the PR. The PR description has a "Deliberate behaviour change" section. Live: trailing junk and a second object → 400, trailing whitespace → 200 [A].

F4: done. map[string]interface{} occurrences, counted the same way on all three trees (non-test .go under cmd/ + internal/):

Tree Total cmd/server/openapi.go
origin/master 1f423e06 765 293
merged 765 293
head 765 293

The type assertion is gone. The responses hoist is a pure move: four - and four + lines in the diff. (My absolute numbers differ from the author's 745 / 281 because master moved; the delta is what matters, and it is 0.)

F5: mostly done. 3812 / 3831 / 284 / 17.2× are right [A] (json.dumps of 200 and 201 16-hex hashes: 3812 and 3831 B; live 200 hashes → 200, 201 hashes → 400 too many hashes). The OpenAPI summary is now "Get observations for several packets" and pinned by a test. Leftovers are in R1.

Scope. Round 2 only touches the five PR files and the findings' subject matter. The single change outside the two #334 handlers is the value-identical geoFilterBodyLimit rename. There is no behaviour change beyond the purpose.

Standing checks. [A]

  • Read-only. cmd/server stays read-only: no SQL, no writes and no RW helper in the diff. readonly_invariant_test.go is green in the full suite.
  • map[string]interface{}. No new occurrence (see F4).
  • Frontend. No public/** changes, so there are no hardcoded colours. bash scripts/check-xss-sinks.sh --diff origin/master on the head: "no public/**/*.{js,html} changes to scan", rc 0.
  • Fork guards. 9 in deploy.yml and 1 in release-fast-path.yml; no workflow is touched.
  • Closing keywords. None in the PR body or the commits, and closingIssuesReferences is empty.
  • Authorship. All six commits since master have author and committer set to the expected dborup identity (project email).
  • Formatting. gofmt -l is empty on the four Go files, and go vet ./... is clean.

Tests and mutants

Red before / green after. I put both handlers back on master's unbounded json.NewDecoder(r.Body).Decode in a scratch copy and kept the new constants so the tests compile. Result: 19 leaf subtests red [T]:

  • the 11 round-1 cases;
  • 2 + 2 consumption cases;
  • 4 trailing-data cases.

All are green on the merged tree. Every #334 acceptance criterion therefore has a test that is red before and green after:

  • bytes before parsing;
  • 413 + JSON;
  • ±1 boundaries;
  • unknown fields;
  • chunked / unknown length;
  • malformed JSON;
  • normal requests;
  • the 200-hash limit.

The 200-hash limit is covered by round 1's TestBatchObservationsHashLimitPreserved plus the pre-existing TestHandleBatchObservations.

My mutants. Each was run on a scratch copy of the merged tree with -run 'BodyLimit|BatchObservations|Decode|Chunked|413|APISpec|OpenAPI|HandleBatch|PathInspect|GeoFilter|ChannelProposal|Inspect', then reverted. Afterwards diff -r against the merged tree was clean apart from test-run artefacts in prune-requests/.

# Mutant Result
A Size checked after an unbounded read; status still 413 Killed, 4 subtests (StopReadingAtTheCap ×2, DoNotDrainAnOversizedBody ×2)
M1 Chunked bypass: MaxBytesReader only when ContentLength >= 0, otherwise an unbounded read plus a post-check (still 413) Killed, 2 subtests (StopReadingAtTheCap). TestBodyLimitsOverRealChunkedRequests alone did not catch it, so the new test adds real coverage
M2 "Polite" drain: io.Copy(io.Discard, originalBody) before answering 413 Killed, 4 subtests (both consumption tests)
M3 /api/paths/inspect answers 413 + ApiError over its cap (doc row becomes false) Survives → R3
M4 geo-filter handler uses an inline 4<<20 instead of geoFilterBodyLimit (doc row becomes false) Survives → R3

Suites on the merged tree (ec74c445, base 1f423e06):

  • cd cmd/server && go test ./... -count=1 -timeout 45m: ok (38.5 s) [T]
  • cd cmd/ingestor && go test ./... -count=1 -timeout 45m: ok (226 s; not touched by the PR) [T]
  • sh test-all.sh: 234 passed, 0 failed (234 files) [T]
  • node test-frontend-helpers.js: 712 passed, 0 failed [T]

E2E. A local Go server ran against test-fixtures/e2e-fixture.db, prepared as in CI: tools/freshen-fixture.sh, the Kpa-clawbot#1486/Kpa-clawbot#1791 seed SQL block taken from deploy.yml, corescope-migrate, then seeds 2073, 199 and 245. It was stopped through its port's pid.

  • test-issue-1487-byop-modal-layout-e2e.js: 4/4 [T]
  • test-map-modal-fluid-e2e.js: 10/10 (BYOP decode modal) [T]
  • test-issue-147-packets-url-obs-e2e.js: 12/12 [T]
  • test-packet-detail-sender-hash-size-obs-e2e.js: 3/3 [T]
  • test-e2e-playwright.js: 131/134 passed, 3 skipped, 0 failed [T], with one environmental test disabled (135 total; the 3 skips are the pre-existing fixture-dependent ones). It fails fast on "Version info lives on Perf dashboard, not in navbar" (the #navStats wait times out) here, identically against an origin/master binary on the same fixture. That is the same environmental failure as in my round-1 review. I reran with only that one test disabled.
  • The PR adds no E2E of its own, and none is needed for a server-side change with no UI surface beyond the BYOP error message, which round 1 covered.

CI on the head (run 37743709836, attempt 1, pull_request), checked per job:

  • ✅ Go Build & Test (23 m)
  • ✅ Playwright E2E (28 m)
  • ✅ Build & Publish Docker Image
  • Release, Deploy Staging and Badges skipped as expected

There were no reruns, and neither #256 nor #267 appears.

Not verified

  • [K] The over-cap behaviour of channel-proposals and geo-filter was checked through the unit-test harness with a test key, not over a live request. The fixture server has proposals disabled, and I used no server API key.
  • [K] Behaviour behind a reverse proxy, HTTP/2, and keep-alive reuse after a 413: same gap as round 1.
  • [K] The client-side batch chunking in public/packets.js is still out of scope, as agreed in round 1.
  • [K] No staging or prod access was used.

@dborup
dborup marked this pull request as ready for review October 8, 2026 08:42
@dborup
dborup merged commit c7c75cb into master Oct 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants