diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 46a3b332a..5525579bd 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -22,6 +22,16 @@ type routeMeta struct { // generic {"type":"object"} placeholder. Use schemaRef(...) to point // at a named entry in components/schemas (see componentSchemas). Response map[string]interface{} `json:"-"` + // Errors documents the non-200 status codes a route answers with, on top + // of the 400/404/405 the whole API shares (see info.description). Bodies + // are always the ApiError shape writeError produces (#266). + Errors []routeError `json:"-"` +} + +// routeError is one documented non-200 response of a route (#334). +type routeError struct { + Status string // HTTP status code, e.g. "413" + Description string } type paramMeta struct { @@ -67,12 +77,18 @@ func routeDescriptions() map[string]routeMeta { {Name: "search", Description: "Full-text search", Type: "string"}, {Name: "groupByHash", Description: "Group duplicate packets by hash", Type: "boolean"}, }}, - "GET /api/packets/{id}": {Summary: "Get packet detail", Tag: "packets"}, - "GET /api/packets/timestamps": {Summary: "Get packet timestamp ranges", Tag: "packets"}, - "POST /api/packets/observations": {Summary: "Batch submit observations", Description: "Submit multiple observer sightings for existing packets.", Tag: "packets"}, + "GET /api/packets/{id}": {Summary: "Get packet detail", Tag: "packets"}, + "GET /api/packets/timestamps": {Summary: "Get packet timestamp ranges", Tag: "packets"}, + "POST /api/packets/observations": {Summary: "Get observations for several packets", Description: "Returns the stored observations for up to 200 content hashes in one call: {\"hashes\": [...]} in, {\"results\": {\"\": [...]}} out. More than 200 hashes is 400. The request body is capped at 65536 bytes before it is parsed (#334) — a larger body is 413 regardless of its shape or of whether it declares a Content-Length, and the byte cap leaves roughly 17x headroom over a full 200-hash request, so the 200-hash limit is what a client actually meets first.", Tag: "packets", + Errors: []routeError{ + {Status: "413", Description: "Request body over 65536 bytes (#334). Enforced before parsing, on bytes received, so it also applies to a chunked request with no Content-Length."}, + }}, // Decode - "POST /api/decode": {Summary: "Decode a raw packet", Description: "Decodes a hex-encoded packet without storing it.", Tag: "packets"}, + "POST /api/decode": {Summary: "Decode a raw packet", Description: "Decodes a hex-encoded packet without storing it. The request body is capped at 4096 bytes before it is parsed (#334) — a larger body is 413 regardless of its shape or of whether it declares a Content-Length. The largest frame the decoder can accept (header + transport codes + MAX_PATH_SIZE path bytes + MAX_PACKET_PAYLOAD payload bytes) is a ~520-byte body, so the cap leaves roughly 7x headroom.", Tag: "packets", + Errors: []routeError{ + {Status: "413", Description: "Request body over 4096 bytes (#334). Enforced before parsing, on bytes received, so it also applies to a chunked request with no Content-Length."}, + }}, // Nodes "GET /api/nodes": {Summary: "List nodes", Description: "Returns all known mesh nodes with status and metadata. Repeater/room rows carry the issue #672 usefulness metrics (traffic_share_score, bridge_score, coverage_score, redundancy_score), the composite usefulness_score + usefulness_grade, and relay-activity counters. See the Node schema.", Tag: "nodes", @@ -234,6 +250,28 @@ func openAPIRef(name string) *openAPISchema { return &openAPISchema{Ref: "#/components/schemas/" + name} } +// openAPIResponse / openAPIMediaType are typed OpenAPI 3.0 response objects. +// They marshal to the same JSON as the map literals used for the shared 200 +// response; routeMeta.Errors uses them instead of new untyped map literals +// (#1383). +type openAPIResponse struct { + Description string `json:"description"` + Content map[string]openAPIMediaType `json:"content,omitempty"` +} + +type openAPIMediaType struct { + Schema *openAPISchema `json:"schema,omitempty"` +} + +// jsonErrorResponse is an application/json response carrying the shared +// ApiError body. +func jsonErrorResponse(description string) openAPIResponse { + return openAPIResponse{ + Description: description, + Content: map[string]openAPIMediaType{"application/json": {Schema: openAPIRef("ApiError")}}, + } +} + // nodeAdvertRouteSchemas documents the #2073 node-detail advert route fields // (port of upstream Kpa-clawbot/CoreScope#2073). func nodeAdvertRouteSchemas() map[string]*openAPISchema { @@ -859,6 +897,11 @@ func componentSchemas() map[string]interface{} { }, }, } + schemas["ApiError"] = &openAPISchema{ + Type: "object", + Description: "The shared error body every /api/* error answer uses (#266): a single human-readable message. Status codes carry the machine-readable meaning, not this string.", + Properties: map[string]*openAPISchema{"error": {Type: "string"}}, + } for name, schema := range nodeAdvertRouteSchemas() { schemas[name] = schema } @@ -925,6 +968,22 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} respSchema = meta.Response } + responses := map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Success", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": respSchema, + }, + }, + }, + } + if hasMeta { + for _, e := range meta.Errors { + responses[e.Status] = jsonErrorResponse(e.Description) + } + } + // Build operation op := map[string]interface{}{ "summary": func() string { @@ -933,16 +992,7 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} } return ri.path }(), - "responses": map[string]interface{}{ - "200": map[string]interface{}{ - "description": "Success", - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": respSchema, - }, - }, - }, - }, + "responses": responses, } if hasMeta { @@ -1026,7 +1076,7 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} "openapi": "3.0.3", "info": map[string]interface{}{ "title": "CoreScope API", - "description": "MeshCore network analyzer — packet capture, node tracking, and mesh analytics. An unrecognized /api or /api/* path returns 404; a documented path called with an unsupported method returns 405 with an Allow header. Both are JSON (#233). HEAD is served on every GET path.", + "description": "MeshCore network analyzer — packet capture, node tracking, and mesh analytics. An unrecognized /api or /api/* path returns 404; a documented path called with an unsupported method returns 405 with an Allow header. Both are JSON (#233). POST /api/decode and POST /api/packets/observations cap their request body in bytes before parsing it and answer 413 with the ApiError body when it is over the cap (#334); the other body-taking endpoints bound their body on the JSON decoder instead, which is weaker and reports an over-cap body as 400 — docs/api-spec.md lists every cap, how it is enforced, and its status. HEAD is served on every GET path.", "version": version, "license": map[string]interface{}{ "name": "MIT", diff --git a/cmd/server/request_body_limits.go b/cmd/server/request_body_limits.go new file mode 100644 index 000000000..21b21d01f --- /dev/null +++ b/cmd/server/request_body_limits.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +// Issue #334 — request-body byte caps for the two public POST endpoints that +// used to hand r.Body straight to a json.Decoder with no bound: +// +// - POST /api/decode (maxDecodeBodyBytes) +// - POST /api/packets/observations (maxBatchObservationsBodyBytes) +// +// Both caps run BEFORE any parsing, so an oversized body never gets decoded +// into memory, whatever its shape (deeply nested, one giant string, or bulk +// stuffed into a field the handler does not even know about). The existing +// 200-hash limit on the batch endpoint is a cardinality limit that runs after +// parsing, so it never bounded bytes; it is unchanged and still fires first for +// every request a legitimate client sends (see the headroom notes below). +// +// Do not rely on a reverse proxy for this: the caps are enforced in the +// application, and TestBodyLimitsOverRealChunkedRequests exercises them over a +// real connection with no Content-Length at all. +// +// TODO(#334, AGENTS.md rule 8): both limits are hardcoded here for now and +// should be exposed as operator-configurable values (config.json, and the +// customizer where it makes sense) in a later milestone. +const ( + // maxDecodeBodyBytes caps POST /api/decode. The largest frame DecodePacket + // can possibly accept is 1 header + 4 transport codes + 1 path byte + + // MAX_PATH_SIZE (64) path bytes + MAX_PACKET_PAYLOAD (184) payload bytes = + // 254 bytes, i.e. 508 hex characters, i.e. a ~520-byte JSON body. 4 KiB + // leaves ~7x headroom over anything the decoder would not reject anyway. + // Matches the 4 KiB upstream uses, and the existing 4096-byte cap on + // POST /api/paths/inspect (inspectBodyLimit). + maxDecodeBodyBytes = 4 << 10 // 4 KiB + + // maxBatchObservationsBodyBytes caps POST /api/packets/observations. The + // biggest request the handler accepts is 200 16-hex content hashes, which + // serializes to ~3.8 KiB, so 64 KiB leaves ~17x headroom: every request + // within the 200-hash limit passes, and a client that exceeds that limit + // still gets the established 400 "too many hashes" rather than a 413. + // Matches the 64 KiB upstream uses. + maxBatchObservationsBodyBytes = 64 << 10 // 64 KiB + + // geoFilterBodyLimit caps PUT /api/config/geo-filter (API key). It predates + // #334 and is named here only so the caps table in docs/api-spec.md can be + // pinned to the value the handler actually enforces; the handler still + // reports an over-cap body as 400 "invalid JSON", not 413. A polygon may + // carry 1000 points, so the cap is generous on purpose. + geoFilterBodyLimit = 1 << 20 // 1 MiB +) + +// decodeLimitedJSONBody reads at most limit bytes of r.Body and unmarshals them +// into v. It answers the request itself and returns false when the body is over +// the cap (413, naming the limit) or is not valid JSON (400); the caller returns +// immediately in that case. Both answers use the shared writeError shape +// ({"error": "..."} as application/json, #266), so clients get one error +// contract across every endpoint. +// +// Reading the body to a bounded buffer first — rather than letting a +// json.Decoder stream it — is what makes the cap a byte cap: the verdict +// depends only on how many bytes arrived, never on where in the JSON the +// decoder happened to stop. A body of exactly limit bytes is accepted; limit+1 +// is rejected. +func decodeLimitedJSONBody(w http.ResponseWriter, r *http.Request, limit int64, v interface{}) bool { + r.Body = http.MaxBytesReader(w, r.Body, limit) + raw, err := io.ReadAll(r.Body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeError(w, http.StatusRequestEntityTooLarge, + fmt.Sprintf("request body too large (max %d bytes)", limit)) + return false + } + // A truncated or aborted upload is as unusable as malformed JSON, and + // json.Decoder answered the same 400 for it before this cap existed. + writeError(w, http.StatusBadRequest, "invalid JSON body") + return false + } + if err := json.Unmarshal(raw, v); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return false + } + return true +} diff --git a/cmd/server/request_body_limits_test.go b/cmd/server/request_body_limits_test.go new file mode 100644 index 000000000..63cb2fe38 --- /dev/null +++ b/cmd/server/request_body_limits_test.go @@ -0,0 +1,791 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "testing" + + "github.com/gorilla/mux" +) + +// Issue #334: POST /api/decode and POST /api/packets/observations decoded +// their JSON bodies with no byte cap. These tests pin the byte caps applied +// BEFORE parsing, the documented 413, and the fact that the pre-existing +// decode/200-hash behaviour is unchanged. + +// padJSONTo returns obj (a complete JSON object) padded with trailing spaces +// to exactly n bytes. Trailing whitespace is valid JSON, so the padding only +// grows the body — it never changes the parsed value. Used to hit the byte +// boundaries exactly without depending on JSON field sizes. +func padJSONTo(t *testing.T, obj string, n int) string { + t.Helper() + if len(obj) > n { + t.Fatalf("object %q is already %d bytes, cannot pad to %d", obj, len(obj), n) + } + return obj + strings.Repeat(" ", n-len(obj)) +} + +// postBody issues POST path with the given body through router and returns the +// recorder. Body is a strings.Reader, so ContentLength is set (the normal case). +func postBody(router http.Handler, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +// postBodyUnknownLength issues the same request with ContentLength == -1, the +// shape Go's server produces for a chunked (Transfer-Encoding: chunked) request. +// opaqueReader hides the concrete type so httptest.NewRequest cannot infer a +// length. +type opaqueReader struct{ r io.Reader } + +func (o opaqueReader) Read(p []byte) (int, error) { return o.r.Read(p) } + +func postBodyUnknownLength(router http.Handler, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", path, opaqueReader{strings.NewReader(body)}) + req.Header.Set("Content-Type", "application/json") + req.ContentLength = -1 + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +// assertJSONError asserts the response is the shared writeError shape +// ({"error": "..."}, application/json) with a non-empty message. +func assertJSONError(t *testing.T, w *httptest.ResponseRecorder, wantCode int) string { + t.Helper() + if w.Code != wantCode { + t.Fatalf("status: want %d, got %d (body %q)", wantCode, w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type: want application/json, got %q", ct) + } + var payload struct { + Error string `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("response is not JSON: %v (body %q)", err, w.Body.String()) + } + if payload.Error == "" { + t.Fatalf("expected a non-empty error field, got %q", w.Body.String()) + } + return payload.Error +} + +// --- POST /api/decode --- + +func TestDecodeBodyLimitBoundaries(t *testing.T) { + _, router := setupNoStoreServer(t) + const obj = `{"hex":"0200"}` + + t.Run("exactly at the limit is accepted", func(t *testing.T) { + w := postBody(router, "/api/decode", padJSONTo(t, obj, maxDecodeBodyBytes)) + if w.Code != http.StatusOK { + t.Fatalf("want 200 for a %d-byte body, got %d (body %q)", maxDecodeBodyBytes, w.Code, w.Body.String()) + } + }) + + t.Run("one byte over the limit is 413", func(t *testing.T) { + w := postBody(router, "/api/decode", padJSONTo(t, obj, maxDecodeBodyBytes+1)) + msg := assertJSONError(t, w, http.StatusRequestEntityTooLarge) + if !strings.Contains(msg, fmt.Sprintf("%d", maxDecodeBodyBytes)) { + t.Errorf("error message should name the limit, got %q", msg) + } + }) + + t.Run("one byte under the limit is accepted", func(t *testing.T) { + w := postBody(router, "/api/decode", padJSONTo(t, obj, maxDecodeBodyBytes-1)) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body %q)", w.Code, w.Body.String()) + } + }) +} + +// An oversized body whose bulk sits in a field the handler does not know about +// must still be rejected on bytes — the cap runs before parsing, so unknown +// fields cannot buy extra room. +func TestDecodeBodyLimitOversizedUnknownField(t *testing.T) { + _, router := setupNoStoreServer(t) + junk := strings.Repeat("A", maxDecodeBodyBytes) + body := `{"hex":"0200","unknownField":"` + junk + `"}` + if len(body) <= maxDecodeBodyBytes { + t.Fatalf("test body is only %d bytes, expected > %d", len(body), maxDecodeBodyBytes) + } + w := postBody(router, "/api/decode", body) + assertJSONError(t, w, http.StatusRequestEntityTooLarge) +} + +func TestDecodeBodyLimitUnknownLength(t *testing.T) { + _, router := setupNoStoreServer(t) + const obj = `{"hex":"0200"}` + + t.Run("oversized with no Content-Length is 413", func(t *testing.T) { + w := postBodyUnknownLength(router, "/api/decode", padJSONTo(t, obj, maxDecodeBodyBytes+1)) + assertJSONError(t, w, http.StatusRequestEntityTooLarge) + }) + + t.Run("a lying Content-Length cannot raise the cap", func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/decode", opaqueReader{strings.NewReader(padJSONTo(t, obj, maxDecodeBodyBytes+1))}) + req.Header.Set("Content-Type", "application/json") + req.ContentLength = int64(len(obj)) // understates the real body + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assertJSONError(t, w, http.StatusRequestEntityTooLarge) + }) + + t.Run("within the cap with no Content-Length still decodes", func(t *testing.T) { + w := postBodyUnknownLength(router, "/api/decode", obj) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body %q)", w.Code, w.Body.String()) + } + }) +} + +func TestDecodeBodyLimitMalformedJSON(t *testing.T) { + _, router := setupNoStoreServer(t) + + t.Run("malformed under the cap is 400", func(t *testing.T) { + w := postBody(router, "/api/decode", `{"hex":`) + assertJSONError(t, w, http.StatusBadRequest) + }) + + t.Run("malformed over the cap is 413, not 400", func(t *testing.T) { + // The cap runs before parsing, so bytes win over syntax. + w := postBody(router, "/api/decode", `{"hex":"`+strings.Repeat("0", maxDecodeBodyBytes+1)) + assertJSONError(t, w, http.StatusRequestEntityTooLarge) + }) + + t.Run("empty body is 400", func(t *testing.T) { + w := postBody(router, "/api/decode", "") + assertJSONError(t, w, http.StatusBadRequest) + }) +} + +// The cap must leave every frame the decoder can possibly accept decodable: +// 1 header + 4 transport codes + 1 path byte + MAX_PATH_SIZE path bytes + +// MAX_PACKET_PAYLOAD payload bytes, hex-encoded, wrapped in the JSON object. +func TestDecodeBodyLimitFitsLargestDecodableFrame(t *testing.T) { + maxFrameBytes := 1 + 4 + 1 + maxPathSize + maxPacketPayload + largest := len(`{"hex":""}`) + 2*maxFrameBytes + if largest > maxDecodeBodyBytes { + t.Fatalf("largest legitimate decode body is %d bytes, over the %d-byte cap", largest, maxDecodeBodyBytes) + } + if maxDecodeBodyBytes < 4*largest { + t.Errorf("cap %d leaves less than 4x headroom over the largest legitimate body (%d)", maxDecodeBodyBytes, largest) + } + // Headroom is bounded on both sides: a cap orders of magnitude above what + // any client can legitimately send is not a bound worth having. + if maxDecodeBodyBytes > 32*largest { + t.Errorf("cap %d is more than 32x the largest legitimate body (%d) — too loose to bound anything", maxDecodeBodyBytes, largest) + } +} + +func TestDecodeNormalRequestUnchanged(t *testing.T) { + _, router := setupNoStoreServer(t) + + t.Run("a valid frame still decodes", func(t *testing.T) { + w := postBody(router, "/api/decode", `{"hex":"0200"}`) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body %q)", w.Code, w.Body.String()) + } + var resp struct { + Decoded map[string]json.RawMessage `json:"decoded"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v (body %q)", err, w.Body.String()) + } + for _, key := range []string{"header", "path", "payload"} { + if _, ok := resp.Decoded[key]; !ok { + t.Errorf("decoded.%s missing from %q", key, w.Body.String()) + } + } + }) + + t.Run("missing hex is still 400", func(t *testing.T) { + w := postBody(router, "/api/decode", `{"hex":""}`) + if msg := assertJSONError(t, w, http.StatusBadRequest); msg != "hex is required" { + t.Errorf("want \"hex is required\", got %q", msg) + } + }) + + t.Run("undecodable hex is still 400", func(t *testing.T) { + w := postBody(router, "/api/decode", `{"hex":"zz"}`) + assertJSONError(t, w, http.StatusBadRequest) + }) +} + +// --- POST /api/packets/observations --- + +// batchBody returns {"hashes":[...]} with n distinct 16-hex hashes. +func batchBody(t *testing.T, n int) string { + t.Helper() + hashes := make([]string, n) + for i := range hashes { + hashes[i] = fmt.Sprintf("%016x", i) + } + raw, err := json.Marshal(struct { + Hashes []string `json:"hashes"` + }{hashes}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(raw) +} + +func TestBatchObservationsBodyLimitBoundaries(t *testing.T) { + _, router := setupNoStoreServer(t) + const obj = `{"hashes":[]}` + + t.Run("exactly at the limit is accepted", func(t *testing.T) { + w := postBody(router, "/api/packets/observations", padJSONTo(t, obj, maxBatchObservationsBodyBytes)) + if w.Code != http.StatusOK { + t.Fatalf("want 200 for a %d-byte body, got %d (body %q)", maxBatchObservationsBodyBytes, w.Code, w.Body.String()) + } + }) + + t.Run("one byte over the limit is 413", func(t *testing.T) { + w := postBody(router, "/api/packets/observations", padJSONTo(t, obj, maxBatchObservationsBodyBytes+1)) + msg := assertJSONError(t, w, http.StatusRequestEntityTooLarge) + if !strings.Contains(msg, fmt.Sprintf("%d", maxBatchObservationsBodyBytes)) { + t.Errorf("error message should name the limit, got %q", msg) + } + }) + + t.Run("one byte under the limit is accepted", func(t *testing.T) { + w := postBody(router, "/api/packets/observations", padJSONTo(t, obj, maxBatchObservationsBodyBytes-1)) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body %q)", w.Code, w.Body.String()) + } + }) +} + +func TestBatchObservationsBodyLimitOversizedUnknownField(t *testing.T) { + _, router := setupNoStoreServer(t) + junk := strings.Repeat("A", maxBatchObservationsBodyBytes) + body := `{"hashes":[],"unknownField":"` + junk + `"}` + w := postBody(router, "/api/packets/observations", body) + assertJSONError(t, w, http.StatusRequestEntityTooLarge) +} + +func TestBatchObservationsBodyLimitUnknownLength(t *testing.T) { + _, router := setupNoStoreServer(t) + + t.Run("oversized with no Content-Length is 413", func(t *testing.T) { + w := postBodyUnknownLength(router, "/api/packets/observations", padJSONTo(t, `{"hashes":[]}`, maxBatchObservationsBodyBytes+1)) + assertJSONError(t, w, http.StatusRequestEntityTooLarge) + }) + + t.Run("within the cap with no Content-Length still works", func(t *testing.T) { + w := postBodyUnknownLength(router, "/api/packets/observations", batchBody(t, 2)) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body %q)", w.Code, w.Body.String()) + } + }) +} + +func TestBatchObservationsBodyLimitMalformedJSON(t *testing.T) { + _, router := setupNoStoreServer(t) + + t.Run("malformed under the cap is 400", func(t *testing.T) { + w := postBody(router, "/api/packets/observations", `not json`) + assertJSONError(t, w, http.StatusBadRequest) + }) + + t.Run("malformed over the cap is 413, not 400", func(t *testing.T) { + w := postBody(router, "/api/packets/observations", `{"hashes":["`+strings.Repeat("a", maxBatchObservationsBodyBytes+1)) + assertJSONError(t, w, http.StatusRequestEntityTooLarge) + }) +} + +// The 200-hash limit is a cardinality limit and must keep firing on its own: +// the largest request it accepts has to stay well inside the byte cap, and a +// 201-hash request must still be the 400 it has always been, not a 413. +func TestBatchObservationsHashLimitPreserved(t *testing.T) { + _, router := setupNoStoreServer(t) + + t.Run("200 hashes fit inside the byte cap", func(t *testing.T) { + body := batchBody(t, 200) + if len(body) > maxBatchObservationsBodyBytes { + t.Fatalf("a 200-hash body is %d bytes, over the %d-byte cap", len(body), maxBatchObservationsBodyBytes) + } + if maxBatchObservationsBodyBytes < 4*len(body) { + t.Errorf("cap %d leaves less than 4x headroom over a full 200-hash body (%d)", maxBatchObservationsBodyBytes, len(body)) + } + if maxBatchObservationsBodyBytes > 64*len(body) { + t.Errorf("cap %d is more than 64x a full 200-hash body (%d) — too loose to bound anything", maxBatchObservationsBodyBytes, len(body)) + } + w := postBody(router, "/api/packets/observations", body) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body %q)", w.Code, w.Body.String()) + } + }) + + t.Run("201 hashes is still 400, not 413", func(t *testing.T) { + body := batchBody(t, 201) + if len(body) > maxBatchObservationsBodyBytes { + t.Fatalf("a 201-hash body is %d bytes, so the byte cap would mask the hash limit", len(body)) + } + msg := assertJSONError(t, postBody(router, "/api/packets/observations", body), http.StatusBadRequest) + if !strings.Contains(msg, "too many hashes") { + t.Errorf("want the hash-limit message, got %q", msg) + } + }) +} + +func TestBatchObservationsNormalRequestUnchanged(t *testing.T) { + _, router := setupNoStoreServer(t) + + t.Run("empty hashes returns an empty results map", func(t *testing.T) { + w := postBody(router, "/api/packets/observations", `{"hashes":[]}`) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body %q)", w.Code, w.Body.String()) + } + var resp struct { + Results map[string]json.RawMessage `json:"results"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v (body %q)", err, w.Body.String()) + } + if len(resp.Results) != 0 { + t.Errorf("want an empty results map, got %v", resp.Results) + } + }) + + t.Run("known hashes return a results map", func(t *testing.T) { + w := postBody(router, "/api/packets/observations", `{"hashes":["abc123","def456"]}`) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body %q)", w.Code, w.Body.String()) + } + var resp struct { + Results map[string]json.RawMessage `json:"results"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v (body %q)", err, w.Body.String()) + } + }) +} + +// --- Real HTTP, real chunked transfer encoding --- + +// Go's http.Client sends a request with an unknown-length body as +// Transfer-Encoding: chunked, so this exercises the limit over a real +// connection with no Content-Length at all — the case a Content-Length +// precheck alone would miss. +func TestBodyLimitsOverRealChunkedRequests(t *testing.T) { + _, router := setupNoStoreServer(t) + ts := httptest.NewServer(router) + defer ts.Close() + + post := func(t *testing.T, path, body string) *http.Response { + t.Helper() + req, err := http.NewRequest("POST", ts.URL+path, opaqueReader{strings.NewReader(body)}) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + return resp + } + + cases := []struct { + name string + path string + body string + wantCode int + }{ + {"decode within the cap", "/api/decode", `{"hex":"0200"}`, http.StatusOK}, + {"decode over the cap", "/api/decode", padJSONTo(t, `{"hex":"0200"}`, maxDecodeBodyBytes+1), http.StatusRequestEntityTooLarge}, + {"batch within the cap", "/api/packets/observations", batchBody(t, 3), http.StatusOK}, + {"batch over the cap", "/api/packets/observations", padJSONTo(t, `{"hashes":[]}`, maxBatchObservationsBodyBytes+1), http.StatusRequestEntityTooLarge}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := post(t, tc.path, tc.body) + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if resp.StatusCode != tc.wantCode { + t.Fatalf("status: want %d, got %d (body %q)", tc.wantCode, resp.StatusCode, raw) + } + if resp.StatusCode == http.StatusRequestEntityTooLarge { + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type: want application/json, got %q", ct) + } + var payload struct { + Error string `json:"error"` + } + if err := json.Unmarshal(raw, &payload); err != nil || payload.Error == "" { + t.Errorf("413 body is not the shared error shape: %q (%v)", raw, err) + } + } + }) + } +} + +// The request side really is chunked, i.e. the server sees no Content-Length. +func TestChunkedRequestHasNoContentLength(t *testing.T) { + var seen int64 = -2 + var te []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.ContentLength + te = r.TransferEncoding + io.Copy(io.Discard, r.Body) + })) + defer srv.Close() + + req, err := http.NewRequest("POST", srv.URL, opaqueReader{strings.NewReader(`{"hex":"0200"}`)}) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + resp.Body.Close() + + if seen != -1 { + t.Errorf("server saw ContentLength %d, want -1 (unknown)", seen) + } + if len(te) != 1 || te[0] != "chunked" { + t.Errorf("server saw TransferEncoding %v, want [chunked]", te) + } +} + +// --- Documentation --- + +// The 413 has to be documented, not just implemented: the served OpenAPI spec +// must declare it on both routes, with the shared ApiError body. +func TestOpenAPIDocuments413OnBodyLimitedRoutes(t *testing.T) { + router := mux.NewRouter() + srv, _ := setupNoStoreServer(t) + srv.RegisterRoutes(router) + spec := buildOpenAPISpec(router, "test") + + paths, ok := spec["paths"].(map[string]interface{}) + if !ok { + t.Fatalf("spec has no paths object") + } + schemas, _ := spec["components"].(map[string]interface{})["schemas"].(map[string]interface{}) + if _, ok := schemas["ApiError"]; !ok { + t.Errorf("components/schemas is missing ApiError") + } + + for _, path := range []string{"/api/decode", "/api/packets/observations"} { + item, ok := paths[path].(map[string]interface{}) + if !ok { + t.Errorf("%s missing from the spec", path) + continue + } + op, ok := item["post"].(map[string]interface{}) + if !ok { + t.Errorf("%s has no post operation", path) + continue + } + resps, ok := op["responses"].(map[string]interface{}) + if !ok { + t.Errorf("%s post has no responses", path) + continue + } + resp, ok := resps["413"].(openAPIResponse) + if !ok { + documented := keysOf(resps) + sort.Strings(documented) + t.Errorf("%s post does not document a 413 (has %v)", path, documented) + continue + } + if resp.Description == "" { + t.Errorf("%s post 413 has no description", path) + } + media, ok := resp.Content["application/json"] + if !ok || media.Schema == nil || media.Schema.Ref != "#/components/schemas/ApiError" { + t.Errorf("%s post 413 does not reference ApiError, got %+v", path, resp.Content) + } + } +} + +// The caps are an external contract, so docs/api-spec.md has to name both of +// them and the 413 — a reader must not have to read the Go source to find out +// how big a body they may send. +func TestAPISpecDocumentsBodyLimits(t *testing.T) { + raw, err := os.ReadFile("../../docs/api-spec.md") + if err != nil { + t.Fatalf("read api-spec.md: %v", err) + } + doc := string(raw) + for _, want := range []string{ + "Request-body byte caps", + "`413` — Request body over the byte cap on `POST /api/decode` or `POST /api/packets/observations`", + fmt.Sprintf("| `POST /api/decode` | %d bytes | bytes received, before parsing | `413`", maxDecodeBodyBytes), + fmt.Sprintf("| `POST /api/packets/observations` | %d bytes | bytes received, before parsing | `413`", maxBatchObservationsBodyBytes), + fmt.Sprintf(`{ "error": "request body too large (max %d bytes)" }`, maxDecodeBodyBytes), + fmt.Sprintf(`{ "error": "request body too large (max %d bytes)" }`, maxBatchObservationsBodyBytes), + "## POST /api/packets/observations", + } { + if !strings.Contains(doc, want) { + t.Errorf("docs/api-spec.md does not document %q", want) + } + } +} + +// The caps table has to describe the endpoints it lists as they actually +// behave: the right route names, the right caps, and the right status code — +// including the endpoints that answer 400 rather than 413. A table that +// overclaims is worse than no table, because a client trusts it. +func TestAPISpecCapsTableMatchesRealBehaviour(t *testing.T) { + raw, err := os.ReadFile("../../docs/api-spec.md") + if err != nil { + t.Fatalf("read api-spec.md: %v", err) + } + doc := string(raw) + + for _, want := range []string{ + // Endpoints capped before #334: still 400, and said so. + fmt.Sprintf("| `POST /api/paths/inspect` | %d bytes | the streaming decoder | `400`", inspectBodyLimit), + fmt.Sprintf("| `POST /api/channel-proposals` | %d bytes | the streaming decoder | `400`", maxProposalBodyBytes), + fmt.Sprintf("| `PUT /api/config/geo-filter` (API key) | %d bytes (1 MiB) | the streaming decoder | `400`", geoFilterBodyLimit), + // The one body-taking route with no cap at all. + "`POST /api/admin/prune-geo-filter?confirm=true`", + "**no limit at all**", + } { + if !strings.Contains(doc, want) { + t.Errorf("docs/api-spec.md does not document %q", want) + } + } + + // /api/path-inspect is not a route; it 404s. The real path is + // /api/paths/inspect. + if strings.Contains(doc, "/api/path-inspect") { + t.Errorf("docs/api-spec.md still names the non-existent route /api/path-inspect") + } +} + +// info.description is the first thing an API consumer reads. It must not claim +// a blanket 413 the implementation does not give (#334 review F2). +func TestOpenAPIDescriptionDoesNotOverclaim413(t *testing.T) { + router := mux.NewRouter() + srv, _ := setupNoStoreServer(t) + srv.RegisterRoutes(router) + spec := buildOpenAPISpec(router, "test") + + info, _ := spec["info"].(map[string]interface{}) + desc, _ := info["description"].(string) + if desc == "" { + t.Fatalf("spec info has no description") + } + for _, want := range []string{ + "POST /api/decode and POST /api/packets/observations cap their request body", + "413", + "bound their body on the JSON decoder instead", + "reports an over-cap body as 400", + } { + if !strings.Contains(desc, want) { + t.Errorf("info.description does not say %q; got %q", want, desc) + } + } + if strings.Contains(desc, "Endpoints that take a request body cap it in bytes before parsing it and answer 413") { + t.Errorf("info.description still claims every body-taking endpoint answers 413: %q", desc) + } +} + +// The summary is what shows up in the route list, so it must not contradict the +// description: this route returns observations, it does not submit them +// (#334 review F5). +func TestBatchObservationsSummaryMatchesBehaviour(t *testing.T) { + meta, ok := routeDescriptions()["POST /api/packets/observations"] + if !ok { + t.Fatalf("POST /api/packets/observations has no route metadata") + } + if strings.Contains(strings.ToLower(meta.Summary), "submit") { + t.Errorf("summary %q says the route submits observations; it returns them", meta.Summary) + } + if !strings.Contains(strings.ToLower(meta.Summary), "observations") { + t.Errorf("summary %q does not mention observations", meta.Summary) + } +} + +// --- The cap must bound what the handler READS, not just what it answers --- +// +// A handler that reads the whole body and only then checks its size answers +// exactly the same 413 as one that stops at the cap, so no status-code +// assertion can tell the two apart — and the second one is the memory +// regression #334 exists to prevent. The tests below therefore assert on the +// bytes the handler pulled off the body, which is the actual property. + +// errEndlessBodyHardStop aborts a runaway read instead of letting the test hang +// or allocate without bound. Reaching it is itself a failure signal. +var errEndlessBodyHardStop = errors.New("endless body hard stop reached") + +const ( + // endlessBodyHardStop is how far a handler is allowed to get before the + // body gives up on it. Orders of magnitude above both caps, so only a + // handler with no bound at all can reach it. + endlessBodyHardStop = 8 << 20 // 8 MiB + + // bodyReadSlack is the margin allowed on top of the cap. http.MaxBytesReader + // reads at most limit+1 bytes from the underlying body (it trims each Read + // to the remaining budget plus one byte, which is all it needs to know the + // limit was passed), so one page of slack is generous while still being + // ~2000x below endlessBodyHardStop. + bodyReadSlack = 4 << 10 +) + +// countingEndlessBody is a request body that never ends: a single '{' followed +// by spaces forever. It counts every byte it hands out. +type countingEndlessBody struct { + read int64 + started bool +} + +func (c *countingEndlessBody) Read(p []byte) (int, error) { + if c.read >= endlessBodyHardStop { + return 0, errEndlessBodyHardStop + } + if len(p) == 0 { + return 0, nil + } + n := len(p) + if remaining := endlessBodyHardStop - c.read; int64(n) > remaining { + n = int(remaining) + } + for i := 0; i < n; i++ { + p[i] = ' ' + } + if !c.started { + // Looks like the start of a JSON object, so a handler that streams the + // body into a decoder keeps asking for more rather than failing early. + p[0] = '{' + c.started = true + } + c.read += int64(n) + return n, nil +} + +func (c *countingEndlessBody) Close() error { return nil } + +// countingReader counts the bytes read from a finite body. +type countingReader struct { + r io.Reader + read int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.read += int64(n) + return n, err +} + +func (c *countingReader) Close() error { return nil } + +// bodyLimitedEndpoints is the set of routes decodeLimitedJSONBody guards, with +// the cap each one enforces. +func bodyLimitedEndpoints() []struct { + path string + limit int64 +} { + return []struct { + path string + limit int64 + }{ + {"/api/decode", maxDecodeBodyBytes}, + {"/api/packets/observations", maxBatchObservationsBodyBytes}, + } +} + +// An endless body must be cut off at the cap. A handler that buffers the whole +// request before measuring it would keep pulling until the hard stop. +func TestBodyLimitsStopReadingAtTheCap(t *testing.T) { + _, router := setupNoStoreServer(t) + + for _, ep := range bodyLimitedEndpoints() { + t.Run(ep.path, func(t *testing.T) { + body := &countingEndlessBody{} + req := httptest.NewRequest("POST", ep.path, body) + req.Header.Set("Content-Type", "application/json") + req.ContentLength = -1 // as a chunked request arrives + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if body.read > ep.limit+bodyReadSlack { + t.Errorf("handler read %d bytes of an endless body; the cap is %d (allowed at most %d). "+ + "The byte cap has to be enforced while reading, not checked after the body is already buffered.", + body.read, ep.limit, ep.limit+bodyReadSlack) + } + assertJSONError(t, w, http.StatusRequestEntityTooLarge) + }) + } +} + +// The same property on a finite body: a request far over the cap must not be +// drained in full before it is rejected. +func TestBodyLimitsDoNotDrainAnOversizedBody(t *testing.T) { + _, router := setupNoStoreServer(t) + + for _, ep := range bodyLimitedEndpoints() { + t.Run(ep.path, func(t *testing.T) { + raw := `{"x":"` + strings.Repeat("A", int(16*ep.limit)) + `"}` + body := &countingReader{r: strings.NewReader(raw)} + req := httptest.NewRequest("POST", ep.path, body) + req.Header.Set("Content-Type", "application/json") + req.ContentLength = int64(len(raw)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if body.read > ep.limit+bodyReadSlack { + t.Errorf("handler read %d bytes of a %d-byte body; the cap is %d (allowed at most %d)", + body.read, len(raw), ep.limit, ep.limit+bodyReadSlack) + } + assertJSONError(t, w, http.StatusRequestEntityTooLarge) + }) + } +} + +// --- Trailing data after the JSON body (#334 F3) --- +// +// Deliberate behaviour change: json.Unmarshal rejects anything but whitespace +// after the JSON value, while the json.Decoder.Decode these handlers used +// before silently ignored it. That matches decodeSingleJSONObject on +// POST /api/channel-proposals, and the frontend only ever sends +// JSON.stringify output, so nothing legitimate regresses. Pinned here so it +// stays a decision rather than drifting back. +func TestBodyLimitsRejectTrailingData(t *testing.T) { + _, router := setupNoStoreServer(t) + + cases := []struct { + name string + path string + body string + }{ + {"decode: junk after the object", "/api/decode", `{"hex":"0200"} trailing`}, + {"decode: a second JSON object", "/api/decode", `{"hex":"0200"}{"hex":"zz"}`}, + {"batch: junk after the object", "/api/packets/observations", `{"hashes":[]} junk`}, + {"batch: a second JSON object", "/api/packets/observations", `{"hashes":[]}{"hashes":[]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assertJSONError(t, postBody(router, tc.path, tc.body), http.StatusBadRequest) + }) + } + + // Trailing whitespace is valid JSON and must still be accepted — that is + // what the boundary tests pad with. + t.Run("trailing whitespace is still accepted", func(t *testing.T) { + w := postBody(router, "/api/decode", `{"hex":"0200"}`+"\n\t ") + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body %q)", w.Code, w.Body.String()) + } + }) +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index a5094c115..072537c3b 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1399,8 +1399,9 @@ func (s *Server) handleBatchObservations(w http.ResponseWriter, r *http.Request) var body struct { Hashes []string `json:"hashes"` } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, 400, "invalid JSON body") + // #334: cap the body in bytes before parsing it. maxHashes below is a + // cardinality limit that only applies once the JSON is already decoded. + if !decodeLimitedJSONBody(w, r, maxBatchObservationsBodyBytes, &body) { return } const maxHashes = 200 @@ -1538,8 +1539,8 @@ func (s *Server) handleDecode(w http.ResponseWriter, r *http.Request) { var body struct { Hex string `json:"hex"` } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, 400, "invalid JSON body") + // #334: cap the body in bytes before parsing it. + if !decodeLimitedJSONBody(w, r, maxDecodeBodyBytes, &body) { return } hexStr := strings.TrimSpace(body.Hex) @@ -4828,7 +4829,7 @@ func (s *Server) handlePruneGeoFilterStatus(w http.ResponseWriter, r *http.Reque // NaN/Inf; bufferKm finite, non-negative, ≤ 20000 km. Concurrent PUTs are // serialized via s.saveMu so they cannot race on the .tmp file. func (s *Server) handlePutConfigGeoFilter(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB cap + r.Body = http.MaxBytesReader(w, r.Body, geoFilterBodyLimit) var body struct { Polygon [][2]float64 `json:"polygon"` diff --git a/docs/api-spec.md b/docs/api-spec.md index 05c61f8e3..7c3578118 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -29,6 +29,7 @@ - [GET /api/packets](#get-apipackets) - [GET /api/packets/timestamps](#get-apipacketstimestamps) - [GET /api/packets/:id](#get-apipacketsid) +- [POST /api/packets/observations](#post-apipacketsobservations) - [POST /api/decode](#post-apidecode) - [GET /api/observers](#get-apiobservers) - [GET /api/observers/:id](#get-apiobserversid) @@ -186,9 +187,54 @@ They return `total` (the unfiltered/filtered count before pagination). - `400` — Bad request (missing/invalid params) - `404` — Resource not found, or an unrecognized `/api` or `/api/*` path - `405` — A known `/api/*` path called with an unsupported method; the response carries an `Allow` header listing the methods that path does support +- `413` — Request body over the byte cap on `POST /api/decode` or `POST /api/packets/observations` (other capped endpoints report an over-cap body as `400`; see below) `HEAD` is accepted on every path that accepts `GET` and returns the same status and headers without a body. +#### Request-body byte caps + +Every endpoint that takes a request body bounds it — with one exception, noted +below — and every bound is enforced by the application itself, not by a reverse +proxy in front of it. **Neither the enforcement nor the status code is +uniform**, so the table gives both per endpoint: + +| Endpoint | Cap | Enforced on | Over the cap | +|---|---|---|---| +| `POST /api/decode` | 4096 bytes | bytes received, before parsing | `413` `{"error":"request body too large (max 4096 bytes)"}`, `application/json` | +| `POST /api/packets/observations` | 65536 bytes | bytes received, before parsing | `413` `{"error":"request body too large (max 65536 bytes)"}`, `application/json` | +| `POST /api/paths/inspect` | 4096 bytes | the streaming decoder | `400` `{"error":"invalid JSON"}`, served as `text/plain` | +| `POST /api/channel-proposals` | 1024 bytes | the streaming decoder | `400` `{"error":"invalid request body"}`, `application/json` | +| `PUT /api/config/geo-filter` (API key) | 1048576 bytes (1 MiB) | the streaming decoder | `400` `{"error":"invalid JSON"}`, `application/json` | + +**Enforced on bytes received, before parsing** (the two rows issue #334 +covers) is the strict reading: the verdict depends only on how many bytes +arrived. It does not depend on the body's shape — bulk sitting in an unknown +field, or in a string the endpoint never reads, is rejected the same way — and +it does not depend on `Content-Length`, so a chunked request that declares no +length, or one that understates it, is cut off at the same byte count. A body +of exactly the cap is accepted; one byte more is `413`, and the server stops +reading there instead of buffering the rest. + +**Enforced on the streaming decoder** is weaker: the limit wraps the body and +the JSON decoder reads through it, so the limit only bites when the JSON +*value* runs past it. A body that is over the limit only because of data that +follows the value is still accepted — for example a 6019-byte +`POST /api/paths/inspect` body whose JSON object ends at byte 19 answers `200` +despite the 4096-byte limit. When the limit does bite, the decoder reports a +parse failure, which is why these rows answer `400` rather than `413`. + +Three gaps are known and deliberately not changed by #334, because each is an +API-visible change with its own clients to check: + +- the three streaming rows are not enforced on bytes received; +- they are not aligned on `413` + the shared error shape; +- `POST /api/admin/prune-geo-filter?confirm=true` (API key) decodes a JSON body + with **no limit at all**. + +Caps are deliberately well above anything a legitimate client sends (see each +endpoint below), so a per-endpoint semantic limit — such as the 200-hash limit +on `POST /api/packets/observations` — is what a real client meets first. + --- ## GET /api/stats @@ -1323,6 +1369,12 @@ Decode a raw packet without storing it. } ``` +Capped at **4096 bytes** before parsing (see +[Request-body byte caps](#request-body-byte-caps)). The largest frame the decoder +can accept is 1 header + 4 transport codes + 1 path-length byte + +`MAX_PATH_SIZE` (64) path bytes + `MAX_PACKET_PAYLOAD` (184) payload bytes = +254 bytes, i.e. 508 hex characters, i.e. a ~520-byte body — roughly 7x headroom. + ### Response `200` ```jsonc @@ -1341,6 +1393,62 @@ Decode a raw packet without storing it. { "error": "hex is required" } ``` +### Response `413` + +```json +{ "error": "request body too large (max 4096 bytes)" } +``` + +--- + +## POST /api/packets/observations + +Return the stored observations for several packets in one call. Used by the +packets table when a non-observer sort needs the child observations of the +groups it is about to render. + +### Request Body + +```jsonc +{ + "hashes": string[] // required — content hashes, at most 200 +} +``` + +Capped at **65536 bytes** before parsing (see +[Request-body byte caps](#request-body-byte-caps)). A full 200-hash request +serializes to ~3.8 KB, so the byte cap leaves roughly 17x headroom and the +200-hash limit below is what a client actually meets first. + +### Response `200` + +```jsonc +{ + "results": { + "": [ Observation, ... ] // one entry per requested hash + } +} +``` + +An empty `hashes` array returns `{"results": {}}`. A hash with no stored +observations gets an empty array. + +### Response `400` + +```json +{ "error": "too many hashes (max 200)" } +``` + +```json +{ "error": "invalid JSON body" } +``` + +### Response `413` + +```json +{ "error": "request body too large (max 65536 bytes)" } +``` + --- ## GET /api/observers