Skip to content

feat(runtime): media DoS controls + raise body cap to 32 MiB for images (#255) - #532

Merged
initializ-mk merged 2 commits into
mainfrom
feat/multimodal-dos-controls
Sep 25, 2026
Merged

initializ-mk merged 2 commits into
mainfrom
feat/multimodal-dos-controls

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Part of #255. Phase 2 (#531) admitted images but kept the inbound-body cap at 2 MiB, because — per the #527 review — "a flat MaxBytesReader alone 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):

Bound Limit On breach
Per-image bytes 5 MiB (MaxImagePartBytes) 4xx image_limit_exceeded
Decoded dimensions 50 MP (MaxImagePixels) 4xx image_limit_exceeded
Images per message 20 (MaxImagePartsPerMessage) 4xx too_many_image_parts
Concurrent media requests 4 (maxConcurrentMediaRequests) 429/unavailable — shed, not queued
  • Decode-dimension bound uses header-only image.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.
  • Concurrency semaphore (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. checkInboundMedia now iterates Parts (to see the bytes), adds the two new reason codes, and keeps the loud-reject contract.

Tests

  • CheckImageLimits matrix: oversized bytes, decompression-bomb dimensions (forged-IHDR PNG: ~30 bytes, 1e10 px → rejected), malformed png, webp byte-cap-only.
  • Gate matrix extended: oversized / bomb / too-many rejected; valid image accepted; existing accept/reject/document cases still hold.
  • acquireMediaSlot: fills N slots, sheds the (N+1)th, frees on release, no-op for non-media and nil semaphore.
  • 32 MiB transport cap test updated. gofmt, go test (core + cli runtime + server), golangci-lint 0 issues, no go.work.sum churn.

Docs

runtime-engine.md (DoS-bounds table), audit-logging.md (new reason codes + load-shedding note), forge.md skill (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.

…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 initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 after ok, 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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Fixed in b2fd8df — added the overflow-safe per-dimension bound.

CheckImageLimits now checks a per-side bound (maxImageDim = 100_000) before the int64(cfg.Width)*int64(cfg.Height) product, so the multiply can't overflow regardless of the decoder.

One thing I verified while fixing it: Go's image/png DecodeConfig already errors on dims that would overflow — width=height=2^31-1 → png: unsupported feature: dimension overflow, 2^32-1 → png: invalid format: non-positive dimension (int32 wrap). So the specific wrap-to-negative input is caught by the decoder as malformed today. But you're right that the check shouldn't rely on the decoder's internal cap — the per-side bound makes CheckImageLimits self-sufficient.

It also closes a real gap the product-only check missed independent of overflow: a pathological single dimension like 100001x1 is only ~100 K pixels, so the old width*height > 50M check would have accepted it — now the per-side bound rejects it.

Tests added (all via DecodeConfig-returned dims, so they exercise the new bound, not just the malformed path):

  • 100001x1 — product alone would pass; per-side bound rejects (cites per-side).
  • 200000x200000 — DecodeConfig returns these (verified); per-side bound fires before the product multiply.
  • 2^32-1 square — rejected (as malformed by the decoder; defense-in-depth assertion).

Docs updated (the dimension row now states the 100 K/side + 50 MP total bounds). Full sweep green: go test ./runtime/, golangci-lint 0 issues.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_000 checked 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×1 product-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 DecodeConfig bomb 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() after ok, 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).

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