Skip to content

api: ingest decodes the body before validating it, so one request can allocate ~20x the body cap #564

Description

@EricAndrechek

What

A single POST /v1/ingest request can allocate ~20× its wire size as live heap, because the body is decoded into a map[string]any before anything checks the schema. The 16 MiB request-body cap bounds the wire size, not the decoded value.

This is pre-existing — it is not introduced by #553. main decodes the identical map from the stream. It is filed separately from #544 because that issue bounds the total across concurrent requests; this is about the multiplier on one request, which no cap currently touches.

Why the cap doesn't bound it

objectReader.Next decodes into map[string]any with whatever keys arrived (internal/api/record_reader.go:158). The only thing that knows the table's columns is h.validator().Validate, first reached at internal/api/ingest.go:448 — inside processRecord, with the whole map already materialized. So "a flat ingest row's key count is bounded by the table's columns" is true of a row that will be accepted, and irrelevant to peak memory: an unknown column is rejected only after the allocation that matters.

Measured

Live heap after runtime.GC(), using the handler's own decoder settings (json.Decoder + UseNumber() into map[string]any), 16 MiB bodies. Reproduced independently three times (once by me, once by each of two reviewers) with agreeing figures:

body shape live heap ratio
flat object, ~1.4M one-byte keys 114–163 MiB 7.1–10.3×
one array-valued key {"a":[1,1,…]} 286 MiB 17.9×
{"a":[{},…]} 357 MiB 22.3×
{"a":[{"b":"x"},…]} 590 MiB 36.9×
benign: one 16 MiB string value 16 MiB 1.0×

Add the ~32 MiB buffer (a body above ~16 MiB − 512 B lands in a 32 MiB allocation) and one request peaks around 320 MiB against a 16 MiB cap. The tail is unbounded — nesting goes higher.

Total-allocation churn is worse than the live figures: 980 MiB for the array-valued-key case, which is GC pressure rather than resident bytes but still real.

Reachability

deployments/compose/settings/policies.json ships default_role: public with insert.allow_columns: ["*"] on clicks and events, so the shape needs no credential in the shipped demo configuration. Auth and policy both gate ahead of the body read, so a deployment that does not grant public insert is not exposed this way — but the shipped one is.

Options

Not proposing a specific fix; roughly in increasing cost:

  1. Bound the decoded value rather than the wire size — e.g. a key-count or depth limit applied during decode, or json.Decoder.Token() streaming for the single-object path so unknown keys are dropped before they are stored.
  2. Validate columns during decode instead of after, so an unknown key is rejected as it is read. This is the structural fix and it fits the type-layer direction, since the type layer needs to know the column set anyway.
  3. Tighten the shipped compose policy so public does not carry insert, which shrinks the unauthenticated surface without changing the multiplier.
  4. Document only. Already done as of c807b460docs/src/content/docs/reverse-proxy.mdx now tells operators to size for "at least 10×" and names the ~18× array-valued-key case. That is a mitigation for someone who reads it, not a fix.

Notes

Documented behavior as of #553; see the sizing note under Request-body size limits in the reverse-proxy guide. Related: #544 (total in-flight bytes).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/apiHTTP handlers, routing, middlewarearea/ingestIngest pipeline (Bento, batching, DLQ)securitySecurity-sensitive issue or fix

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions