feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -173,6 +173,13 @@ app.onException((ex, req, res) -> {
|
|||||||
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
|
| `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. |
|
| `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. |
|
| `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
|
## TLS
|
||||||
|
|
||||||
|
|||||||
@@ -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-
|
**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
|
shaped traffic, but the dedicated rate-based and composite abuse controls are deliberately owned
|
||||||
by the following security phase.
|
by the following security phase.
|
||||||
|
|
||||||
**Decision.** Keep `FlashConfiguration.http2Enabled` defaulting to `false` during this phase.
|
**Decision.** Keep `FlashConfiguration.http2Enabled` defaulting to `false`. Applications can
|
||||||
Applications can enable the complete path explicitly; the default changes only after the hostile-
|
enable the complete path explicitly. The Phase 13 hostile-peer suite is now green, but the same
|
||||||
peer suite and its limits are green.
|
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
|
**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
|
adversarial gate. This is rollout sequencing, not an architectural separation: both protocols use
|
||||||
the same public request/response, header, trailer and streaming APIs.
|
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
|
**Revisit when.** At Phase 14 closure, after TLS HTTP/2 and cleartext h2c have independent rollout
|
||||||
remain opt-in.
|
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. |
|
| 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. |
|
| 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. |
|
| 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 | — | — |
|
| 14 — h2c prior knowledge + proxy support | not started | — | — |
|
||||||
| 15 — RFC 8441 extended CONNECT (WS over h2) | not started | — | — |
|
| 15 — RFC 8441 extended CONNECT (WS over h2) | not started | — | — |
|
||||||
| 16 — Compliance test suite | 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
|
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.
|
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
|
# 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.
|
applicable, and how to tune it. This is the document an operator reads at 3 a.m.
|
||||||
|
|
||||||
### DoD
|
### DoD
|
||||||
- [ ] Every attack in this phase has a test that proves the defence.
|
- [x] Every attack in this phase has a test that proves the defence.
|
||||||
- [ ] Every limit is documented with its rationale.
|
- [x] Every limit is documented with its rationale.
|
||||||
- [ ] A `security-review` pass over the whole `h2` package is completed and its findings fixed.
|
- [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;
|
@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};
|
* 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
|
* 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;
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
import dev.relism.flash.bytes.Pairs;
|
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.ControlIntent;
|
||||||
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
|
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
|
||||||
import dev.relism.flash.http2.frame.FrameFlags;
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
@@ -63,6 +64,9 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
private Http2StreamDispatcher streamDispatcher;
|
private Http2StreamDispatcher streamDispatcher;
|
||||||
private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
|
private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
|
||||||
private int dispatchCount;
|
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() {
|
public Http2Connection() {
|
||||||
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
|
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
|
||||||
@@ -88,6 +92,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(ConnectionContext ctx) throws IOException {
|
public void run(ConnectionContext ctx) throws IOException {
|
||||||
|
configure(ctx.configuration());
|
||||||
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
|
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
|
||||||
flowController =
|
flowController =
|
||||||
new Http2FlowController(
|
new Http2FlowController(
|
||||||
@@ -126,6 +131,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
BooleanSupplier stopped)
|
BooleanSupplier stopped)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
if (!verifyPreface(input)) return;
|
if (!verifyPreface(input)) return;
|
||||||
|
abuse.start();
|
||||||
|
|
||||||
sendConstant(writer, Http2Preface.serverSettings());
|
sendConstant(writer, Http2Preface.serverSettings());
|
||||||
sendConstant(writer, Http2Preface.initialConnectionWindow());
|
sendConstant(writer, Http2Preface.initialConnectionWindow());
|
||||||
@@ -135,12 +141,15 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
boolean firstFrame = true;
|
boolean firstFrame = true;
|
||||||
try {
|
try {
|
||||||
while (!gracefulFinished && !peerGoAway) {
|
while (!gracefulFinished && !peerGoAway) {
|
||||||
|
abuse.checkBudgets();
|
||||||
|
closeIdleStreams(writer);
|
||||||
if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer);
|
if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer);
|
||||||
FrameHeader frame;
|
FrameHeader frame;
|
||||||
try {
|
try {
|
||||||
frame = reader.readFrame(Math.min(100, nextReadTimeoutMs()));
|
frame = reader.readFrame(Math.min(100, nextReadTimeoutMs()));
|
||||||
} catch (SocketTimeoutException timeout) {
|
} catch (SocketTimeoutException timeout) {
|
||||||
checkSettingsTimeout();
|
checkSettingsTimeout();
|
||||||
|
headerBlocks.checkTimeout();
|
||||||
if (stopped.getAsBoolean() && !gracefulStarted) {
|
if (stopped.getAsBoolean() && !gracefulStarted) {
|
||||||
startGracefulShutdown(writer);
|
startGracefulShutdown(writer);
|
||||||
continue;
|
continue;
|
||||||
@@ -149,7 +158,9 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (frame == null) break;
|
if (frame == null) break;
|
||||||
|
abuse.receivedFrame(frame.length());
|
||||||
try {
|
try {
|
||||||
|
headerBlocks.checkTimeout();
|
||||||
FrameValidator.validate(frame, headerBlocks.insideHeaderBlock());
|
FrameValidator.validate(frame, headerBlocks.insideHeaderBlock());
|
||||||
if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) {
|
if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) {
|
||||||
throw Http2Exception.PROTOCOL_ERROR;
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
@@ -201,7 +212,10 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
|
|
||||||
private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
FrameType type = frame.type();
|
FrameType type = frame.type();
|
||||||
if (type == null) return;
|
if (type == null) {
|
||||||
|
abuse.uselessFrameReceived();
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SETTINGS -> receiveSettings(frame, writer);
|
case SETTINGS -> receiveSettings(frame, writer);
|
||||||
case PING -> receivePing(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;
|
if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
Http2Stream existing = streams.get(streamId);
|
Http2Stream existing = streams.get(streamId);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
|
existing.touch();
|
||||||
if (!FrameFlags.isEndStream(frame.flags())) {
|
if (!FrameFlags.isEndStream(frame.flags())) {
|
||||||
throw new Http2StreamException(
|
throw new Http2StreamException(
|
||||||
streamId, Http2ErrorCode.PROTOCOL_ERROR, "trailers require END_STREAM");
|
streamId, Http2ErrorCode.PROTOCOL_ERROR, "trailers require END_STREAM");
|
||||||
@@ -232,6 +247,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (streamId <= highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
if (streamId <= highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
abuse.streamCreated();
|
||||||
highestClientStreamId = streamId;
|
highestClientStreamId = streamId;
|
||||||
pendingTrailers = false;
|
pendingTrailers = false;
|
||||||
|
|
||||||
@@ -285,15 +301,13 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId);
|
lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId);
|
||||||
}
|
}
|
||||||
if (streamDispatcher == null && !pendingTrailers) {
|
if (streamDispatcher == null && !pendingTrailers) {
|
||||||
streams.remove(streamId);
|
streams.retire(stream, streamId);
|
||||||
streams.release(stream);
|
|
||||||
if (!gracefulStarted) startGracefulShutdown(writer);
|
if (!gracefulStarted) startGracefulShutdown(writer);
|
||||||
} else if (dispatch) {
|
} else if (dispatch) {
|
||||||
enqueueDispatch(stream);
|
enqueueDispatch(stream);
|
||||||
} else if (stream.responseStarted() && stream.responseWriter().finished()
|
} else if (stream.responseStarted() && stream.responseWriter().finished()
|
||||||
&& !stream.responseInFlight() && stream.state() == Http2StreamState.CLOSED) {
|
&& !stream.responseInFlight() && stream.state() == Http2StreamState.CLOSED) {
|
||||||
streams.remove(stream.id());
|
streams.retire(stream, streamId);
|
||||||
streams.release(stream);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -304,6 +318,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void receivePriority(FrameHeader frame) {
|
private void receivePriority(FrameHeader frame) {
|
||||||
|
abuse.uselessFrameReceived();
|
||||||
int dependency = readUInt31(frame.buffer(), frame.payloadOffset());
|
int dependency = readUInt31(frame.buffer(), frame.payloadOffset());
|
||||||
if (dependency == frame.streamId()) {
|
if (dependency == frame.streamId()) {
|
||||||
throw new Http2StreamException(
|
throw new Http2StreamException(
|
||||||
@@ -312,6 +327,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void receiveData(FrameHeader frame) {
|
private void receiveData(FrameHeader frame) {
|
||||||
|
if (frame.length() == 0) abuse.uselessFrameReceived();
|
||||||
flowController.receiveConnectionBytes(frame.length());
|
flowController.receiveConnectionBytes(frame.length());
|
||||||
Http2Stream stream = streams.get(frame.streamId());
|
Http2Stream stream = streams.get(frame.streamId());
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
@@ -322,6 +338,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
boolean bodyAccepted = false;
|
boolean bodyAccepted = false;
|
||||||
try {
|
try {
|
||||||
|
stream.touch();
|
||||||
stream.transition(
|
stream.transition(
|
||||||
FrameFlags.isEndStream(frame.flags())
|
FrameFlags.isEndStream(frame.flags())
|
||||||
? Http2StreamState.Event.RECV_DATA_ES
|
? Http2StreamState.Event.RECV_DATA_ES
|
||||||
@@ -352,8 +369,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
else if (stream.responseStarted() && stream.responseWriter().finished()
|
else if (stream.responseStarted() && stream.responseWriter().finished()
|
||||||
&& !stream.responseInFlight()
|
&& !stream.responseInFlight()
|
||||||
&& stream.state() == Http2StreamState.CLOSED) {
|
&& stream.state() == Http2StreamState.CLOSED) {
|
||||||
streams.remove(stream.id());
|
streams.retire(stream, frame.streamId());
|
||||||
streams.release(stream);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (RuntimeException failure) {
|
} catch (RuntimeException failure) {
|
||||||
@@ -363,6 +379,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void receiveRstStream(FrameHeader frame) {
|
private void receiveRstStream(FrameHeader frame) {
|
||||||
|
abuse.resetReceived();
|
||||||
Http2Stream stream = streams.get(frame.streamId());
|
Http2Stream stream = streams.get(frame.streamId());
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
@@ -371,7 +388,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
boolean releaseDeferred =
|
boolean releaseDeferred =
|
||||||
stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE;
|
stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE;
|
||||||
stream.transition(Http2StreamState.Event.RECV_RST);
|
stream.transition(Http2StreamState.Event.RECV_RST);
|
||||||
streams.remove(stream.id());
|
if (!streams.removeIfSame(stream, frame.streamId())) return;
|
||||||
if (releaseDeferred) {
|
if (releaseDeferred) {
|
||||||
stream.cancel();
|
stream.cancel();
|
||||||
if (stream.responseStarted() && !stream.responseInFlight()) streams.release(stream);
|
if (stream.responseStarted() && !stream.responseInFlight()) streams.release(stream);
|
||||||
@@ -408,6 +425,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0;
|
if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
abuse.settingsReceived();
|
||||||
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows);
|
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows);
|
||||||
sendConstant(writer, Http2Preface.settingsAck());
|
sendConstant(writer, Http2Preface.settingsAck());
|
||||||
}
|
}
|
||||||
@@ -420,12 +438,14 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
abuse.pingReceived();
|
||||||
ControlIntent pong = scratch.acquire(ControlKind.PING);
|
ControlIntent pong = scratch.acquire(ControlKind.PING);
|
||||||
pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8);
|
pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8);
|
||||||
writer.writePriority(pong);
|
writer.writePriority(pong);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void receiveWindowUpdate(FrameHeader frame) {
|
private void receiveWindowUpdate(FrameHeader frame) {
|
||||||
|
abuse.uselessFrameReceived();
|
||||||
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
|
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
|
||||||
if (increment == 0) {
|
if (increment == 0) {
|
||||||
if (frame.streamId() == 0) throw Http2Exception.PROTOCOL_ERROR;
|
if (frame.streamId() == 0) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
@@ -439,6 +459,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
stream.touch();
|
||||||
flowController.increaseStreamSendWindow(stream, increment);
|
flowController.increaseStreamSendWindow(stream, increment);
|
||||||
} catch (IllegalStateException overflow) {
|
} catch (IllegalStateException overflow) {
|
||||||
throw new Http2StreamException(
|
throw new Http2StreamException(
|
||||||
@@ -457,6 +478,29 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
peerGoAway = true;
|
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 {
|
private void startGracefulShutdown(Http2FrameWriter writer) throws IOException {
|
||||||
gracefulStarted = true;
|
gracefulStarted = true;
|
||||||
sendGoAway(writer, Integer.MAX_VALUE, Http2ErrorCode.NO_ERROR, "server shutting down");
|
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) {
|
private void closeStreamAfterError(int streamId) {
|
||||||
Http2Stream stream = streams.remove(streamId);
|
Http2Stream stream = streams.get(streamId);
|
||||||
if (stream == null) return;
|
if (stream == null) return;
|
||||||
|
if (!streams.removeIfSame(stream, streamId)) return;
|
||||||
if (stream.dispatched()) stream.cancel();
|
if (stream.dispatched()) stream.cancel();
|
||||||
else streams.release(stream);
|
else streams.release(stream);
|
||||||
if (pendingHeaderStream == stream) pendingHeaderStream = null;
|
if (pendingHeaderStream == stream) pendingHeaderStream = null;
|
||||||
|
|||||||
@@ -16,7 +16,18 @@ final class Http2HeaderBlockDecoder {
|
|||||||
|
|
||||||
private final ContinuationAssembler assembler = new ContinuationAssembler();
|
private final ContinuationAssembler assembler = new ContinuationAssembler();
|
||||||
private final HpackDecoder decoder = new HpackDecoder();
|
private final HpackDecoder decoder = new HpackDecoder();
|
||||||
|
private final long assemblyTimeoutNanos;
|
||||||
private boolean endStream;
|
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() {
|
boolean insideHeaderBlock() {
|
||||||
return assembler.isActive();
|
return assembler.isActive();
|
||||||
@@ -24,6 +35,7 @@ final class Http2HeaderBlockDecoder {
|
|||||||
|
|
||||||
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
|
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
|
||||||
boolean accept(FrameHeader frame, HeaderSink sink) {
|
boolean accept(FrameHeader frame, HeaderSink sink) {
|
||||||
|
checkTimeout();
|
||||||
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
|
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
|
||||||
throw Http2Exception.PROTOCOL_ERROR;
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
}
|
}
|
||||||
@@ -50,14 +62,26 @@ final class Http2HeaderBlockDecoder {
|
|||||||
streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage());
|
streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage());
|
||||||
}
|
}
|
||||||
assembler.reset();
|
assembler.reset();
|
||||||
|
assemblyStartedNanos = 0;
|
||||||
return true;
|
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() {
|
boolean endStream() {
|
||||||
return endStream;
|
return endStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void begin(FrameHeader frame) {
|
private void begin(FrameHeader frame) {
|
||||||
|
assemblyStartedNanos = System.nanoTime();
|
||||||
endStream = FrameFlags.isEndStream(frame.flags());
|
endStream = FrameFlags.isEndStream(frame.flags());
|
||||||
long unpadded =
|
long unpadded =
|
||||||
Padding.unpad(
|
Padding.unpad(
|
||||||
|
|||||||
@@ -73,6 +73,27 @@ public final class Http2Limits {
|
|||||||
*/
|
*/
|
||||||
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 400;
|
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
|
* 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
|
* 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) {
|
private void handle(Http2Stream stream) {
|
||||||
|
stream.touch();
|
||||||
if (stream.cancelled()) {
|
if (stream.cancelled()) {
|
||||||
streams.release(stream);
|
streams.release(stream);
|
||||||
return;
|
return;
|
||||||
@@ -162,17 +163,18 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void tryResumeResponse(Http2Stream stream) {
|
private void tryResumeResponse(Http2Stream stream) {
|
||||||
|
stream.touch();
|
||||||
if (stream.cancelled()) {
|
if (stream.cancelled()) {
|
||||||
stream.endResponseBatch();
|
stream.endResponseBatch();
|
||||||
streams.release(stream);
|
streams.release(stream);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Http2ResponseWriter responseWriter = stream.responseWriter();
|
Http2ResponseWriter responseWriter = stream.responseWriter();
|
||||||
|
int streamId = stream.id();
|
||||||
if (responseWriter.finished()) {
|
if (responseWriter.finished()) {
|
||||||
stream.endResponseBatch();
|
stream.endResponseBatch();
|
||||||
if (stream.state() == Http2StreamState.CLOSED) {
|
if (stream.state() == Http2StreamState.CLOSED) {
|
||||||
streams.remove(stream.id());
|
streams.retire(stream, streamId);
|
||||||
streams.release(stream);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,8 +230,10 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void responseBatchCompleted(Http2Stream stream) {
|
public void responseBatchCompleted(Http2Stream stream) {
|
||||||
|
stream.touch();
|
||||||
stream.endResponseBatch();
|
stream.endResponseBatch();
|
||||||
if (stream.id() == 0) return;
|
int streamId = stream.id();
|
||||||
|
if (streamId == 0) return;
|
||||||
if (stream.cancelled()) {
|
if (stream.cancelled()) {
|
||||||
streams.remove(stream.id());
|
streams.remove(stream.id());
|
||||||
streams.release(stream);
|
streams.release(stream);
|
||||||
@@ -237,8 +241,7 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
|
|||||||
}
|
}
|
||||||
if (stream.responseWriter().finished()) {
|
if (stream.responseWriter().finished()) {
|
||||||
if (stream.state() == Http2StreamState.CLOSED) {
|
if (stream.state() == Http2StreamState.CLOSED) {
|
||||||
streams.remove(stream.id());
|
streams.retire(stream, streamId);
|
||||||
streams.release(stream);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -246,18 +249,18 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
|
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
|
||||||
if (stream.id() == 0) return;
|
int streamId = stream.id();
|
||||||
if (cause != null) log.error("HTTP/2 stream {} failed", stream.id(), cause);
|
if (!streams.removeIfSame(stream, streamId)) return;
|
||||||
streams.remove(stream.id());
|
if (cause != null) log.error("HTTP/2 stream {} failed", streamId, cause);
|
||||||
try {
|
try {
|
||||||
stream.cancel();
|
stream.cancel();
|
||||||
} catch (RuntimeException cancellationFailure) {
|
} 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 {
|
try {
|
||||||
failures.fail(stream.id(), error);
|
failures.fail(streamId, error);
|
||||||
} catch (IOException writeFailure) {
|
} 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 {
|
} finally {
|
||||||
streams.release(stream);
|
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 volatile boolean responseStarted;
|
||||||
private boolean releaseClaimed;
|
private boolean releaseClaimed;
|
||||||
private volatile boolean resumeTask;
|
private volatile boolean resumeTask;
|
||||||
|
private volatile long lastActivityNanos;
|
||||||
Http2Stream poolNext;
|
Http2Stream poolNext;
|
||||||
|
|
||||||
Http2Stream(DataBufferPool dataBuffers) {
|
Http2Stream(DataBufferPool dataBuffers) {
|
||||||
@@ -96,6 +97,7 @@ public final class Http2Stream
|
|||||||
headerBlock.reset();
|
headerBlock.reset();
|
||||||
trailerBlock.reset();
|
trailerBlock.reset();
|
||||||
trailers.reset(trailerBlock);
|
trailers.reset(trailerBlock);
|
||||||
|
touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
@@ -301,6 +303,14 @@ public final class Http2Stream
|
|||||||
return cancelled;
|
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() {
|
public synchronized int sendWindow() {
|
||||||
return sendWindow;
|
return sendWindow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ public final class Http2StreamTable {
|
|||||||
return removed;
|
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) {
|
public synchronized void forEach(StreamConsumer consumer) {
|
||||||
for (int i = 0; i < keys.length; i++) {
|
for (int i = 0; i < keys.length; i++) {
|
||||||
if (keys[i] != 0) consumer.accept(values[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_STREAMS_CREATED_PER_INTERVAL > 0);
|
||||||
assertTrue(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME > 0);
|
assertTrue(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME > 0);
|
||||||
assertTrue(Http2Limits.MAX_PING_QUEUE_DEPTH > 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.MAX_EMPTY_DATA_FRAMES_PER_STREAM > 0);
|
||||||
assertTrue(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL > 0);
|
assertTrue(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL > 0);
|
||||||
assertTrue(Http2Limits.CONNECTION_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;
|
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.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -23,4 +25,17 @@ class Http2StreamTableTest {
|
|||||||
assertSame(streams[i], table.get(streams[i].id()));
|
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