feat(runtime): media DoS controls + raise body cap to 32 MiB for images (#255) - #532
Conversation
…es (#255) Phase 2 admitted images but kept the body cap at 2 MiB because a flat MaxBytesReader alone isn't media DoS protection (per the #527 review). This lands the controls that make a large cap safe, then raises it. Controls (forge-core/runtime/media_limits.go + the ingest gate): - Per-image byte cap (5 MiB, MaxImagePartBytes) and per-message image count (20, MaxImagePartsPerMessage). - Decode-dimension bound (50 MP, MaxImagePixels) via header-only image.DecodeConfig — defuses decompression bombs (a tiny file reporting a gigapixel canvas). Covers png/jpeg/gif (stdlib); webp is bounded by the byte cap (no new dependency). A malformed image in a supported format is rejected. - Concurrency semaphore (Runner.mediaSem, cap 4): bounds concurrent media-bearing requests so peak media memory ≈ cap × body-cap. A request that can't get a slot is shed (JSON-RPC unavailable / HTTP 429), not queued. Wired into all four send handlers; non-media requests are never bounded. With the controls in place, the inbound-body cap is raised to 32 MiB on both transports (runtime.maxRequestBodyBytes + server.handleJSONRPC), so real images fit. checkInboundMedia now iterates Parts (to see bytes), adds reason codes too_many_image_parts / image_limit_exceeded, and keeps the loud-reject contract. Tests: CheckImageLimits matrix (oversized bytes, decompression-bomb dimensions via a forged-IHDR PNG, malformed, webp byte-cap-only); gate matrix extended (oversized/bomb/too-many rejected, valid image accepted); acquireMediaSlot concurrency bound. Docs: runtime-engine DoS-bounds table, audit reason codes, forge.md skill (synced).
initializ-mk
left a comment
There was a problem hiding this comment.
Self-review — verified against source ✅ one requested change
This lands the DoS controls that make the 32 MiB cap safe — posting as a COMMENT (own PR) with one overflow fix.
Verified good:
- Byte cap checked before decode; header-only
DecodeConfig(no pixel alloc) — right bomb-defense mechanism; per-message count limit; distinct reason codes (image_limit_exceeded/too_many_image_parts/model_not_vision_capable/unsupported_media_type). - Semaphore correct — non-blocking shed (not queued), no-op for non-media + nil sem,
defer release()only afterok, and acquired after the gate so rejected-media floods don't consume slots. All 4 handlers wired (JSON-RPC send/subscribe + REST send/subscribe). - Cap raise to 32 MiB is now safe — landed WITH the controls, per the #527/#255 guidance; parity across both transports.
Requesting one change (MEDIUM): the pixel-bomb check isn't overflow-safe — see inline. Small fix + a test; this is the central bomb defense so worth getting right.
| } | ||
| return "" | ||
| } | ||
| if int64(cfg.Width)*int64(cfg.Height) > MaxImagePixels { |
There was a problem hiding this comment.
Requesting change (MEDIUM): make this overflow-safe. int64(cfg.Width)*int64(cfg.Height) can overflow int64 for PNG (32-bit IHDR dims — JPEG/GIF are 16-bit, so safe). Go's png DecodeConfig reads IHDR as int(uint32), so on 64-bit it can return dims up to ~4.29e9; a forged tiny PNG declaring e.g. width=height=0xFFFFFFFF gives a product of ~1.84e19, which wraps to a negative int64 → > 50_000_000 is false → the bomb passes and gets forwarded to the provider. (Blast radius is limited — forge only reads the header, never decodes, so the provider's own limits are the backstop — but it defeats this control for crafted PNGs, and the check shouldn't rely on the decoder's internal caps.)\n\nFix — a per-dimension bound before the product:\ngo\nconst maxImageDim = 100_000 // product then can't overflow int64\nif cfg.Width > maxImageDim || cfg.Height > maxImageDim {\n return fmt.Sprintf("image dimension exceeds %d px", maxImageDim)\n}\nif int64(cfg.Width)*int64(cfg.Height) > MaxImagePixels { ... }\n\nor cfg.Width > 0 && cfg.Height > MaxImagePixels/cfg.Width. Please add a test with a forged-IHDR PNG whose width*height overflows int64 — distinct from the existing 1e10-px bomb test, which is positive and doesn't overflow, so it wouldn't catch this.
CheckImageLimits computed int64(cfg.Width)*int64(cfg.Height) directly. PNG carries 32-bit IHDR dimensions, so a forged image could report dims whose product overflows int64 and wraps negative, slipping past the > MaxImagePixels check. (Go's own DecodeConfig happens to error on such dims today, but the check shouldn't depend on the decoder's internal cap.) Add a per-side bound (maxImageDim = 100_000) enforced BEFORE the product, so the multiply can't overflow. This also closes a real gap the product-only check missed: a low-pixel but pathological single dimension (e.g. 100001x1) now rejects. Tests: single-oversized-dimension (product alone would pass), large-square dims returned by DecodeConfig, and a forged 2^32-1 dimension.
|
Fixed in
One thing I verified while fixing it: Go's It also closes a real gap the product-only check missed independent of overflow: a pathological single dimension like Tests added (all via
Docs updated (the dimension row now states the 100 K/side + 50 MP total bounds). Full sweep green: |
initializ-mk
left a comment
There was a problem hiding this comment.
Closing re-review — commit b2fd8dfb ✅ LGTM (with a correction to my earlier finding)
Correction first (my miss): my MEDIUM overflow finding overstated reachability. Go's png DecodeConfig reads IHDR dims as int32, so any value > 2^31-1 wraps negative and is rejected as malformed by the decoder itself — and the largest dims it does accept, (2^31-1)² ≈ 4.6e18, fit under int64 max (9.22e18). So the negative-wrap bypass I described wasn't actually reachable via the stdlib PNG path. I should have checked the decoder's dimension handling before rating it MEDIUM.
The fix is still a genuine net positive and correctly done, so worth keeping:
maxImageDim = 100_000checked per-side before the product →width*height ≤ 1e10, overflow-safe regardless of the decoder's internal cap (robust if a wider-dimension format decoder, e.g. webp, is registered later).- Adds independent coverage: a degenerate strip like
100_001 × 1(100K px, under the 50 MP cap) is now rejected — the product check alone would have accepted it. - Tests are genuine (the
100_001×1product-would-pass case, large-square, and forged-0xFFFFFFFF), and the author verified + documented the decoder's actual behavior rather than trusting my claim.
Everything on the PR checks out:
- Byte cap before decode; header-only
DecodeConfigbomb defense; per-message count; overflow-safe per-side + pixel bounds; distinct reason codes. - Concurrency semaphore: non-blocking shed, no-op for non-media,
defer release()afterok, acquired after the gate, all 4 handlers. - 32 MiB cap raise landed WITH the controls (per #527/#255), parity across transports.
Verdict: LGTM. Needs a second reviewer to merge (own PR).
Part of #255. Phase 2 (#531) admitted images but kept the inbound-body cap at 2 MiB, because — per the #527 review — "a flat
MaxBytesReaderalone is not media DoS protection." This PR lands the controls that make a large cap safe, then raises it to 32 MiB.Controls
forge-core/runtime/media_limits.go+ the ingest gate (checkInboundMedia):MaxImagePartBytes)image_limit_exceededMaxImagePixels)image_limit_exceededMaxImagePartsPerMessage)too_many_image_partsmaxConcurrentMediaRequests)429/unavailable — shed, not queuedimage.DecodeConfig(no pixel-buffer allocation), so it's cheap and defuses decompression bombs — a tiny file reporting a gigapixel canvas. Covers png/jpeg/gif (stdlib); webp is bounded by the byte cap so this adds no new dependency. A malformed image in a supported format is rejected.Runner.mediaSem, non-blocking) bounds peak media memory ≈cap × body-cap. Wired into all four send handlers; non-media requests are never bounded.Cap raise
With the controls in place, the inbound-body cap goes to 32 MiB on both transports (
runtime.maxRequestBodyBytes+server.handleJSONRPC), so real photos fit.checkInboundMedianow iteratesParts(to see the bytes), adds the two new reason codes, and keeps the loud-reject contract.Tests
CheckImageLimitsmatrix: oversized bytes, decompression-bomb dimensions (forged-IHDR PNG: ~30 bytes, 1e10 px → rejected), malformed png, webp byte-cap-only.acquireMediaSlot: fills N slots, sheds the (N+1)th, frees on release, no-op for non-media and nil semaphore.go test(core + cli runtime + server),golangci-lint0 issues, nogo.work.sumchurn.Docs
runtime-engine.md(DoS-bounds table),audit-logging.md(new reason codes + load-shedding note),forge.mdskill (synced).Follow-ups (later #255 phases)
Document input (Phase 3) · persist-inbound-for-tools (Phase 4) · model-generated image output (Phase 5). Cross-turn image replay still rides with Phase 4's disk persistence.