Skip to content

fix(http,security)!: harden serve_with_limits accept loop against #525/#526 - #527

Merged
bug-ops merged 1 commit into
mainfrom
fix/525-serve-limits-hardening
Aug 19, 2026
Merged

fix(http,security)!: harden serve_with_limits accept loop against #525/#526#527
bug-ops merged 1 commit into
mainfrom
fix/525-serve-limits-hardening

Conversation

@bug-ops

@bug-ops bug-ops commented Aug 19, 2026

Copy link
Copy Markdown
Owner

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:

  • serve_with_limits: no http2 max_concurrent_streams cap, header_read_timeout is http1-only #525 — the h2 builder had no max_concurrent_streams cap and header_read_timeout only applied to h1. During the audit, the actual unguarded gap turned out to be upstream of both: hyper-util's auto::Builder sniffs the h1/h2 preface (ReadVersion) before any protocol builder engages, with no timer at all — a connection sending zero bytes was bounded only by max_connection_duration (300s), not header_read_timeout (10s default), for either protocol. Fixed by gating TokioIo::new(stream) behind a tokio::time::timeout-wrapped stream.readable() wait inside the spawned task (readiness-only, consumes no bytes). Also tightened max_concurrent_streams below hyper's own default of 200 via a new ConnectionLimits field.
  • serve_with_limits: residual DoS via accept()-level backpressure and 1024x300s slot budget #526 — a single source completing headers then slow-reading could hold a connection-pool permit for the full 300s ceiling, needing only ~3.4 conn/s to exhaust the 1024-slot budget, with exhaustion presenting as legitimate clients hanging in the accept backlog. Mitigated (not fully closed — a distributed attack from >=16 source IPs still reproduces the filed symptom, which requires infrastructure the issue explicitly scoped out) with a new per-IP accept-level cap, reusing the existing WebSocketRateLimiter as a private instance (never sharing state with any RateLimitMiddleware the 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::RateLimitGuard no longer derives Clone. It implements Drop to decrement a connection counter, so a clone would silently over-decrement it. The one existing caller already wraps it in Arc instead 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: None was 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 --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo 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-features
  • RUSTFLAGS="-D warnings" RUSTDOCFLAGS="--deny rustdoc::broken_intra_doc_links" cargo doc --no-deps -p pjson-rs -p pjson-rs-domain
  • 6 new unit tests on accept_rate_limit_key covering IPv6 /64 masking, IPv4-mapped normalization, and loopback passthrough

Known coverage gaps (documented, judged acceptable): the h2 max_concurrent_streams cap 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-level CapacityExceeded fail-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

…#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
@github-actions github-actions Bot added documentation Documentation updates, README, guides core Changes to pjs-core crate infrastructure Infrastructure layer changes (Clean Architecture) security Security issues or improvements testing Test coverage improvements, new test cases rust Rust language specific changes labels Aug 19, 2026
@bug-ops
bug-ops enabled auto-merge (squash) August 19, 2026 00:53
@github-actions

Copy link
Copy Markdown

WASM Bundle Size Report

Target Raw (KB) Gzipped (KB) Status
web 154 69 PASS
nodejs 154 69 PASS
bundler 154 69 PASS

@bug-ops
bug-ops merged commit d37d0eb into main Aug 19, 2026
54 checks passed
@bug-ops
bug-ops deleted the fix/525-serve-limits-hardening branch August 19, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Changes to pjs-core crate documentation Documentation updates, README, guides infrastructure Infrastructure layer changes (Clean Architecture) rust Rust language specific changes security Security issues or improvements testing Test coverage improvements, new test cases

Projects

None yet

1 participant