From 5755ef77fe31d5fbd864692a9212102c8d46717b Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 13 Aug 2026 19:40:52 +0000 Subject: [PATCH] feat(core): harden HTTP/2 abuse resistance --- README.md | 7 + flash/docs/http2/DECISIONS.md | 34 +- flash/docs/http2/IMPLEMENTATION-PLAN.md | 29 +- flash/docs/http2/SECURITY.md | 42 +++ .../http2/RollingWindowCounterBenchmark.java | 27 ++ .../flash/extension/FlashConfiguration.java | 28 ++ .../relism/flash/http2/Http2AbuseGuard.java | 114 +++++++ .../relism/flash/http2/Http2Connection.java | 63 +++- .../flash/http2/Http2HeaderBlockDecoder.java | 24 ++ .../dev/relism/flash/http2/Http2Limits.java | 21 ++ .../flash/http2/Http2StreamDispatcher.java | 25 +- .../flash/http2/RollingWindowCounter.java | 31 ++ .../flash/http2/stream/Http2Stream.java | 10 + .../flash/http2/stream/Http2StreamTable.java | 16 + .../relism/flash/http2/Http2AbuseTest.java | 309 ++++++++++++++++++ .../relism/flash/http2/Http2LimitsTest.java | 4 + .../flash/http2/RollingWindowCounterTest.java | 24 ++ .../http2/stream/Http2StreamTableTest.java | 15 + 18 files changed, 793 insertions(+), 30 deletions(-) create mode 100644 flash/docs/http2/SECURITY.md create mode 100644 flash/src/jmh/java/dev/relism/flash/http2/RollingWindowCounterBenchmark.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/Http2AbuseGuard.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/RollingWindowCounter.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/RollingWindowCounterTest.java diff --git a/README.md b/README.md index 5c0dec8..59a4940 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,13 @@ app.onException((ex, req, res) -> { | `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. | | `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. | | `http2Enabled` | `false` | Whether the server negotiates HTTP/2 through ALPN or accepts h2c prior knowledge. The conservative default keeps protocol rollout explicit. | +| `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. | +| `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. | +| `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. | +| `h2MaxStreamsPerConnection` | `100000` | Total stream budget; `0` disables it. | +| `h2MaxBytesPerConnection` | `0` | Optional total wire-byte budget; `0` disables it. | +| `h2MaxConnectionLifetimeMs` | `0` | Optional connection lifetime; `0` disables it. | +| `h2StreamIdleTimeoutMs` | `60000` | Inactive open-stream deadline. | ## TLS diff --git a/flash/docs/http2/DECISIONS.md b/flash/docs/http2/DECISIONS.md index 5a59eb0..bbad296 100644 --- a/flash/docs/http2/DECISIONS.md +++ b/flash/docs/http2/DECISIONS.md @@ -982,21 +982,43 @@ window and pool byte capacity together; never raise credit independently of boun --- -## DEC-29 — Keep HTTP/2 opt-in until the adversarial phase is complete +## DEC-29 — Keep HTTP/2 opt-in through the cleartext rollout boundary **Context.** Trailers and push streaming make the protocol feature-complete for ordinary and gRPC- shaped traffic, but the dedicated rate-based and composite abuse controls are deliberately owned by the following security phase. -**Decision.** Keep `FlashConfiguration.http2Enabled` defaulting to `false` during this phase. -Applications can enable the complete path explicitly; the default changes only after the hostile- -peer suite and its limits are green. +**Decision.** Keep `FlashConfiguration.http2Enabled` defaulting to `false`. Applications can +enable the complete path explicitly. The Phase 13 hostile-peer suite is now green, but the same +flag currently also admits cleartext prior-knowledge traffic; Phase 14 owns splitting that into a +separate `http2CleartextEnabled` opt-in before the general protocol default can change safely. **Consequence.** Existing deployments do not silently expose a newly completed protocol before its adversarial gate. This is rollout sequencing, not an architectural separation: both protocols use the same public request/response, header, trailer and streaming APIs. -**Revisit when.** At Phase 13 closure; either flip the default with evidence or record why it must -remain opt-in. +**Revisit when.** At Phase 14 closure, after TLS HTTP/2 and cleartext h2c have independent rollout +controls. + +--- + +## DEC-30 — Rate-limit aggregate non-progress work as one class + +**Context.** SETTINGS, PING, PRIORITY, WINDOW_UPDATE, empty DATA and unknown extension frames have +different wire semantics but share the abuse property that they can consume parser/control work +without advancing an application message. Separate limits leave gaps when an attacker alternates +frame types below every individual threshold. + +**Decision.** Keep dedicated lower limits for mandatory SETTINGS and PING replies, plus one +connection-owned two-bucket counter for the aggregate non-progress class. RST_STREAM and stream +creation retain dedicated CVE-2023-44487 counters because their expensive effect is stream +lifecycle churn, not merely frame parsing. + +**Consequence.** Mixed floods are bounded without six timers or maps. All counters are fixed fields +on the connection, use `System.nanoTime()`, allocate nothing per increment and require no reaper +thread. A fixed control-intent pool and one-in-flight intent per live stream bound write queues. + +**Revisit when.** Production telemetry shows legitimate control-heavy traffic approaching the +aggregate default; tune the threshold from evidence without splitting the defence by frame type. --- diff --git a/flash/docs/http2/IMPLEMENTATION-PLAN.md b/flash/docs/http2/IMPLEMENTATION-PLAN.md index 4d7b03d..efcce89 100644 --- a/flash/docs/http2/IMPLEMENTATION-PLAN.md +++ b/flash/docs/http2/IMPLEMENTATION-PLAN.md @@ -74,7 +74,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`. | 10 — Stream state machine + dispatch | done | `feature/core/http2` | Explicit stream transition table, bounded primitive stream table and pool, pseudo-header/message validation, protocol-neutral `Request` assembly, virtual-thread dispatch and exception path, cancellation-safe release, raw h2c + Java HTTP/2 integration. Phase 11 closed the two deferred content-length/DATA cases; h2spec sections 5/8 are now 39/39. JMH pooled lifecycle: 458.499 ns/op, 0.003 B/op, no GC. 618/618 tests green at phase closure. | | 11 — DATA, flow control, bodies | done | `feature/core/http2` | Two-level receive/send flow control, consumption-driven WINDOW_UPDATE hysteresis, bounded/coalescing DATA pool, inline and blocking streaming request bodies through the existing `RequestBody`, resumable fixed/known/unknown response streams, content-length and empty-DATA validation. Real TLS HTTP/2 transfer: 100 MiB upload + 100 MiB download verified byte-for-byte. h2spec combined sections 5, 6.1, 6.9 and 8: 50 passed, 1 tool-skipped, 0 failed. JMH: inline materialization exactly one 1,040-byte array; request streaming 0.001 B/op; response streaming 0.002 B/op; full pooled lifecycle 0.003 B/op. 633/633 tests green from a clean `-Pjmh` build. | | 12 — Trailers, half-close, gRPC | done | `feature/core/http2` | Protocol-neutral request/response trailers, bounded push streaming, four half-close orderings and authority-form CONNECT tunnels complete. Real grpcurl 1.9.3 unary/server-streaming/error interop passes. EX-48/49 fixed. HTTP/2 remains opt-in until the Phase 13 hostile-peer gate (DEC-29). 649/649 tests green from a clean `-Pjmh` build. | -| 13 — Security hardening & abuse resistance | not started | — | — | +| 13 — Security hardening & abuse resistance | done | `feature/core/http2` | Two-bucket Rapid Reset/stream/settings/ping/aggregate counters, control/write queue bounds, optional stream/byte/lifetime budgets, absolute header and idle-stream deadlines, and hostile-peer suite complete. Security review found+fixed EX-50/51. JMH counter: 38.083 ns/op, ~10^-4 B/op, no GC. 100k-CONTINUATION attack terminates in under 2 s with bounded retained heap. 663/663 tests green from a clean `-Pjmh` build. | | 14 — h2c prior knowledge + proxy support | not started | — | — | | 15 — RFC 8441 extended CONNECT (WS over h2) | not started | — | — | | 16 — Compliance test suite | not started | — | — | @@ -794,6 +794,25 @@ targets and HTTP/2 `:authority` arrive without that prefix, so the existing CONN never match its documented target. **Fix**: normalize CONNECT authority targets separately in the shared router registration path and verify a live bidirectional HTTP/2 tunnel. **Phase**: 12. +### EX-50 — Declared HTTP/2 header and stream idle deadlines were not enforced + +Found during the whole-package hostile-peer review. `HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS` and +`STREAM_IDLE_TIMEOUT_MS` existed in `Http2Limits` and were described as enforced defences, but no +production path read either constant. A peer could retain a CONTINUATION assembly or an open +stream indefinitely. **Fix**: give header assembly an absolute non-renewable deadline checked on +frames and read wakeups; track per-stream activity and cancel idle streams with `RST_STREAM +CANCEL`; expose the stream deadline operationally and add deadline regression tests. **Phase**: 13. + +### EX-51 — Concurrent half-close could retire the same pooled HTTP/2 stream twice + +Found when the clean integration suite logged an internal error despite passing its assertions. +The demultiplexer and response-completion thread could both observe a closed stream, then one +thread could recycle it before the other read its id. The loser attempted to remove stream id +zero; a more unfortunate interleaving could have touched a reused pooled object. **Fix**: make +stream retirement atomic in `Http2StreamTable` and require both the expected stream id and object +identity to match the live table entry. A regression test proves that a stale retirement cannot +remove the next generation of the same pooled object. **Phase**: 13. + --- # PART III — The phases @@ -2834,9 +2853,11 @@ of allocated memory (assert with a heap sample, not a hope). applicable, and how to tune it. This is the document an operator reads at 3 a.m. ### DoD -- [ ] Every attack in this phase has a test that proves the defence. -- [ ] Every limit is documented with its rationale. -- [ ] A `security-review` pass over the whole `h2` package is completed and its findings fixed. +- [x] Every attack in this phase has a test that proves the defence. +- [x] Every limit is documented with its rationale. +- [x] A `security-review` pass over the whole `h2` package is completed and its findings fixed + (`EX-50`: declared header-assembly and idle-stream deadlines were not wired; `EX-51`: + concurrent half-close could retire the same pooled stream twice). --- diff --git a/flash/docs/http2/SECURITY.md b/flash/docs/http2/SECURITY.md new file mode 100644 index 0000000..ef0ceaa --- /dev/null +++ b/flash/docs/http2/SECURITY.md @@ -0,0 +1,42 @@ +# HTTP/2 security controls + +HTTP/2 multiplexing lets one connection create disproportionate parser, stream and response work. +Flash therefore combines structural bounds, flow-control bounds and rate bounds. Rate counters use +two fixed half-window buckets, allocate nothing per frame and need no timer thread. +JMH on JDK 21 measures one rate-counter increment at 38.083 ns/op and approximately +`10^-4 B/op` (allocation noise floor, no GC). + +| Limit | Default | Defence / tuning guidance | +|---|---:|---| +| `MAX_CONCURRENT_STREAMS` | 64 | Bounds simultaneously retained stream state. | +| `MAX_STREAMS_CREATED_PER_INTERVAL` | 400 / 10 s | Companion to Rapid Reset; tune with `h2MaxStreamsCreatedPerInterval`. | +| `MAX_RESET_STREAMS_PER_INTERVAL` | 200 / 10 s | CVE-2023-44487 Rapid Reset; tune with `h2MaxResetStreamsPerInterval`. | +| `MAX_CONTINUATION_FRAMES_PER_BLOCK` | 8 | CVE-2024-27316 CONTINUATION flood. | +| `MAX_HEADER_LIST_SIZE` | 32 KiB | Stops HPACK expansion before fields reach stream storage. | +| `MAX_HPACK_STRING_LENGTH` | 8 KiB | Bounds one decoded literal, including Huffman expansion. | +| `MAX_SETTINGS_PER_INTERVAL` | 100 / 10 s | Bounds mandatory SETTINGS acknowledgements. | +| `MAX_PINGS_PER_INTERVAL` | 200 / 10 s | Bounds mandatory PING acknowledgements. | +| `MAX_USELESS_FRAMES_PER_INTERVAL` | 10,000 / 10 s | Aggregate CPU bound for PRIORITY, WINDOW_UPDATE, empty DATA and unknown frames. | +| `MAX_SETTINGS_ACK_QUEUE_DEPTH` | 64 | Bounds queued SETTINGS control writes. | +| `MAX_PING_QUEUE_DEPTH` | 64 | Bounds queued PING control writes. | +| `MAX_EMPTY_DATA_FRAMES_PER_STREAM` | 1,000 | Stops DATA work that spends no flow-control credit. | +| `INITIAL_WINDOW_SIZE_LOCAL` | 1 MiB | Matches the bounded DATA pool; consumption, not receipt, returns credit. | +| `MAX_REQUEST_BODY_SIZE` | 100 MiB | Hard per-stream request body bound. | +| `HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS` | 10 s | Absolute HEADERS-to-END_HEADERS deadline. | +| `STREAM_IDLE_TIMEOUT_MS` | 60 s | Cancels retained inactive streams; tune with `h2StreamIdleTimeoutMs`. | +| `FRAME_READ_TIMEOUT_MS` | 20 s | Absolute partial-frame deadline. | +| `WRITE_TIMEOUT_MS` | 30 s | Interrupts a socket writer blocked by a peer that stopped reading. | +| `MAX_STREAMS_PER_CONNECTION` | 100,000 | Optional connection churn budget; zero disables, tune with `h2MaxStreamsPerConnection`. | +| `MAX_BYTES_PER_CONNECTION` | disabled | Optional wire-byte budget; tune with `h2MaxBytesPerConnection`. | +| `MAX_CONNECTION_LIFETIME_MS` | disabled | Optional lifetime rotation; tune with `h2MaxConnectionLifetimeMs`. | + +`h2AbuseRateIntervalMs` changes the rolling interval for reset and stream-creation operator +limits. Breaching a connection-wide rate or budget produces GOAWAY `ENHANCE_YOUR_CALM`; malformed +stream-local messages use the RFC-defined stream error. The ordinary write queue is bounded by the +64 live streams and their single-in-flight response intent; control writes use the fixed scratch +slots above, so a slow reader cannot create an unbounded application queue. + +The security suite covers Rapid Reset, stream churn, SETTINGS/PING/non-progress floods, 100,000 +CONTINUATION frames, HPACK expansion, malformed names/pseudo-fields, configurable resource budgets, +header assembly deadlines and idle-stream cancellation. HTTP/1 request/header/body security tests +remain in the full suite and the shared public message model uses the same bounds on both paths. diff --git a/flash/src/jmh/java/dev/relism/flash/http2/RollingWindowCounterBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/RollingWindowCounterBenchmark.java new file mode 100644 index 0000000..b1b7a8c --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/RollingWindowCounterBenchmark.java @@ -0,0 +1,27 @@ +package dev.relism.flash.http2; + +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(2) +public class RollingWindowCounterBenchmark { + private final RollingWindowCounter counter = new RollingWindowCounter(10_000); + + @Benchmark + public boolean increment() { + return counter.incrementExceeded(Integer.MAX_VALUE); + } +} diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java b/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java index b1048e7..6c808f8 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java @@ -101,6 +101,34 @@ public class FlashConfiguration { */ @Builder.Default boolean h2HuffmanDynamicValues = false; + /** Maximum peer RST_STREAM frames per rolling interval. */ + @Builder.Default int h2MaxResetStreamsPerInterval = + dev.relism.flash.http2.Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL; + + /** Maximum peer-created streams per rolling interval. */ + @Builder.Default int h2MaxStreamsCreatedPerInterval = + dev.relism.flash.http2.Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL; + + /** Rolling interval used by HTTP/2 abuse-rate counters. */ + @Builder.Default long h2AbuseRateIntervalMs = + dev.relism.flash.http2.Http2Limits.RESET_RATE_INTERVAL_MS; + + /** Maximum total streams served by one HTTP/2 connection; zero disables the budget. */ + @Builder.Default long h2MaxStreamsPerConnection = + dev.relism.flash.http2.Http2Limits.MAX_STREAMS_PER_CONNECTION; + + /** Maximum wire bytes read by one HTTP/2 connection; zero disables the budget. */ + @Builder.Default long h2MaxBytesPerConnection = + dev.relism.flash.http2.Http2Limits.MAX_BYTES_PER_CONNECTION; + + /** Maximum HTTP/2 connection lifetime in milliseconds; zero disables the budget. */ + @Builder.Default long h2MaxConnectionLifetimeMs = + dev.relism.flash.http2.Http2Limits.MAX_CONNECTION_LIFETIME_MS; + + /** Maximum inactivity time for an open HTTP/2 stream. */ + @Builder.Default long h2StreamIdleTimeoutMs = + dev.relism.flash.http2.Http2Limits.STREAM_IDLE_TIMEOUT_MS; + /** * Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default {@code true}; * set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2AbuseGuard.java b/flash/src/main/java/dev/relism/flash/http2/Http2AbuseGuard.java new file mode 100644 index 0000000..579acf6 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2AbuseGuard.java @@ -0,0 +1,114 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.extension.FlashConfiguration; + +/** Enforces per-connection HTTP/2 rate limits and lifetime budgets. */ +final class Http2AbuseGuard { + private RollingWindowCounter resetRate; + private RollingWindowCounter streamCreationRate; + private RollingWindowCounter settingsRate; + private RollingWindowCounter pingRate; + private RollingWindowCounter uselessFrameRate; + private int maxResetRate; + private int maxStreamCreationRate; + private long maxStreams; + private long maxBytes; + private long maxLifetimeNanos; + private long startedNanos; + private long wireBytes; + private long streams; + + Http2AbuseGuard() { + configure(FlashConfiguration.builder().build()); + } + + void configure(FlashConfiguration configuration) { + long interval = configuration.getH2AbuseRateIntervalMs(); + if (interval < 2) throw new IllegalArgumentException("h2AbuseRateIntervalMs must be at least 2"); + resetRate = new RollingWindowCounter(interval); + streamCreationRate = new RollingWindowCounter(interval); + settingsRate = new RollingWindowCounter(interval); + pingRate = new RollingWindowCounter(interval); + uselessFrameRate = new RollingWindowCounter(interval); + maxResetRate = + positive( + configuration.getH2MaxResetStreamsPerInterval(), + "h2MaxResetStreamsPerInterval"); + maxStreamCreationRate = + positive( + configuration.getH2MaxStreamsCreatedPerInterval(), + "h2MaxStreamsCreatedPerInterval"); + maxStreams = + nonNegative(configuration.getH2MaxStreamsPerConnection(), "h2MaxStreamsPerConnection"); + maxBytes = + nonNegative(configuration.getH2MaxBytesPerConnection(), "h2MaxBytesPerConnection"); + long lifetime = + nonNegative( + configuration.getH2MaxConnectionLifetimeMs(), "h2MaxConnectionLifetimeMs"); + maxLifetimeNanos = toNanos(lifetime); + } + + void start() { + startedNanos = System.nanoTime(); + } + + void receivedFrame(int payloadLength) { + wireBytes += 9L + payloadLength; + checkBudgets(); + } + + void streamCreated() { + if (streamCreationRate.incrementExceeded(maxStreamCreationRate)) { + calm("stream creation rate"); + } + streams++; + if (maxStreams > 0 && streams > maxStreams) calm("connection stream budget"); + } + + void resetReceived() { + if (resetRate.incrementExceeded(maxResetRate)) calm("RST_STREAM rate"); + } + + void settingsReceived() { + if (settingsRate.incrementExceeded(Http2Limits.MAX_SETTINGS_PER_INTERVAL)) { + calm("SETTINGS rate"); + } + } + + void pingReceived() { + if (pingRate.incrementExceeded(Http2Limits.MAX_PINGS_PER_INTERVAL)) calm("PING rate"); + } + + void uselessFrameReceived() { + if (uselessFrameRate.incrementExceeded(Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL)) { + calm("non-progress frame rate"); + } + } + + void checkBudgets() { + if (maxBytes > 0 && wireBytes > maxBytes) calm("connection byte budget"); + if (maxLifetimeNanos > 0 && System.nanoTime() - startedNanos > maxLifetimeNanos) { + calm("connection lifetime budget"); + } + } + + private static int positive(int value, String name) { + if (value <= 0) throw new IllegalArgumentException(name + " must be positive"); + return value; + } + + private static long nonNegative(long value, String name) { + if (value < 0) throw new IllegalArgumentException(name + " must not be negative"); + return value; + } + + private static long toNanos(long milliseconds) { + return milliseconds > Long.MAX_VALUE / 1_000_000L + ? Long.MAX_VALUE + : milliseconds * 1_000_000L; + } + + private static void calm(String reason) { + throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, reason + " exceeded"); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2Connection.java b/flash/src/main/java/dev/relism/flash/http2/Http2Connection.java index 35d763c..857f34d 100644 --- a/flash/src/main/java/dev/relism/flash/http2/Http2Connection.java +++ b/flash/src/main/java/dev/relism/flash/http2/Http2Connection.java @@ -1,6 +1,7 @@ package dev.relism.flash.http2; import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.extension.FlashConfiguration; import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent; import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind; import dev.relism.flash.http2.frame.FrameFlags; @@ -63,6 +64,9 @@ public final class Http2Connection implements ConnectionProtocol { private Http2StreamDispatcher streamDispatcher; private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS]; private int dispatchCount; + private final Http2AbuseGuard abuse = new Http2AbuseGuard(); + private long streamIdleTimeoutNanos = Http2Limits.STREAM_IDLE_TIMEOUT_MS * 1_000_000L; + private final Http2Stream[] idleSweep = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS]; public Http2Connection() { this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS); @@ -88,6 +92,7 @@ public final class Http2Connection implements ConnectionProtocol { @Override public void run(ConnectionContext ctx) throws IOException { + configure(ctx.configuration()); Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write); flowController = new Http2FlowController( @@ -126,6 +131,7 @@ public final class Http2Connection implements ConnectionProtocol { BooleanSupplier stopped) throws IOException { if (!verifyPreface(input)) return; + abuse.start(); sendConstant(writer, Http2Preface.serverSettings()); sendConstant(writer, Http2Preface.initialConnectionWindow()); @@ -135,12 +141,15 @@ public final class Http2Connection implements ConnectionProtocol { boolean firstFrame = true; try { while (!gracefulFinished && !peerGoAway) { + abuse.checkBudgets(); + closeIdleStreams(writer); if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer); FrameHeader frame; try { frame = reader.readFrame(Math.min(100, nextReadTimeoutMs())); } catch (SocketTimeoutException timeout) { checkSettingsTimeout(); + headerBlocks.checkTimeout(); if (stopped.getAsBoolean() && !gracefulStarted) { startGracefulShutdown(writer); continue; @@ -149,7 +158,9 @@ public final class Http2Connection implements ConnectionProtocol { continue; } if (frame == null) break; + abuse.receivedFrame(frame.length()); try { + headerBlocks.checkTimeout(); FrameValidator.validate(frame, headerBlocks.insideHeaderBlock()); if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) { throw Http2Exception.PROTOCOL_ERROR; @@ -201,7 +212,10 @@ public final class Http2Connection implements ConnectionProtocol { private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException { FrameType type = frame.type(); - if (type == null) return; + if (type == null) { + abuse.uselessFrameReceived(); + return; + } switch (type) { case SETTINGS -> receiveSettings(frame, writer); case PING -> receivePing(frame, writer); @@ -221,6 +235,7 @@ public final class Http2Connection implements ConnectionProtocol { if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR; Http2Stream existing = streams.get(streamId); if (existing != null) { + existing.touch(); if (!FrameFlags.isEndStream(frame.flags())) { throw new Http2StreamException( streamId, Http2ErrorCode.PROTOCOL_ERROR, "trailers require END_STREAM"); @@ -232,6 +247,7 @@ public final class Http2Connection implements ConnectionProtocol { return; } if (streamId <= highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR; + abuse.streamCreated(); highestClientStreamId = streamId; pendingTrailers = false; @@ -285,15 +301,13 @@ public final class Http2Connection implements ConnectionProtocol { lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId); } if (streamDispatcher == null && !pendingTrailers) { - streams.remove(streamId); - streams.release(stream); + streams.retire(stream, streamId); if (!gracefulStarted) startGracefulShutdown(writer); } else if (dispatch) { enqueueDispatch(stream); } else if (stream.responseStarted() && stream.responseWriter().finished() && !stream.responseInFlight() && stream.state() == Http2StreamState.CLOSED) { - streams.remove(stream.id()); - streams.release(stream); + streams.retire(stream, streamId); } } } finally { @@ -304,6 +318,7 @@ public final class Http2Connection implements ConnectionProtocol { } private void receivePriority(FrameHeader frame) { + abuse.uselessFrameReceived(); int dependency = readUInt31(frame.buffer(), frame.payloadOffset()); if (dependency == frame.streamId()) { throw new Http2StreamException( @@ -312,6 +327,7 @@ public final class Http2Connection implements ConnectionProtocol { } private void receiveData(FrameHeader frame) { + if (frame.length() == 0) abuse.uselessFrameReceived(); flowController.receiveConnectionBytes(frame.length()); Http2Stream stream = streams.get(frame.streamId()); if (stream == null) { @@ -322,6 +338,7 @@ public final class Http2Connection implements ConnectionProtocol { } boolean bodyAccepted = false; try { + stream.touch(); stream.transition( FrameFlags.isEndStream(frame.flags()) ? Http2StreamState.Event.RECV_DATA_ES @@ -352,8 +369,7 @@ public final class Http2Connection implements ConnectionProtocol { else if (stream.responseStarted() && stream.responseWriter().finished() && !stream.responseInFlight() && stream.state() == Http2StreamState.CLOSED) { - streams.remove(stream.id()); - streams.release(stream); + streams.retire(stream, frame.streamId()); } } } catch (RuntimeException failure) { @@ -363,6 +379,7 @@ public final class Http2Connection implements ConnectionProtocol { } private void receiveRstStream(FrameHeader frame) { + abuse.resetReceived(); Http2Stream stream = streams.get(frame.streamId()); if (stream == null) { if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR; @@ -371,7 +388,7 @@ public final class Http2Connection implements ConnectionProtocol { boolean releaseDeferred = stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE; stream.transition(Http2StreamState.Event.RECV_RST); - streams.remove(stream.id()); + if (!streams.removeIfSame(stream, frame.streamId())) return; if (releaseDeferred) { stream.cancel(); if (stream.responseStarted() && !stream.responseInFlight()) streams.release(stream); @@ -408,6 +425,7 @@ public final class Http2Connection implements ConnectionProtocol { if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0; return; } + abuse.settingsReceived(); peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows); sendConstant(writer, Http2Preface.settingsAck()); } @@ -420,12 +438,14 @@ public final class Http2Connection implements ConnectionProtocol { } return; } + abuse.pingReceived(); ControlIntent pong = scratch.acquire(ControlKind.PING); pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8); writer.writePriority(pong); } private void receiveWindowUpdate(FrameHeader frame) { + abuse.uselessFrameReceived(); int increment = readUInt31(frame.buffer(), frame.payloadOffset()); if (increment == 0) { if (frame.streamId() == 0) throw Http2Exception.PROTOCOL_ERROR; @@ -439,6 +459,7 @@ public final class Http2Connection implements ConnectionProtocol { return; } try { + stream.touch(); flowController.increaseStreamSendWindow(stream, increment); } catch (IllegalStateException overflow) { throw new Http2StreamException( @@ -457,6 +478,29 @@ public final class Http2Connection implements ConnectionProtocol { peerGoAway = true; } + void configure(FlashConfiguration configuration) { + abuse.configure(configuration); + long idle = configuration.getH2StreamIdleTimeoutMs(); + if (idle <= 0) throw new IllegalArgumentException("h2StreamIdleTimeoutMs must be positive"); + streamIdleTimeoutNanos = idle > Long.MAX_VALUE / 1_000_000L + ? Long.MAX_VALUE : idle * 1_000_000L; + } + + private void closeIdleStreams(Http2FrameWriter writer) throws IOException { + int count = streams.copyValues(idleSweep); + long now = System.nanoTime(); + for (int i = 0; i < count; i++) { + Http2Stream stream = idleSweep[i]; + idleSweep[i] = null; + if (!stream.idleExpired(now, streamIdleTimeoutNanos)) continue; + int streamId = stream.id(); + if (!streams.removeIfSame(stream, streamId)) continue; + sendRstStream(writer, streamId, Http2ErrorCode.CANCEL); + if (stream.dispatched()) stream.cancel(); + else streams.release(stream); + } + } + private void startGracefulShutdown(Http2FrameWriter writer) throws IOException { gracefulStarted = true; sendGoAway(writer, Integer.MAX_VALUE, Http2ErrorCode.NO_ERROR, "server shutting down"); @@ -495,8 +539,9 @@ public final class Http2Connection implements ConnectionProtocol { } private void closeStreamAfterError(int streamId) { - Http2Stream stream = streams.remove(streamId); + Http2Stream stream = streams.get(streamId); if (stream == null) return; + if (!streams.removeIfSame(stream, streamId)) return; if (stream.dispatched()) stream.cancel(); else streams.release(stream); if (pendingHeaderStream == stream) pendingHeaderStream = null; diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2HeaderBlockDecoder.java b/flash/src/main/java/dev/relism/flash/http2/Http2HeaderBlockDecoder.java index 7ea46e8..9d820c8 100644 --- a/flash/src/main/java/dev/relism/flash/http2/Http2HeaderBlockDecoder.java +++ b/flash/src/main/java/dev/relism/flash/http2/Http2HeaderBlockDecoder.java @@ -16,7 +16,18 @@ final class Http2HeaderBlockDecoder { private final ContinuationAssembler assembler = new ContinuationAssembler(); private final HpackDecoder decoder = new HpackDecoder(); + private final long assemblyTimeoutNanos; private boolean endStream; + private long assemblyStartedNanos; + + Http2HeaderBlockDecoder() { + this(Http2Limits.HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS); + } + + Http2HeaderBlockDecoder(long assemblyTimeoutMillis) { + if (assemblyTimeoutMillis <= 0) throw new IllegalArgumentException("non-positive timeout"); + assemblyTimeoutNanos = assemblyTimeoutMillis * 1_000_000L; + } boolean insideHeaderBlock() { return assembler.isActive(); @@ -24,6 +35,7 @@ final class Http2HeaderBlockDecoder { /** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */ boolean accept(FrameHeader frame, HeaderSink sink) { + checkTimeout(); if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) { throw Http2Exception.PROTOCOL_ERROR; } @@ -50,14 +62,26 @@ final class Http2HeaderBlockDecoder { streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage()); } assembler.reset(); + assemblyStartedNanos = 0; return true; } + void checkTimeout() { + if (assembler.isActive() + && System.nanoTime() - assemblyStartedNanos >= assemblyTimeoutNanos) { + assembler.reset(); + assemblyStartedNanos = 0; + throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, + "header block assembly timeout"); + } + } + boolean endStream() { return endStream; } private void begin(FrameHeader frame) { + assemblyStartedNanos = System.nanoTime(); endStream = FrameFlags.isEndStream(frame.flags()); long unpadded = Padding.unpad( diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2Limits.java b/flash/src/main/java/dev/relism/flash/http2/Http2Limits.java index 70c90fc..abca22e 100644 --- a/flash/src/main/java/dev/relism/flash/http2/Http2Limits.java +++ b/flash/src/main/java/dev/relism/flash/http2/Http2Limits.java @@ -73,6 +73,27 @@ public final class Http2Limits { */ public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 400; + /** Maximum SETTINGS frames accepted within one abuse-rate interval. */ + public static final int MAX_SETTINGS_PER_INTERVAL = 100; + + /** Maximum non-acknowledgement PING frames accepted within one abuse-rate interval. */ + public static final int MAX_PINGS_PER_INTERVAL = 200; + + /** + * Aggregate bound for frames that consume parsing work without carrying application data: + * PRIORITY, WINDOW_UPDATE, empty DATA and unknown extension frames. + */ + public static final int MAX_USELESS_FRAMES_PER_INTERVAL = 10_000; + + /** Default total-stream budget for one connection; zero disables the budget. */ + public static final long MAX_STREAMS_PER_CONNECTION = 100_000; + + /** Default wire-byte budget for one connection; zero disables the budget. */ + public static final long MAX_BYTES_PER_CONNECTION = 0; + + /** Default connection lifetime budget in milliseconds; zero disables the budget. */ + public static final long MAX_CONNECTION_LIFETIME_MS = 0; + /** * Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A SETTINGS * frame is already bounded in byte length by {@link #MAX_FRAME_SIZE_LOCAL} (each entry is 6 diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2StreamDispatcher.java b/flash/src/main/java/dev/relism/flash/http2/Http2StreamDispatcher.java index 473e7c4..9110cc7 100644 --- a/flash/src/main/java/dev/relism/flash/http2/Http2StreamDispatcher.java +++ b/flash/src/main/java/dev/relism/flash/http2/Http2StreamDispatcher.java @@ -93,6 +93,7 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink { } private void handle(Http2Stream stream) { + stream.touch(); if (stream.cancelled()) { streams.release(stream); return; @@ -162,17 +163,18 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink { } private void tryResumeResponse(Http2Stream stream) { + stream.touch(); if (stream.cancelled()) { stream.endResponseBatch(); streams.release(stream); return; } Http2ResponseWriter responseWriter = stream.responseWriter(); + int streamId = stream.id(); if (responseWriter.finished()) { stream.endResponseBatch(); if (stream.state() == Http2StreamState.CLOSED) { - streams.remove(stream.id()); - streams.release(stream); + streams.retire(stream, streamId); } return; } @@ -228,8 +230,10 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink { @Override public void responseBatchCompleted(Http2Stream stream) { + stream.touch(); stream.endResponseBatch(); - if (stream.id() == 0) return; + int streamId = stream.id(); + if (streamId == 0) return; if (stream.cancelled()) { streams.remove(stream.id()); streams.release(stream); @@ -237,8 +241,7 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink { } if (stream.responseWriter().finished()) { if (stream.state() == Http2StreamState.CLOSED) { - streams.remove(stream.id()); - streams.release(stream); + streams.retire(stream, streamId); } return; } @@ -246,18 +249,18 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink { } private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) { - if (stream.id() == 0) return; - if (cause != null) log.error("HTTP/2 stream {} failed", stream.id(), cause); - streams.remove(stream.id()); + int streamId = stream.id(); + if (!streams.removeIfSame(stream, streamId)) return; + if (cause != null) log.error("HTTP/2 stream {} failed", streamId, cause); try { stream.cancel(); } catch (RuntimeException cancellationFailure) { - log.debug("Failed to cancel HTTP/2 stream {} cleanly", stream.id(), cancellationFailure); + log.debug("Failed to cancel HTTP/2 stream {} cleanly", streamId, cancellationFailure); } try { - failures.fail(stream.id(), error); + failures.fail(streamId, error); } catch (IOException writeFailure) { - log.debug("Failed to write RST_STREAM for {}", stream.id(), writeFailure); + log.debug("Failed to write RST_STREAM for {}", streamId, writeFailure); } finally { streams.release(stream); } diff --git a/flash/src/main/java/dev/relism/flash/http2/RollingWindowCounter.java b/flash/src/main/java/dev/relism/flash/http2/RollingWindowCounter.java new file mode 100644 index 0000000..4c7b154 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/RollingWindowCounter.java @@ -0,0 +1,31 @@ +package dev.relism.flash.http2; + +/** Allocation-free two-bucket rolling rate counter owned by one connection thread. */ +final class RollingWindowCounter { + private final long bucketNanos; + private long currentBucket; + private int currentCount; + private int previousCount; + + RollingWindowCounter(long intervalMillis) { + if (intervalMillis < 2) throw new IllegalArgumentException("interval must be at least 2 ms"); + bucketNanos = intervalMillis * 1_000_000L / 2; + } + + boolean incrementExceeded(int limit) { + return incrementExceeded(limit, System.nanoTime()); + } + + boolean incrementExceeded(int limit, long nowNanos) { + long bucket = nowNanos / bucketNanos; + if (currentBucket == 0) { + currentBucket = bucket; + } else if (bucket != currentBucket) { + previousCount = bucket == currentBucket + 1 ? currentCount : 0; + currentCount = 0; + currentBucket = bucket; + } + currentCount++; + return currentCount + previousCount > limit; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/stream/Http2Stream.java b/flash/src/main/java/dev/relism/flash/http2/stream/Http2Stream.java index 627d5b1..f6f10d0 100644 --- a/flash/src/main/java/dev/relism/flash/http2/stream/Http2Stream.java +++ b/flash/src/main/java/dev/relism/flash/http2/stream/Http2Stream.java @@ -69,6 +69,7 @@ public final class Http2Stream private volatile boolean responseStarted; private boolean releaseClaimed; private volatile boolean resumeTask; + private volatile long lastActivityNanos; Http2Stream poolNext; Http2Stream(DataBufferPool dataBuffers) { @@ -96,6 +97,7 @@ public final class Http2Stream headerBlock.reset(); trailerBlock.reset(); trailers.reset(trailerBlock); + touch(); } void clear() { @@ -301,6 +303,14 @@ public final class Http2Stream return cancelled; } + public void touch() { + lastActivityNanos = System.nanoTime(); + } + + public boolean idleExpired(long nowNanos, long timeoutNanos) { + return timeoutNanos > 0 && nowNanos - lastActivityNanos >= timeoutNanos; + } + public synchronized int sendWindow() { return sendWindow; } diff --git a/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamTable.java b/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamTable.java index 4aa2d8b..f325900 100644 --- a/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamTable.java +++ b/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamTable.java @@ -97,6 +97,22 @@ public final class Http2StreamTable { return removed; } + /** Removes a stream only when both its id and pooled-object identity still match. */ + public synchronized boolean removeIfSame(Http2Stream stream, int streamId) { + if (streamId <= 0) return false; + int slot = find(streamId); + if (keys[slot] != streamId || values[slot] != stream) return false; + remove(streamId); + return true; + } + + /** Atomically removes and recycles the matching generation of a pooled stream. */ + public synchronized boolean retire(Http2Stream stream, int streamId) { + if (!removeIfSame(stream, streamId)) return false; + release(stream); + return true; + } + public synchronized void forEach(StreamConsumer consumer) { for (int i = 0; i < keys.length; i++) { if (keys[i] != 0) consumer.accept(values[i]); diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java new file mode 100644 index 0000000..057146f --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java @@ -0,0 +1,309 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.hpack.HeaderListSizeException; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.hpack.HpackEncoder; +import dev.relism.flash.http2.hpack.HpackHeaderBlock; +import dev.relism.flash.http2.message.PseudoHeaders; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.http2.frame.Http2FrameWriter; +import dev.relism.flash.transport.BufferedByteSource; +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.InputStream; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.io.ByteArrayOutputStream; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class Http2AbuseTest { + @Test + void rapidResetClosesConnectionWithEnhanceYourCalm() throws Exception { + List frames = new ArrayList<>(); + frames.add(Http2TestFrames.PREFACE); + frames.add(Http2TestFrames.settings()); + for (int i = 0; i <= Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL; i++) { + int streamId = i * 2 + 1; + frames.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, streamId, + new byte[0])); + frames.add(Http2TestFrames.frame(FrameType.RST_STREAM, 0, streamId, new byte[4])); + } + assertCalm(run(frames)); + } + + @Test + void streamCreationFloodIsBoundedIndependentlyOfResets() throws Exception { + List frames = new ArrayList<>(); + frames.add(Http2TestFrames.PREFACE); + frames.add(Http2TestFrames.settings()); + for (int i = 0; i <= Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL; i++) { + frames.add(Http2TestFrames.frame( + FrameType.HEADERS, FrameFlags.END_HEADERS, i * 2 + 1, new byte[0])); + } + assertCalm(run(frames)); + } + + @Test + void settingsAndPingFloodsAreRateLimited() throws Exception { + List settings = base(); + for (int i = 0; i <= Http2Limits.MAX_SETTINGS_PER_INTERVAL; i++) { + settings.add(Http2TestFrames.settings()); + } + assertCalm(run(settings)); + + List pings = base(); + for (int i = 0; i <= Http2Limits.MAX_PINGS_PER_INTERVAL; i++) { + pings.add(Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8])); + } + assertCalm(run(pings)); + } + + @Test + void aggregateNonProgressFrameFloodIsRateLimited() throws Exception { + List frames = base(); + byte[] priority = new byte[5]; + for (int i = 0; i <= Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL; i++) { + frames.add(Http2TestFrames.frame(FrameType.PRIORITY, 0, 1, priority)); + } + assertCalm(run(frames)); + } + + @Test + void operatorConnectionStreamAndByteBudgetsAreEnforced() throws Exception { + FlashConfiguration oneStream = FlashConfiguration.builder() + .h2MaxStreamsPerConnection(1).build(); + List streams = base(); + streams.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0])); + streams.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 3, new byte[0])); + assertCalm(runConfigured(streams, oneStream)); + + FlashConfiguration nineBytes = FlashConfiguration.builder() + .h2MaxBytesPerConnection(9).build(); + List bytes = base(); + bytes.add(Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8])); + assertCalm(runConfigured(bytes, nineBytes)); + } + + @Test + void optionalConnectionLifetimeBudgetRotatesTheConnection() throws Exception { + byte[] initial = Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings()); + ByteArrayInputStream delegate = new ByteArrayInputStream(initial); + InputStream stalled = new InputStream() { + @Override + public int read(byte[] target, int offset, int length) throws IOException { + if (delegate.available() > 0) return delegate.read(target, offset, length); + try { + Thread.sleep(5); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException(interrupted); + } + throw new SocketTimeoutException("idle"); + } + + @Override + public int read() throws IOException { + byte[] one = new byte[1]; + int count = read(one, 0, 1); + return count < 0 ? -1 : one[0] & 0xff; + } + }; + Http2Connection connection = new Http2Connection(); + connection.configure(FlashConfiguration.builder().h2MaxConnectionLifetimeMs(1).build()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000); + try { + connection.run(new BufferedByteSource(stalled, null), writer, () -> false); + } finally { + writer.close(); + } + + assertCalm(new Run(Http2TestFrames.parse(output.toByteArray()))); + } + + @Test + void continuationFloodDiesBeforeMaterializingAttack() throws Exception { + List frames = base(); + frames.add(Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82})); + byte[] continuation = Http2TestFrames.frame(FrameType.CONTINUATION, 0, 1, new byte[0]); + for (int i = 0; i < 100_000; i++) frames.add(continuation); + byte[] input = Http2TestFrames.concat(frames.toArray(byte[][]::new)); + long before = usedHeap(); + + Run result = org.junit.jupiter.api.Assertions.assertTimeoutPreemptively( + Duration.ofSeconds(2), () -> run(input)); + + assertEquals(Http2ErrorCode.PROTOCOL_ERROR.code(), result.lastGoAwayError()); + assertTrue(usedHeap() - before < 8L * 1024 * 1024, "attack processing retained too much heap"); + } + + @Test + void hpackBombStopsPublishingFieldsAtTheConfiguredBound() { + ByteWriter block = new ByteWriter(4096); + byte[] name = "x".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + byte[] value = new byte[1024]; + for (int i = 0; i < 100; i++) HpackEncoder.writeLiteral(block, name, value); + int[] published = {0}; + + assertThrows( + HeaderListSizeException.class, + () -> new HpackDecoder(4096, 4096).decode( + block.array(), 0, block.length(), (n, v, sensitive) -> published[0]++)); + + assertTrue(published[0] <= 3, "fields beyond the list bound reached stream storage"); + } + + @Test + void incompleteHeaderBlockHasAnAbsoluteAssemblyDeadline() throws Exception { + byte[] wire = Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82}); + dev.relism.flash.http2.frame.Http2FrameReader reader = + new dev.relism.flash.http2.frame.Http2FrameReader( + new BufferedByteSource(new ByteArrayInputStream(wire), null)); + Http2HeaderBlockDecoder decoder = new Http2HeaderBlockDecoder(1); + decoder.accept(reader.readFrame(), (name, value, sensitive) -> {}); + Thread.sleep(5); + + Http2Exception failure = assertThrows(Http2Exception.class, decoder::checkTimeout); + assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, failure.errorCode()); + } + + @Test + void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception { + int port = freePort(); + FlashApp app = FlashApp.create(FlashConfiguration.builder() + .host("127.0.0.1").port(port).http2Enabled(true).h2StreamIdleTimeoutMs(20).build()); + app.post("/idle", (request, response) -> request.body().bytes()); + app.start(); + ByteWriter headers = new ByteWriter(64); + HpackEncoder.writeIndexed(headers, 3); + HpackEncoder.writeIndexed(headers, 6); + HpackEncoder.writeLiteralWithNameIndex(headers, 4, ascii("/idle"), false); + HpackEncoder.writeLiteralWithNameIndex(headers, 1, ascii("localhost"), false); + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(2_000); + socket.getOutputStream().write(Http2TestFrames.concat( + Http2TestFrames.PREFACE, Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, + java.util.Arrays.copyOf(headers.array(), headers.length())))); + socket.getOutputStream().flush(); + Http2TestFrames.WireFrame rst = readUntil(socket.getInputStream(), FrameType.RST_STREAM); + assertEquals(Http2ErrorCode.CANCEL.code(), Http2TestFrames.readInt(rst.payload(), 0)); + } finally { + app.stop().join(); + } + } + + @Test + void zeroNameDuplicatePseudoAndOversizedFieldAreRejected() { + HpackHeaderBlock emptyName = new HpackHeaderBlock(); + new HpackDecoder().decode(new byte[] {0, 0, 0}, 0, 3, emptyName); + assertThrows(Http2StreamException.class, + () -> new PseudoHeaders().validate(emptyName, 1)); + + HpackHeaderBlock duplicate = new HpackHeaderBlock(); + new HpackDecoder().decode(new byte[] {(byte) 0x82, (byte) 0x82}, 0, 2, duplicate); + assertThrows(Http2StreamException.class, + () -> new PseudoHeaders().validate(duplicate, 1)); + + assertThrows(Http2Exception.class, + () -> new HpackDecoder().decode(new byte[] {0, 0x7f, (byte) 0x81, 0x3f}, 0, 4, + (n, v, s) -> {})); + } + + private static List base() { + List frames = new ArrayList<>(); + frames.add(Http2TestFrames.PREFACE); + frames.add(Http2TestFrames.settings()); + return frames; + } + + private static Run run(List frames) throws Exception { + return run(Http2TestFrames.concat(frames.toArray(byte[][]::new))); + } + + private static Run run(byte[] input) throws Exception { + Http2ConnectionHandshakeTest.RunResult result = Http2ConnectionHandshakeTest.run(input); + return new Run(Http2TestFrames.parse(result.output())); + } + + private static Run runConfigured(List frames, FlashConfiguration configuration) + throws Exception { + Http2Connection connection = new Http2Connection(); + connection.configure(configuration); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000); + try { + connection.run( + new BufferedByteSource( + new ByteArrayInputStream(Http2TestFrames.concat(frames.toArray(byte[][]::new))), null), + writer, + () -> false); + writer.drain(); + } finally { + writer.close(); + } + return new Run(Http2TestFrames.parse(output.toByteArray())); + } + + private static void assertCalm(Run result) { + assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM.code(), result.lastGoAwayError()); + } + + private static long usedHeap() { + Runtime runtime = Runtime.getRuntime(); + return runtime.totalMemory() - runtime.freeMemory(); + } + + private static byte[] ascii(String text) { + return text.getBytes(StandardCharsets.US_ASCII); + } + + private static Http2TestFrames.WireFrame readUntil(InputStream input, FrameType expected) + throws Exception { + for (int i = 0; i < 12; i++) { + byte[] header = input.readNBytes(9); + if (header.length != 9) throw new EOFException(); + int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + byte[] payload = input.readNBytes(length); + Http2TestFrames.WireFrame frame = new Http2TestFrames.WireFrame( + header[3] & 0xff, header[4] & 0xff, + Http2TestFrames.readInt(header, 5) & 0x7fff_ffff, payload); + if (frame.type() == expected.code()) return frame; + } + throw new AssertionError("missing " + expected); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private record Run(List frames) { + int lastGoAwayError() { + for (int i = frames.size() - 1; i >= 0; i--) { + Http2TestFrames.WireFrame frame = frames.get(i); + if (frame.type() == FrameType.GOAWAY.code()) { + return Http2TestFrames.readInt(frame.payload(), 4); + } + } + throw new AssertionError("missing GOAWAY"); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2LimitsTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2LimitsTest.java index 837ad23..5b908ba 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2LimitsTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2LimitsTest.java @@ -24,6 +24,10 @@ class Http2LimitsTest { assertTrue(Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL > 0); assertTrue(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME > 0); assertTrue(Http2Limits.MAX_PING_QUEUE_DEPTH > 0); + assertTrue(Http2Limits.MAX_SETTINGS_PER_INTERVAL > 0); + assertTrue(Http2Limits.MAX_PINGS_PER_INTERVAL > 0); + assertTrue(Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL > 0); + assertTrue(Http2Limits.MAX_STREAMS_PER_CONNECTION > 0); assertTrue(Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM > 0); assertTrue(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL > 0); assertTrue(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL > 0); diff --git a/flash/src/test/java/dev/relism/flash/http2/RollingWindowCounterTest.java b/flash/src/test/java/dev/relism/flash/http2/RollingWindowCounterTest.java new file mode 100644 index 0000000..e8fa589 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/RollingWindowCounterTest.java @@ -0,0 +1,24 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class RollingWindowCounterTest { + @Test + void retainsOnlyCurrentAndImmediatelyPreviousHalfWindow() { + RollingWindowCounter counter = new RollingWindowCounter(1_000); + assertFalse(counter.incrementExceeded(2, 500_000_000L)); + assertFalse(counter.incrementExceeded(2, 999_000_000L)); + assertTrue(counter.incrementExceeded(2, 1_000_000_000L)); + assertFalse(counter.incrementExceeded(2, 1_500_000_000L)); + } + + @Test + void longIdleGapClearsBothBuckets() { + RollingWindowCounter counter = new RollingWindowCounter(1_000); + assertFalse(counter.incrementExceeded(1, 500_000_000L)); + assertFalse(counter.incrementExceeded(1, 2_000_000_000L)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamTableTest.java b/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamTableTest.java index 6cd9f3e..a49c21e 100644 --- a/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamTableTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamTableTest.java @@ -1,7 +1,9 @@ package dev.relism.flash.http2.stream; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -23,4 +25,17 @@ class Http2StreamTableTest { assertSame(streams[i], table.get(streams[i].id())); } } + + @Test + void staleRetirementCannotRemoveAReusedPooledStream() { + Http2StreamTable table = new Http2StreamTable(1); + Http2Stream firstGeneration = table.acquire(1); + + assertTrue(table.retire(firstGeneration, 1)); + Http2Stream secondGeneration = table.acquire(3); + assertSame(firstGeneration, secondGeneration); + + assertFalse(table.retire(firstGeneration, 1)); + assertSame(secondGeneration, table.get(3)); + } }