feat(core): harden HTTP/2 abuse resistance
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<byte[]> 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<byte[]> 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<byte[]> settings = base();
|
||||
for (int i = 0; i <= Http2Limits.MAX_SETTINGS_PER_INTERVAL; i++) {
|
||||
settings.add(Http2TestFrames.settings());
|
||||
}
|
||||
assertCalm(run(settings));
|
||||
|
||||
List<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> base() {
|
||||
List<byte[]> frames = new ArrayList<>();
|
||||
frames.add(Http2TestFrames.PREFACE);
|
||||
frames.add(Http2TestFrames.settings());
|
||||
return frames;
|
||||
}
|
||||
|
||||
private static Run run(List<byte[]> 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<byte[]> 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<Http2TestFrames.WireFrame> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user