Skip to content

fix(server): derive the http body cap from the attachment ceiling - #75

Merged
plusky merged 1 commit into
mainfrom
fix/body-cap-52
Aug 8, 2026
Merged

fix(server): derive the http body cap from the attachment ceiling#75
plusky merged 1 commit into
mainfrom
fix/body-cap-52

Conversation

@plusky

@plusky plusky commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #52.

Two ceilings governed one thing without knowing about each other: the
transport's pinned 4 MiB POST cap and the operator's
global.max_attachment_bytes (decoded size). Above ~3 MiB configured, the
transport silently overrode the policy with a bare 413 — unrecordable, and
shaped like nothing else this server says.

The decided relationship

cap = clamp(ceil(max_attachment_bytes / 3) · 4 + 1 MiB, 4 MiB, 64 MiB)

  • 4 MiB floor — non-attachment traffic and every policy at or below the
    2 MiB default behave exactly as before; 0 ("no policy cap") stays at the
    floor rather than becoming an unbounded body.
  • 64 MiB ceiling — the memory bound holds for all policy values: a huge
    or fat-fingered cap clamps instead of deriving an effectively unbounded
    body. A decoded cap above ~47 MiB is therefore not honored over HTTP — the
    mirror of the 0 decision; both are documented.
  • http_server_config is now a method on BugWarden, so the transport
    config reads the same guard the server enforces with — they cannot
    disagree by construction, and the integration harness exercises the same
    extraction main uses.

All three #52 acceptance criteria are satisfied, including the third: a 413
is documented as unrecordable (never reaches call_tool; same
pre-handler class as an auth refusal, #32), with the operator diagnosis
path written down.

Adversarial review before opening (three independent lenses)

Run against the uncommitted diff; every finding fixed before this PR was
opened.

Security — found the blocker. The first implementation saturated a huge
max_attachment_bytes to usize::MAX: an effectively uncapped POST body
buffered pre-guard — an OOM lever the old pin made impossible, violating
the derivation's own docstring. Fixed with the 64 MiB ceiling; the u64-
extreme tests now assert the clamp, and each extreme exercises a different
saturating step.

Correctness — found the unpinned term. Every test input was divisible
by 3, so replacing div_ceil(3) with floor division survived the whole
suite — the one term the change exists to get right. Now pinned with an
exact-equality test at 3 MiB + 1 (must derive 4 bytes more than floor
division yields). The review also verified the formula is the exact
padded base64 length, not merely a bound, and that Bugzilla's own
65535-character comment limit keeps worst-case framing inside the 1 MiB
headroom.

Docs — found the oracle. The 413 boundary is observable pre-auth:
probing body sizes recovers the derived cap and, above the floor,
max_attachment_bytes — a value two DESIGN.md sections deliberately keep
out of refusal text. Now recorded as an accepted trade-off with the
reasons (constant floor at or below ~2.25 MiB including the default;
what leaks is a memory-tuning number, not bug data — I1/I2/I3 untouched;
network reach is the access control until #32), cross-referenced from the
add_attachment non-disclosure note.

Notes also addressed: wrapped-base64 headroom erosion documented in the
helper (canonical encoders unaffected), README no longer overclaims the
guarantee, the test-inventory arithmetic reads correctly, and no rustdoc
links a private item.

Invariants

No guard weakened. I2/I3 do not apply to a 413 (no bug id, no filtered
count); I9 untouched (no CLI flag reaches the cap); I15's one-record scope
is tool calls, which a 413 never becomes — DESIGN.md now says so where the
audit decisions live. The one API change: http_server_config is no longer
a free function (pre-1.0 library surface).

Verification

cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo clippy -p bugwarden --features gen --all-targets -- -D warnings, cargo test --workspace --all-targets --locked (406 passed,
0 failed
), cargo deny check — all green, run independently after the
review fixes landed.

The streamable-HTTP transport capped every POST body at a pinned 4 MiB
while global.max_attachment_bytes limits attachments on the decoded
size. Base64 expands by 4/3 plus framing, so an operator who raised the
attachment cap past ~3 MiB had uploads refused by the transport with a
bare 413 before the handler ran: the configured limit was silently
overridden, the refusal looked like nothing else bugwarden says, and no
audit record was written.

Size the transport from the policy instead. http_server_config is now a
method on BugWarden reading the same guard the server enforces with --
the config and the guard cannot disagree by construction -- and the cap
is derived as ceil(max_attachment_bytes/3)*4 plus 1 MiB of framing
headroom, clamped between a 4 MiB floor and a 64 MiB ceiling:

* the floor keeps non-attachment traffic and every policy at or below
  the 2 MiB default exactly where the old pin held them;
* the ceiling restores the memory bound for ALL policy values -- 0 ("no
  policy cap") stays at the floor, and a huge or fat-fingered cap
  clamps to 64 MiB instead of deriving an effectively unbounded body,
  which would hand any client that can reach the port an
  out-of-memory lever. A decoded cap above ~47 MiB is therefore not
  honored over HTTP, the mirror of the 0 decision, and both are
  documented.

A transport 413 remains unrecordable in the audit stream -- the request
never reaches call_tool, the same pre-handler class as an auth refusal
(#32) -- and DESIGN.md now says so, with the operator diagnosis path.
The derived boundary is also observable pre-auth: probing body sizes
recovers the cap, and with it max_attachment_bytes, once the derivation
exceeds the floor. Recorded as accepted -- below ~2.25 MiB (the default
included) the cap is the constant floor and discloses nothing, what
leaks is a memory-tuning number rather than bug data or a rule name,
and network reach is the access control until #32 -- with the
add_attachment non-disclosure note now pointing at the trade-off.

Tests pin the formula at the base64 quantum (a cap of 3 MiB + 1 must
derive 4 bytes more than floor division would), both clamp edges, both
saturating steps at the u64 extremes, and end to end over HTTP: a 5 MiB
body is admitted under a 6 MiB policy, refused past the derived cap,
and refused under the default policy where the floor still stands --
with no upstream request in any refused case.

Closes #52
@plusky
plusky merged commit 1ed15b4 into main Aug 8, 2026
11 checks passed
@plusky
plusky deleted the fix/body-cap-52 branch August 8, 2026 19:51
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.

Reconcile the streamable-HTTP body cap with global.max_attachment_bytes

1 participant