fix(server): derive the http body cap from the attachment ceiling - #75
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, thetransport 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)2 MiB default behave exactly as before;
0("no policy cap") stays at thefloor rather than becoming an unbounded body.
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
0decision; both are documented.http_server_configis now a method onBugWarden, so the transportconfig reads the same guard the server enforces with — they cannot
disagree by construction, and the integration harness exercises the same
extraction
mainuses.All three #52 acceptance criteria are satisfied, including the third: a 413
is documented as unrecordable (never reaches
call_tool; samepre-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_bytestousize::MAX: an effectively uncapped POST bodybuffered 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 wholesuite — 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 floordivision 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 keepout 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_attachmentnon-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_configis no longera 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 thereview fixes landed.