fix(http,security)!: harden serve_with_limits accept loop against #525/#526 - #527
Merged
Conversation
…#526 Closes an unguarded hyper-util auto::Builder preface-sniff phase that let a zero-byte connection ride out the full max_connection_duration (300s) instead of the much tighter header_read_timeout, by gating TokioIo::new behind a readiness-only stream.readable() timeout inside the spawned task. Tightens the h2 max_concurrent_streams cap below hyper's own default and adds a per-IP accept-level connection cap, reusing the existing WebSocketRateLimiter (private instance, never shared with RateLimitMiddleware) keyed on the peer IP masked to its IPv6 /64, with IPv4-mapped and loopback addresses normalized first so distinct IPv4 clients on a dual-stack listener don't collapse onto one shared budget. Rejected connections release their semaphore permit before any task is spawned, mitigating (not fully closing, since >=16 source IPs still reproduce the filed symptom) the accept()-level backpressure DoS in #526. BREAKING CHANGE: security::rate_limit::RateLimitGuard no longer derives Clone. It implements Drop to decrement a connection counter; a clone would silently over-decrement it. The one existing caller already wraps it in Arc instead of cloning directly. Closes #525 Mitigates #526
bug-ops
enabled auto-merge (squash)
August 19, 2026 00:53
WASM Bundle Size Report
|
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.
Summary
Follow-up hardening of
serve_with_limits(crates/pjs-core/src/infrastructure/http/serve.rs), addressing two P2 gaps found in an adversarial audit of PR #524:max_concurrent_streamscap andheader_read_timeoutonly applied to h1. During the audit, the actual unguarded gap turned out to be upstream of both: hyper-util'sauto::Buildersniffs the h1/h2 preface (ReadVersion) before any protocol builder engages, with no timer at all — a connection sending zero bytes was bounded only bymax_connection_duration(300s), notheader_read_timeout(10s default), for either protocol. Fixed by gatingTokioIo::new(stream)behind atokio::time::timeout-wrappedstream.readable()wait inside the spawned task (readiness-only, consumes no bytes). Also tightenedmax_concurrent_streamsbelow hyper's own default of 200 via a newConnectionLimitsfield.WebSocketRateLimiteras a private instance (never sharing state with anyRateLimitMiddlewarethe router applies). Keyed on the peer IP masked to its IPv6 /64, with IPv4-mapped and loopback addresses normalized before masking so distinct IPv4 clients behind a dual-stack listener don't collapse onto one shared budget — an adversarial review round caught this as a new DoS the first pass of the fix would have introduced.Breaking change
security::rate_limit::RateLimitGuardno longer derivesClone. It implementsDropto decrement a connection counter, so a clone would silently over-decrement it. The one existing caller already wraps it inArcinstead of cloning directly (verified via workspace-wide grep).Process
Went through a full security-review chain: audit -> implement -> adversarial critique -> test coverage -> critique found 2 blocking gaps (S1:
max_concurrent_streams: Nonewas forwarding as "unlimited" instead of preserving hyper's default; S2: the IPv6 masking bug above) -> rework -> re-verification (independently re-derived against upstream hyper/hyper-util sources, not just re-running tests) -> code review (re-ran full check suite at final HEAD). Both issues were escalated P3 -> P2 during the audit based on a corrected/expanded understanding of the attack surface.Test plan
cargo +nightly fmt --checkcargo clippy --workspace --all-targets --all-features -- -D warningscargo nextest run --workspace --all-features --lib --bins(1079/1079)cargo nextest run -p pjson-rs --all-features --test http_serve_connection_limits(9/9, incl. 3 new integration tests: per-IP cap rejection, permit-non-leak on rejection, zero-byte preface-read-gate closure)cargo test --workspace --doc --all-featuresRUSTFLAGS="-D warnings" RUSTDOCFLAGS="--deny rustdoc::broken_intra_doc_links" cargo doc --no-deps -p pjson-rs -p pjson-rs-domainaccept_rate_limit_keycovering IPv6 /64 masking, IPv4-mapped normalization, and loopback passthroughKnown coverage gaps (documented, judged acceptable): the h2
max_concurrent_streamscap has no direct test (would need an h2 client reading the SETTINGS frame — no such dependency exists in this crate, out of scope to add for a one-line config call verified against upstream source); the accept-levelCapacityExceededfail-open path has no direct test (triggering it needs 100k distinct tracked IPs; the underlying limiter's fail-closed behavior at that scale is already covered elsewhere).Closes #525
Mitigates #526