Skip to content

feat(oci): body-size cap on all push paths (DoS hardening, stacked on #84)#85

Closed
jamestexas wants to merge 1 commit into
feat/build-cache-v1-conformance-667ea6from
feat/oci-body-size-cap
Closed

feat(oci): body-size cap on all push paths (DoS hardening, stacked on #84)#85
jamestexas wants to merge 1 commit into
feat/build-cache-v1-conformance-667ea6from
feat/oci-body-size-cap

Conversation

@jamestexas
Copy link
Copy Markdown
Contributor

Stacked on #84. Merge #83#84 → this.

Closes the highest-priority finding from cloister-667ea6's adversarial review (dos-resilience-auditor):

push paths buffer unbounded body before any size check — single request can exhaust the V8 isolate heap; cross-tenant vector via the singleton BlobStore.

Approach

Two-layer defense:

  1. Cheap header check — rejects on the client's Content-Length header before arrayBuffer() runs. Standard clients send this; over-cap requests reject in ms with zero buffering cost.
  2. Post-buffer actual-size check — catches clients that omit Content-Length or spoof it. Same 413 PAYLOAD_TOO_LARGE response.

Both checks applied at all 5 push call sites:

  • Monolithic POST .../blobs/uploads/?digest=...
  • Chunked-begin POST .../blobs/uploads/ (optional seed body)
  • PATCH .../blobs/uploads/<uuid>cumulative check on (session bytes so far + this chunk) so paced PATCHes can't grow a session past the cap
  • Chunked-finalize PUT .../blobs/uploads/<uuid>?digest=... — also cumulative
  • PUT .../manifests/<reference>

Configuration

Per-instance via new OciRegistryRoute({ maxBlobBytes }). Default 256 MiB — fits realistic mache .db chunks and OCI image layers while keeping one push under workerd's per-isolate heap budget. Env-binding override surface deferred (separate follow-up).

Test plan

  • 5 new cases in test/routes/oci-registry-push.test.ts §"body-size cap":
    • monolithic body > cap → 413
    • Content-Length header > cap (small actual body) → 413 (header-only fast path)
    • chunked PATCH cumulative > cap → 413
    • manifest PUT body > cap → 413
    • body exactly at cap → 201 (boundary inclusive)
  • task lint → 1141/1141 tests pass (was 1136, +5 body-cap)

What this does NOT close from cloister-667ea6

  • cloister-7c0a0b (P0, architectural) — cross-tenant blob/manifest disclosure on pull paths. Needs per-repo membership index design pass.
  • cloister-7e631b (P1) — BlobStore.put substrate-level verification when key provided. Smaller; could land soon.
  • Upload-session GC, dual-digest error leak, TrustStore.upsertRegistryTag validation — recorded as comments on cloister-667ea6.

🤖 Generated with Claude Code

…dening for cloister-667ea6)

Closes the highest-priority finding from cloister-667ea6 adversarial
review (dos-resilience-auditor): push paths called arrayBuffer()
unconditionally, with no Content-Length precheck and no size cap. A
single push of an N-byte body materialized ~3N bytes in isolate
memory before any hashing ran, exposing the shared isolate to
cross-tenant memory-exhaustion attacks.

Approach (two-layer defense):

1. **Cheap header check** (checkContentLengthHeader): rejects on the
   client's Content-Length header alone, BEFORE arrayBuffer() runs.
   Standard clients send this; if it claims > cap, return 413
   PAYLOAD_TOO_LARGE (OCI SIZE_INVALID) without paying any
   buffering cost.

2. **Post-buffer actual-size check** (checkActualSize): catches
   clients that omit Content-Length or spoof it under the cap then
   send more. Returns the same 413.

Both checks applied at all five push call sites in OciRegistryRoute:
  - Monolithic POST .../blobs/uploads/?digest=...  (line 472)
  - Chunked-begin POST .../blobs/uploads/ optional seed body (line 494)
  - PATCH .../blobs/uploads/<uuid> — CUMULATIVE size check on
    (session.size + chunk.byteLength), so paced PATCHes can't grow
    a session past the cap incrementally  (line 540)
  - Chunked-finalize PUT .../blobs/uploads/<uuid>?digest=... — also
    cumulative on trailing body  (line 591)
  - Manifest PUT .../manifests/<reference>  (line 645)

Per-instance configurable via `new OciRegistryRoute({ maxBlobBytes })`,
default 256 MiB (DEFAULT_MAX_BLOB_BYTES). The default fits realistic
mache .db chunks and OCI image layers while keeping one push under
the workerd per-isolate heap budget. Env-binding override surface
deferred to a follow-up bead.

Tests: 5 new cases in oci-registry-push.test.ts §"body-size cap":
- monolithic body > cap → 413
- Content-Length header > cap (small actual body) → 413
- chunked PATCH cumulative > cap → 413
- manifest PUT body > cap → 413
- body exactly == cap → 201 (boundary inclusive)

Verification: task lint → 1141/1141 tests pass (was 1136, +5 body-cap).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jamestexas
Copy link
Copy Markdown
Contributor Author

Superseded by #90 (consolidated build-cache/v1 storage-key + hardening). Closing to reduce review load.

@jamestexas jamestexas closed this May 24, 2026
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.

1 participant