diff --git a/README.md b/README.md index 721c3eb..aa45e88 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,12 @@ app.onException((ex, req, res) -> { | `tls` | `null` | TLS for the default listener — see [TLS](#tls) | | `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) | | `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) | +| `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) | +| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/http2/HTTP1-HARDENING.md). | +| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. | +| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. | +| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. | +| `http2Enabled` | `false` | Whether this server will ever negotiate HTTP/2. Off by default until the HTTP/2 connection state machine lands (see `flash/docs/http2/IMPLEMENTATION-PLAN.md`). | ## TLS diff --git a/flash/docs/http2/DECISIONS.md b/flash/docs/http2/DECISIONS.md index 6845f29..8b606a5 100644 --- a/flash/docs/http2/DECISIONS.md +++ b/flash/docs/http2/DECISIONS.md @@ -280,3 +280,94 @@ not actually a separate module. **Revisit when.** The `h2` package's commit volume makes `core` too coarse to navigate in `git log` — not expected before Phase 10 at the earliest, if ever. + +--- + +## DEC-12 — Phase 1 plan corrections: two missing files, one corrected limit check + +**Context.** While implementing Phase 1, two problems in the plan document itself surfaced +(distinct from problems in the *code*, which is what the `EX-nn` registry tracks). + +1. Phase 1 task 8 requires "a `BufferedByteSource` owned by the connection that wraps the read + buffer plus the socket and exposes `readByte()`, `readFully(...)`, `skip(...)` and `peek()`", + and task 12 depends on it for h2c preface detection — but the Phase 1 **Files** list never + named the file. Likewise, the typed rejection `EX-02`/`EX-03`/`EX-08`/`EX-18` all need (a + specific HTTP status to respond with, as distinct from `HttpException`'s handler-routed + semantics — see `DEC-14`) was never named as a file either. +2. Task 4's exact wording — "Enforce `MAX_REQUEST_LINE_LENGTH` against `headerEndIdx - base` for + the request line specifically" — describes checking the length of the *entire header block* + (`headerEndIdx` is where the whole header section ends), not the request line. The request + line's own end is `protocolEnd` (or `sectionStart`), not `headerEndIdx`. + +**Decision.** +1. Added `flash/src/main/java/dev/relism/flash/transport/BufferedByteSource.java` and + `flash/src/main/java/dev/relism/flash/exceptions/MalformedRequestException.java` to Phase 1's + Files list (see the phase section itself, now corrected in place). +2. Implemented the check as `protocolEnd - base > MAX_REQUEST_LINE_LENGTH` — the request line's + actual span — rather than the literal (and, read literally, incorrect) `headerEndIdx - base`. + +**Consequence.** None beyond the plan text now matching what was actually built and why — +these are wording/omission fixes, not design trade-offs. Recorded per the plan's own rule that +corrections to the plan must be explicit and tracked, never silent. + +**Revisit when.** N/A — already resolved. + +--- + +## DEC-13 — `BufferedByteSource`'s deadline is enforced by computing the exact remaining `SO_TIMEOUT` per underlying read, not by a fixed poll-and-retry loop + +**Context.** `EX-07` requires an *absolute* deadline across a sequence of socket reads (a +per-read `SO_TIMEOUT` alone never trips against a peer that keeps each individual read within +the window while never completing the whole message — the canonical slowloris shape). Two ways +to implement that on top of the blocking `Socket`/`SSLSocket` API, which only offers a per-read +timeout: + +**Options.** +1. Set `SO_TIMEOUT` to a fixed, short polling interval (e.g. 1 s); on each + `SocketTimeoutException`, re-check whether the absolute deadline has actually passed, and if + not, retry. Deadline precision is bounded by the poll interval (up to ~1 s of slop). +2. Before every underlying read, compute the exact remaining budget + (`deadlineNanos - System.nanoTime()`) and hand that exact value to `setSoTimeout`. A + `SocketTimeoutException` from that read then unambiguously means the deadline — not merely + one poll cycle — has elapsed, with no retry loop needed. + +**Decision.** Option 2. + +**Consequence.** Deadline precision is exact (modulo OS timer granularity) rather than +poll-interval-bounded, and the implementation is simpler — no retry loop, no distinction between +"timed out this poll" and "timed out for real". The cost is one `setSoTimeout` syscall per +underlying fill (not per byte, not per `read()` call served from the buffer) — negligible, since +fills already happen at buffer granularity (up to 8 KiB at a time), not per byte. + +**Revisit when.** Not expected to be revisited; this is strictly better than option 1 on both +precision and simplicity. + +--- + +## DEC-14 — `MalformedRequestException extends HttpException`; caught separately from the per-request handler try/catch, never routed through the user's exception handler + +**Context.** `EX-02`/`EX-03`/`EX-08`/`EX-18` all need to reject a request with a specific HTTP +status before any handler or middleware runs. `HttpException` already exists in this codebase +for "carry a status code, get turned into a response" — but it is caught by +`router.getExceptionHandler()` inside the per-request try/catch, which is user-configurable +(e.g. `flash-ext-jackson` installs a JSON-formatting handler). + +**Options.** +1. Reuse `HttpException` directly, letting a malformed request flow through the same + user-configurable exception handler as an application-level failure. +2. A new type, `MalformedRequestException extends HttpException`, caught at a separate site — + around `parser.parse(in)` itself, before routing — with a fixed, minimal, non-customizable + response, always followed by closing the connection. + +**Decision.** Option 2. + +**Consequence.** A malformed or hostile request never reaches user code at all — not the +handler, not middleware, not a custom exception handler that might (reasonably, for its actual +purpose) try to look up a route, log structured JSON, or otherwise do work that assumes a +well-formed `Request`. The connection is always closed afterwards, never kept alive, which is +exactly the property `EX-02`'s smuggling defense depends on. Subclassing `HttpException` (rather +than an unrelated new hierarchy) keeps `status()`/message` access idiomatic with the rest of the +codebase's error-status convention, while the distinct type is what lets `HttpServer` catch it +at the parse site specifically. + +**Revisit when.** Not expected to be revisited. diff --git a/flash/docs/http2/HTTP1-HARDENING.md b/flash/docs/http2/HTTP1-HARDENING.md new file mode 100644 index 0000000..8fe32a6 --- /dev/null +++ b/flash/docs/http2/HTTP1-HARDENING.md @@ -0,0 +1,94 @@ +# HTTP/1.1 Hardening (Phase 1) + +Audience: operators. This is the document to read when a `400`/`413`/`414`/`431`/`501` shows up +in the logs and it isn't obvious why. Every rejection rule Flash's HTTP/1.1 parser enforces is +listed here with its RFC citation and the status it produces. Contributor-level detail (why each +check is implemented the way it is, the exact code paths) lives in the Javadoc of +`RequestParser`, `ChunkedInputStream`, and `dev.relism.flash.exceptions.MalformedRequestException`. + +Every rejection in this document has one thing in common: **the connection is always closed +afterwards, never kept alive.** A rejected request is exactly the situation a smuggling attack +needs a reusable connection for, so none of these rejections offer one — see +`MalformedRequestException`'s Javadoc. + +## Request-smuggling defenses (RFC 9112 §6.1) + +| Rule | Status | Detail | +|---|---|---| +| `Content-Length` and `Transfer-Encoding` both present | `400` | The canonical CL.TE/TE.CL smuggling vector. Rejected regardless of which header appears first. | +| Multiple `Content-Length` lines with **differing** values | `400` | Identical repeated values are tolerated (RFC 9110 §8.6 permits treating them as one). | +| `Transfer-Encoding` whose **final** coding is not `chunked` | `501` | Flash implements only `chunked`; anything else (`gzip` alone, or `chunked, gzip` — chunked must be *last*) is unsupported. | + +## Strict `Content-Length` parsing (RFC 9110 §8.6) + +| Input | Status | +|---|---| +| Empty value | `400` | +| Any non-digit byte (including a leading `+` or `-`) | `400` | +| More than 19 digits | `400` | +| Value overflows `Long.MAX_VALUE` | `400` | +| Value exceeds `Http1Limits.MAX_CONTENT_LENGTH` (4 GiB by default) | `413` | + +The previous parser silently skipped non-digit characters (`"5abc"` parsed as `5`; `"-1"` parsed +as `1`) instead of rejecting them — this is the fix. + +## Header and request-line limits (`Http1Limits`) + +| Limit | Default | Status when exceeded | +|---|---|---| +| `MAX_HEADER_COUNT` | 100 | `431 Request Header Fields Too Large` | +| `MAX_HEADER_NAME_LENGTH` | 256 B | `431` | +| `MAX_HEADER_VALUE_LENGTH` | 8192 B | `431` | +| `MAX_REQUEST_LINE_LENGTH` | 8192 B | `431` | +| Header block exceeds `maxHeaderBufferSize` (or the connection ends before it completes) | configurable, default 64 KiB | `431` | + +## Line-terminator and header-syntax correctness (RFC 9112 §5) + +| Rule | Status | +|---|---| +| A `\r` not immediately followed by `\n` (bare CR) | `400` — a known desynchronization/smuggling surface | +| A header line beginning with whitespace (obsolete line folding, RFC 9112 §5.2) | `400` | +| A header name containing a byte outside RFC 9110 §5.6.2's `tchar` set | `400` | +| A header line with no `:` | `400` | + +## Chunked transfer safety (RFC 9112 §7.1, `Http1Limits`) + +| Limit | Default | Status when exceeded | +|---|---|---| +| `MAX_CHUNK_SIZE` | 16 MiB | `413` | +| Chunk-size line longer than 16 hex digits | — | `400` | +| `MAX_CHUNK_EXT_LENGTH` (the optional `;name=value` after a chunk size) | 256 B | `400` | +| `MAX_CHUNKS_PER_BODY` | 100 000 | `413` | +| `MAX_TRAILER_COUNT` | 50 | `431` | +| A chunk's data not followed by `\r\n`, or a malformed chunk-size/trailer terminator | — | `400` | + +Trailers are consumed (safely, within the bounds above) but discarded, not exposed to the +handler, on HTTP/1.1 today — exposing them via `Request.trailers()` on both protocols is Phase +12 scope. + +## Timeouts (`FlashConfiguration`) + +| Setting | Default | Covers | +|---|---|---| +| `idleKeepAliveTimeoutMs` | 60 000 | How long a keep-alive connection may sit idle waiting for its next request. | +| `headerReadTimeoutMs` | 10 000 | Once the first byte of a request arrives, how long the full header block may take. | +| `bodyReadTimeoutMs` | 30 000 | How long reading the body (by the handler, or the automatic post-response drain) may take. | +| `shutdownDrainTimeoutMs` | 15 000 | How long graceful shutdown waits for in-flight requests before force-closing (wired up starting Phase 2). | + +These are enforced by an **absolute deadline**, not merely `Socket.setSoTimeout`. A per-read +socket timeout alone never trips against a peer that sends one byte just often enough to keep +each individual read alive (the classic slowloris shape) — see +`dev.relism.flash.transport.BufferedByteSource`'s Javadoc for how the absolute deadline is +implemented on top of the JDK's per-read-only timeout API. + +## TLS (RFC 9113 §9.2.2, applies once a listener offers `h2` over ALPN) + +- The TLS handshake is forced explicitly (not left to the JDK's lazy on-first-read trigger) + before any protocol decision is made, and is bounded by `headerReadTimeoutMs`. +- When a listener's `TlsConfig.applicationProtocols` includes `"h2"`, the enabled TLS 1.2 cipher + suite list is filtered against the RFC 9113 Appendix A blocklist + (`TlsConfig.TLS12_H2_BLOCKED_CIPHERS`, ~280 entries). TLS 1.3 is never affected — none of its + cipher suites are on that list. +- HTTP/2 itself is not yet served in this phase (lands in Phase 8): a connection that negotiates + `h2` via ALPN, or that opens with the h2c prior-knowledge preface while + `FlashConfiguration.http2Enabled` is set, is currently closed cleanly rather than served. diff --git a/flash/docs/http2/IMPLEMENTATION-PLAN.md b/flash/docs/http2/IMPLEMENTATION-PLAN.md index 12d61f0..dada6e3 100644 --- a/flash/docs/http2/IMPLEMENTATION-PLAN.md +++ b/flash/docs/http2/IMPLEMENTATION-PLAN.md @@ -62,7 +62,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`. | Phase | Status | Branch/PR | Notes | |---|---|---|---| | 0 — Groundwork | done | `feature/core/http2` | Package skeleton, `Http2Limits`, `Http1Limits`, `Http2ErrorCode`, `Http2Exception`/`Http2StreamException`, `DECISIONS.md` (`DEC-01`…`DEC-11`), `package-info.java`. 226/226 tests green. | -| 1 — HTTP/1.1 hardening + ALPN/preface | not started | — | — | +| 1 — HTTP/1.1 hardening + ALPN/preface | done | `feature/core/http2` | EX-02/03/07/08/10/17/18/30/31 fixed; EX-35/36 found+fixed. `BufferedByteSource`, `ProtocolNegotiator`, `MalformedRequestException` added (plan corrected, DEC-12). 277/277 tests green (run twice). h1 benchmark check deferred — no JMH harness until Phase 3 (documented in DoD). | | 2 — Transport decomposition | not started | — | — | | 3 — Serialized frame writer (GO/NO-GO gate) | not started | — | — | | 4 — Byte-layer foundations | not started | — | — | @@ -608,6 +608,28 @@ composed transport rather than a god object. package-private `TransportFactory`. **Phase**: 2. +### EX-35 — `Transfer-Encoding` multi-value handling drops the message boundary silently +Found while implementing `EX-02` in `RequestParser.java`'s header-scan loop (the exact code that +decides `isChunked`). The pre-existing check was `equalsIgnoreCase(buffer, valueStart, lineEnd, +"chunked")` — an exact **whole-value** comparison. RFC 9112 §6.1 requires only that `chunked` be +the **final** coding in a comma-separated list (e.g. `Transfer-Encoding: gzip, chunked` is valid +and self-delimiting). The old check silently treated any such multi-coding value as *not* +chunked at all — `isChunked` stayed `false`, `contentLength` stayed `0`, and the body bytes that +followed were left for the next `parse()` call to misinterpret as the start of a new request: +a real message-boundary corruption, not just a missed feature. +**Fix**: parse the comma-separated token list and inspect only the last token +(`RequestParser.isFinalCodingChunked`). A value whose final coding is not `chunked` is now +rejected with `501` (`EX-02`'s own fix), rather than silently misparsed. +**Phase**: 1. + +### EX-36 — A header line without a `:` was silently skipped instead of rejected +Found in the same loop as `EX-18`/`EX-35`. `RequestParser`'s header-line loop located the colon +via `find(...)` and, if none was found (`colon == -1`), simply did nothing for that line and +moved on to the next — a malformed header line was permissively ignored rather than rejected. +RFC 9112 §5 gives no such leniency: a header field line without a colon is not valid HTTP. +**Fix**: `colon == -1` now rejects the request with `400 Bad Request`. +**Phase**: 1. + --- # PART III — The phases @@ -800,7 +822,9 @@ is unreadable) blocks every h2 phase, and fixing it is the natural companion to seam. ### EX items -`EX-02`, `EX-03`, `EX-07`, `EX-08`, `EX-10`, `EX-17`, `EX-18`, `EX-30`, `EX-31`. +`EX-02`, `EX-03`, `EX-07`, `EX-08`, `EX-10`, `EX-17`, `EX-18`, `EX-30`, `EX-31`, plus two found +while implementing this phase and registered in Part II per R10: `EX-35` (multi-value +`Transfer-Encoding` silently misparsed), `EX-36` (a header line with no `:` silently skipped). ### Files @@ -813,9 +837,19 @@ Modified: - `flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java` Created: -- `flash/src/main/java/dev/relism/flash/http/Http1Limits.java` (from Phase 0) +- `flash/src/main/java/dev/relism/flash/http/Http1Limits.java` (from Phase 0; extended here with + the chunked-transfer bounds for `EX-10`'s safety task) - `flash/src/main/java/dev/relism/flash/transport/ProtocolNegotiator.java` - `flash/src/main/java/dev/relism/flash/transport/NegotiatedProtocol.java` (enum: `HTTP_1_1`, `H2`) +- `flash/src/main/java/dev/relism/flash/transport/BufferedByteSource.java` — **plan correction**: + task 8 below requires this class (the buffered, deadline-aware, peekable source `EX-10`'s fix + and `EX-07`'s absolute-deadline requirement both need), but it was missing from this phase's + original Files list. Added here; recorded as `DEC-12` in `DECISIONS.md`. +- `flash/src/main/java/dev/relism/flash/exceptions/MalformedRequestException.java` — likewise + not originally listed: the typed, status-carrying rejection `EX-02`/`EX-03`/`EX-08`/`EX-18` + all need to tell `HttpServer` which status to respond with, as distinct from + `HttpException` (which routes through the user's handler chain — a malformed request must + not). Recorded alongside `DEC-12`. ### Tasks @@ -900,18 +934,18 @@ Created: already has one. ### Safety checks (checklist — all mandatory) -- [ ] `Content-Length` strict-numeric, bounded, single-valued -- [ ] `Content-Length` + `Transfer-Encoding` rejected -- [ ] Non-`chunked` final transfer coding rejected -- [ ] Bare CR / missing LF rejected -- [ ] obs-fold (leading whitespace continuation line) rejected -- [ ] Header name `tchar` validated -- [ ] Header count / name length / value length / request-line length bounded -- [ ] Chunk size, chunk count, chunk-extension length, trailer count bounded -- [ ] Header-read absolute deadline enforced (not just `setSoTimeout`) -- [ ] Idle keep-alive timeout enforced -- [ ] Body-read timeout enforced -- [ ] TLS handshake covered by a timeout +- [x] `Content-Length` strict-numeric, bounded, single-valued — `RequestParserSecurityTest` +- [x] `Content-Length` + `Transfer-Encoding` rejected, regardless of order — `RequestParserSecurityTest` +- [x] Non-`chunked` final transfer coding rejected — `RequestParserSecurityTest` +- [x] Bare CR / missing LF rejected — `RequestParserSecurityTest` +- [x] obs-fold (leading whitespace continuation line) rejected — `RequestParserSecurityTest` +- [x] Header name `tchar` validated — `RequestParserSecurityTest` +- [x] Header count / name length / value length / request-line length bounded — `RequestParserSecurityTest` +- [x] Chunk size, chunk count, chunk-extension length, trailer count bounded — `ChunkedInputStreamTest` +- [x] Header-read absolute deadline enforced (not just `setSoTimeout`) — `HttpServerTimeoutTest.slowlorisHeaderDribble_disconnectedWithinHeaderReadTimeout` +- [x] Idle keep-alive timeout enforced — `HttpServerTimeoutTest.idleKeepAliveConnection_disconnectedWithinIdleTimeout` +- [x] Body-read timeout enforced — `HttpServerTimeoutTest.slowBodyDribble_disconnectedWithinBodyReadTimeout` +- [x] TLS handshake covered by a timeout — `HttpServerTimeoutTest.tlsHandshakeNeverStarted_disconnectedWithinHeaderReadTimeout` ### Tests - `RequestParserSecurityTest` — one test per rejection above, each asserting both the status @@ -933,11 +967,21 @@ Created: operators can understand a `400` in their logs. ### DoD -- [ ] Every checklist item above is implemented and tested. -- [ ] `mvn test` green. -- [ ] No behavioural change to well-formed HTTP/1.1 traffic (verified by the existing test - suite passing unmodified). -- [ ] h1 benchmark shows no regression beyond noise (baseline captured before the phase). +- [x] Every checklist item above is implemented and tested. +- [x] `mvn test` green. Full `flash` module: 277/277, run twice in a row for timing-test stability + (the four `HttpServerTimeoutTest` cases are wall-clock-based). +- [x] No behavioural change to well-formed HTTP/1.1 traffic (verified by the existing test + suite passing unmodified — the only test-file edits were signature updates for + `RequestParser.parse(BufferedByteSource)` and exception-type/status updates for the small + number of existing tests that asserted the pre-fix buggy behaviour, e.g. a 5 GB + `Content-Length` being silently accepted, or an unrecognised method producing a bare + `IOException` instead of a typed `501`; each such change is called out in the Phase 1 + commit). +- [ ] h1 benchmark shows no regression beyond noise (baseline captured before the phase). **Not + verified — no JMH harness exists yet; it is a Phase 3 deliverable.** Left unchecked + rather than claimed. Once Phase 3 adds the harness, an h1 GET benchmark should be run + against the pre-Phase-1 commit and against this one before Phase 3 is considered started, + so this box can be resolved retroactively. --- diff --git a/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java b/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java index 492dc7d..bb89eeb 100644 --- a/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java +++ b/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java @@ -1,24 +1,35 @@ package dev.relism.flash; -import java.io.ByteArrayInputStream; +import dev.relism.flash.exceptions.MalformedRequestException; +import dev.relism.flash.http.Http1Limits; +import dev.relism.flash.transport.BufferedByteSource; + import java.io.IOException; import java.io.InputStream; -import java.io.SequenceInputStream; /** * De-chunking {@link InputStream} for HTTP/1.1 {@code Transfer-Encoding: chunked} request bodies. * Handles pre-buffered bytes from the header read-ahead, chunk framing, and trailer consumption. * Returns -1 at end of the final chunk; the underlying socket is left positioned for the next request. + * + *
{@code EX-10}: reads through the connection's shared {@link BufferedByteSource} + * instead of the raw, unbuffered socket stream. Chunk-size digits, the trailing CRLF after each + * chunk, and trailer lines are all read one byte at a time by design (the framing is + * byte-oriented) — that used to mean one {@code read(2)} syscall per byte on the raw socket; + * against {@link BufferedByteSource} it is a read from an already-filled in-memory buffer. + * The header-parser's read-ahead bytes are handed to {@code src} via + * {@link BufferedByteSource#prependOnce}, replacing the {@code SequenceInputStream}/ + * {@code ByteArrayInputStream} pair the previous implementation allocated per chunked request. */ final class ChunkedInputStream extends InputStream { - private final InputStream src; + private final BufferedByteSource src; private int chunkRemaining = 0; private boolean done = false; + private int chunksSeen = 0; - ChunkedInputStream(InputStream socket, byte[] preBuf, int preBufOff, int preBufLen) { - src = preBufLen > 0 - ? new SequenceInputStream(new ByteArrayInputStream(preBuf, preBufOff, preBufLen), socket) - : socket; + ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen) { + this.src = src; + if (preBufLen > 0) src.prependOnce(preBuf, preBufOff, preBufLen); } @Override @@ -29,7 +40,7 @@ final class ChunkedInputStream extends InputStream { if (chunkRemaining == 0) { consumeTrailers(); done = true; return -1; } } int b = src.read(); - if (b >= 0 && --chunkRemaining == 0) { src.read(); src.read(); } // consume trailing \r\n + if (b >= 0 && --chunkRemaining == 0) consumeChunkTerminator(); return b; } @@ -43,30 +54,99 @@ final class ChunkedInputStream extends InputStream { int n = src.read(buf, off, Math.min(len, chunkRemaining)); if (n > 0) { chunkRemaining -= n; - if (chunkRemaining == 0) { src.read(); src.read(); } // consume trailing \r\n + if (chunkRemaining == 0) consumeChunkTerminator(); } return n; } + /** Validates and consumes the CRLF that terminates every chunk's data (RFC 9112 §7.1.1). */ + private void consumeChunkTerminator() throws IOException { + int cr = src.read(); + int lf = src.read(); + if (cr != '\r' || lf != '\n') { + throw new MalformedRequestException(400, "Malformed chunk terminator"); + } + } + + /** + * Reads one chunk-size line: hex digits, an optional {@code ;}-prefixed chunk-extension + * (discarded — RFC 9112 §7.1.1 permits ignoring extensions this server does not recognise), + * then CRLF. Bounded per {@code Http1Limits} against: more than 16 hex digits (a chunk size + * cannot legitimately need more — {@code Long.MAX_VALUE} is 16 hex digits), a size above + * {@link Http1Limits#MAX_CHUNK_SIZE}, an extension longer than + * {@link Http1Limits#MAX_CHUNK_EXT_LENGTH}, and more than + * {@link Http1Limits#MAX_CHUNKS_PER_BODY} chunks per body — all defences against a peer + * that is technically well-formed but deliberately expensive to parse. + */ private int readChunkSize() throws IOException { + if (++chunksSeen > Http1Limits.MAX_CHUNKS_PER_BODY) { + throw new MalformedRequestException(413, "Too many chunks"); + } + long size = 0; - int b; - while ((b = src.read()) != -1) { - if (b >= '0' && b <= '9') size = (size << 4) | (b - '0'); - else if (b >= 'a' && b <= 'f') size = (size << 4) | (b - 'a' + 10); - else if (b >= 'A' && b <= 'F') size = (size << 4) | (b - 'A' + 10); - else { while ((b = src.read()) != -1 && b != '\n'); break; } // ext or \r\n - if (size > Integer.MAX_VALUE) throw new IOException("Chunk size exceeds 2 GB limit"); + int digits = 0; + int b = src.read(); + while (isHexDigit(b)) { + if (++digits > 16) throw new MalformedRequestException(400, "Chunk size line too long"); + size = (size << 4) | hexValue(b); + if (size > Http1Limits.MAX_CHUNK_SIZE) { + throw new MalformedRequestException(413, "Chunk size exceeds configured maximum"); + } + b = src.read(); + } + if (digits == 0) throw new MalformedRequestException(400, "Malformed chunk size"); + + int extLen = 0; + while (b != -1 && b != '\r') { + if (++extLen > Http1Limits.MAX_CHUNK_EXT_LENGTH) { + throw new MalformedRequestException(400, "Chunk extension too long"); + } + b = src.read(); + } + if (b != '\r' || src.read() != '\n') { + throw new MalformedRequestException(400, "Malformed chunk size line terminator"); } return (int) size; } - // Reads and discards trailer headers until the empty line that terminates the chunked body. + private static boolean isHexDigit(int b) { + return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F'); + } + + private static int hexValue(int b) { + if (b <= '9') return b - '0'; + if (b <= 'F') return b - 'A' + 10; + return b - 'a' + 10; + } + + /** + * Reads and discards trailer headers until the empty line that terminates the chunked body + * (RFC 9112 §7.1.2). Bounded by {@link Http1Limits#MAX_TRAILER_COUNT} and + * {@link Http1Limits#MAX_HEADER_VALUE_LENGTH} — without a bound, a peer could follow the + * final chunk with an unbounded trailer section purely to waste CPU discarding it. Trailers + * are discarded, not exposed to the handler; exposing them is Phase 12 scope + * ({@code Request.trailers()}). + */ private void consumeTrailers() throws IOException { + int trailerCount = 0; while (true) { int b = src.read(); - if (b == -1 || b == '\r') { src.read(); return; } // empty line — done - while ((b = src.read()) != -1 && b != '\n'); // skip non-empty trailer line + if (b == -1) return; // EOF mid-trailers — nothing left to bound. + if (b == '\r') { + if (src.read() != '\n') { + throw new MalformedRequestException(400, "Malformed trailer section terminator"); + } + return; // empty line — trailer section done + } + if (++trailerCount > Http1Limits.MAX_TRAILER_COUNT) { + throw new MalformedRequestException(431, "Too many trailers"); + } + int lineLen = 1; + while ((b = src.read()) != -1 && b != '\n') { + if (++lineLen > Http1Limits.MAX_HEADER_VALUE_LENGTH) { + throw new MalformedRequestException(431, "Trailer line too long"); + } + } } } } diff --git a/flash/src/main/java/dev/relism/flash/HttpServer.java b/flash/src/main/java/dev/relism/flash/HttpServer.java index d352134..b068aa9 100644 --- a/flash/src/main/java/dev/relism/flash/HttpServer.java +++ b/flash/src/main/java/dev/relism/flash/HttpServer.java @@ -1,5 +1,6 @@ package dev.relism.flash; +import dev.relism.flash.exceptions.MalformedRequestException; import dev.relism.flash.extension.FlashApp; import dev.relism.flash.extension.FlashConfiguration; import dev.relism.flash.http.ContentType; @@ -11,6 +12,9 @@ import dev.relism.flash.models.Response; import dev.relism.flash.routing.AbstractRouter; import dev.relism.flash.routing.AbstractWsRouter; import dev.relism.flash.tls.TlsConfig; +import dev.relism.flash.transport.BufferedByteSource; +import dev.relism.flash.transport.NegotiatedProtocol; +import dev.relism.flash.transport.ProtocolNegotiator; import dev.relism.flash.websocket.WebSocketFrame; import dev.relism.flash.websocket.WebSocketHandler; import dev.relism.flash.websocket.WebSocketSession; @@ -25,6 +29,7 @@ import java.io.*; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; +import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -275,7 +280,6 @@ class HttpServer implements ServerHandle { executorService.submit(() -> { activeSockets.add(socket); try (socket; - InputStream in = socket.getInputStream(); OutputStream out = new BufferedOutputStream(socket.getOutputStream())) { // TCP_NODELAY: disable Nagle's algorithm. @@ -286,6 +290,18 @@ class HttpServer implements ServerHandle { socket.setTcpNoDelay(true); socket.setSendBufferSize(SOCKET_BUF_SIZE); + // EX-30: force the TLS handshake explicitly, under a bounded timeout, + // before any protocol decision is made. SSLSocket#getApplicationProtocol() + // (which ProtocolNegotiator relies on) returns null until the handshake has + // actually completed; nothing previously forced that before the first read, + // which happened to work by accident (the JDK triggers it lazily on read) + // but left ALPN unreadable at exactly the point negotiation needs it. + if (socket instanceof SSLSocket sslSocketForHandshake) { + socket.setSoTimeout(configuration.getHeaderReadTimeoutMs()); + sslSocketForHandshake.startHandshake(); + socket.setSoTimeout(0); // BufferedByteSource's deadline takes over below + } + // rawOut is the unbuffered socket stream — passed to WebSocketSession // directly. WS writes are already bulk (header + payload in two calls); // with TCP_NODELAY the kernel ships them without Nagle delay, so no @@ -295,16 +311,71 @@ class HttpServer implements ServerHandle { // userspace coalescing before a single syscall. OutputStream rawOut = socket.getOutputStream(); + // EX-10: the single buffered, deadline-aware view over this connection's + // inbound bytes — see BufferedByteSource's Javadoc. Not part of the + // try-with-resources list above because closing `socket` already closes + // the stream it wraps (same reasoning that already applied to rawOut). + BufferedByteSource in = new BufferedByteSource(socket.getInputStream(), socket); + + NegotiatedProtocol negotiated = negotiateProtocol(socket, in); + if (negotiated == NegotiatedProtocol.H2) { + // No Http2Connection exists yet (lands in Phase 8) — close cleanly + // rather than attempt to speak a protocol this version cannot serve. + return; + } + RequestParser parser = new RequestParser( configuration.getMaxHeaderBufferSize(), (InetSocketAddress) socket.getRemoteSocketAddress(), socket instanceof SSLSocket sslSocket ? sslSocket : null); + byte[] idleProbe = new byte[1]; + while (!stopped) { - Request request = parser.parse(in); + // EX-07: wait for the next request to begin, bounded by the generous + // idle-keep-alive timeout — sitting idle between keep-alive requests is + // normal, not an attack. peek() lets us detect "bytes have started + // arriving" without handing them to the parser under the wrong deadline. + // + // Skipped entirely when the parser already has bytes buffered from a + // previous read (HTTP pipelining: a client that sent two requests back + // to back before reading either response). In that case the next + // request has, by definition, already started — peeking the *source* + // for a fresh byte would wait for something that is never coming there, + // since it already arrived and is sitting in the parser's own buffer. + if (!parser.hasBufferedBytes()) { + in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L); + int firstByteSeen; + try { + firstByteSeen = in.peek(idleProbe, 0, 1); + } catch (SocketTimeoutException e) { + break; // idle timeout — nothing pending; close quietly, like EOF + } + if (firstByteSeen <= 0) break; // clean EOF + } + + // Bytes have started arriving: tighten to the slowloris-specific bound + // for the rest of the header block. A per-read SO_TIMEOUT alone would + // never trip here — see BufferedByteSource's Javadoc. + in.setDeadline(System.nanoTime() + configuration.getHeaderReadTimeoutMs() * 1_000_000L); + Request request; + try { + request = parser.parse(in); + } catch (MalformedRequestException e) { + // EX-02/03/08/18: a fixed, minimal, non-customizable rejection — + // never routed through the handler or the user's exception handler + // (see MalformedRequestException's Javadoc) — and the connection is + // always closed afterwards, never kept alive. + Response rejection = new Response(e.status(), e.getMessage(), ContentType.TEXT_PLAIN); + writeResponse(out, rejection, false); + break; + } catch (SocketTimeoutException e) { + break; // header-read deadline exceeded — close + } if (request == null) break; if (request.method() == HttpMethod.GET && isWebSocketUpgrade(request)) { + in.clearDeadline(); // the WS session loop is long-lived; it paces itself WebSocketHandler wsHandler = wsRouter.route(request); if (wsHandler == null) { out.write(WS_REJECT_400); @@ -323,6 +394,11 @@ class HttpServer implements ServerHandle { return; } + // Headers are fully read; the body (if any) may still be pending — + // whether the handler consumes it or the automatic drain() below does, + // bound it by the same deadline (EX-07). + in.setDeadline(System.nanoTime() + configuration.getBodyReadTimeoutMs() * 1_000_000L); + boolean keepAlive = isKeepAlive(request); Response response = new Response(200, ContentType.TEXT_PLAIN); @@ -341,6 +417,7 @@ class HttpServer implements ServerHandle { writeResponse(out, response, keepAlive); request.drain(); + in.clearDeadline(); if (!keepAlive) break; } @@ -368,6 +445,32 @@ class HttpServer implements ServerHandle { } } + // ── Protocol negotiation ─────────────────────────────────────────────────── + + /** + * Decides h1 vs h2 for one connection, applying {@link FlashConfiguration#isHttp2Enabled()} + * to the plaintext (h2c) path — see {@link ProtocolNegotiator}'s Javadoc for why the flag is + * applied here rather than inside the negotiator itself. TLS/ALPN detection costs nothing + * (the handshake already resolved it) and is therefore always performed, regardless of the + * flag: what the flag gates is whether Flash even attempts the h2c preface peek on a + * plaintext socket, so that a plaintext connection with the feature left at its default + * behaves byte-for-byte like pre-HTTP/2 Flash. + */ + private NegotiatedProtocol negotiateProtocol(Socket socket, BufferedByteSource in) throws IOException { + if (socket instanceof SSLSocket) { + return ProtocolNegotiator.negotiate(socket, in); + } + if (!configuration.isHttp2Enabled()) { + return NegotiatedProtocol.HTTP_1_1; + } + in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L); + try { + return ProtocolNegotiator.negotiate(socket, in); + } finally { + in.clearDeadline(); + } + } + // ── WebSocket upgrade detection (zero-alloc) ────────────────────────────── private static boolean isWebSocketUpgrade(Request request) { diff --git a/flash/src/main/java/dev/relism/flash/RequestParser.java b/flash/src/main/java/dev/relism/flash/RequestParser.java index 1a50a25..c1a2d99 100644 --- a/flash/src/main/java/dev/relism/flash/RequestParser.java +++ b/flash/src/main/java/dev/relism/flash/RequestParser.java @@ -1,17 +1,19 @@ package dev.relism.flash; +import dev.relism.flash.exceptions.MalformedRequestException; +import dev.relism.flash.http.Http1Limits; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.models.HeaderMap; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestLine; import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews; +import dev.relism.flash.transport.BufferedByteSource; import lombok.extern.slf4j.Slf4j; import javax.net.ssl.SSLSocket; import java.io.IOException; -import java.io.InputStream; import java.net.InetSocketAddress; import java.util.Arrays; @@ -35,11 +37,41 @@ import java.util.Arrays; * to the next request. They are snapshotted at the top of {@link #parse} * and reset to {@code 0/0} before any work begins, so an exception thrown mid-parse * leaves the fields clean rather than pointing at stale data from a previous request. + * + *
Distinct from {@link HttpException}, which a handler throws to describe an + * application-level failure and which is routed through the user's configured exception + * handler ({@code AbstractRouter.getExceptionHandler()}). A malformed request never reaches a + * handler, or middleware, or the user's exception handler at all: it is rejected by the + * transport itself, with a fixed, minimal, non-customizable response, and the connection is + * always closed afterwards — never kept alive. Keeping a connection alive after a rejected + * request is exactly the situation a smuggling attempt exploits (a rejected first request + * hiding a crafted second one in the same TCP stream), so the transport never offers that + * choice to user code. + */ +public class MalformedRequestException extends HttpException { + + public MalformedRequestException(int status, String message) { + super(status, message); + } +} diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java b/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java index 5a8745c..14cdb7e 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java @@ -57,6 +57,58 @@ public class FlashConfiguration { @Builder.Default int wsFrameBufferSize = 64 * 1024; + /** + * Maximum time, in milliseconds, allowed for a request's headers to be fully read once the + * first byte of it has arrived. Bounds the classic slowloris attack: a peer that trickles + * one header byte every few seconds forever. Enforced by an absolute deadline + * (see {@code dev.relism.flash.transport.BufferedByteSource}), not merely a per-read socket + * timeout — a per-read timeout alone never trips as long as each individual read succeeds + * within the window, no matter how long the overall header block takes. Default: 10 000 + * ({@code EX-07}). + */ + @Builder.Default + int headerReadTimeoutMs = 10_000; + + /** + * Maximum time, in milliseconds, a keep-alive connection may sit idle waiting for its next + * request before being closed. More generous than {@link #headerReadTimeoutMs} because an + * idle keep-alive connection is normal, expected behaviour, not an attack in progress — the + * tighter bound applies only once bytes have actually started arriving. Default: 60 000 + * ({@code EX-07}). + */ + @Builder.Default + int idleKeepAliveTimeoutMs = 60_000; + + /** + * Maximum time, in milliseconds, a request's body may take to be fully read (by the handler + * or by the automatic drain after it returns) once headers are parsed. Default: 30 000 + * ({@code EX-07}). + */ + @Builder.Default + int bodyReadTimeoutMs = 30_000; + + /** + * Maximum time, in milliseconds, {@link dev.relism.flash.ServerHandle#stop()} waits for + * in-flight requests to finish after it stops accepting new connections, before force- + * closing whatever remains. Default: 15 000 ({@code EX-32} — the graceful two-stage + * shutdown this bounds is wired up starting Phase 2). + */ + @Builder.Default + int shutdownDrainTimeoutMs = 15_000; + + /** + * Whether this server will ever negotiate HTTP/2. Default {@code false}: until the h2 + * connection state machine exists (Phase 8) there is nothing to negotiate into, so this + * flag currently only gates the h2c cleartext-preface detection + * ({@code dev.relism.flash.transport.ProtocolNegotiator}) — skipping it entirely keeps + * plaintext connections byte-for-byte identical to pre-HTTP/2 Flash when left at its + * default. TLS/ALPN connections are always detected accurately regardless of this flag + * (that costs nothing — see {@code ProtocolNegotiator}'s Javadoc) but are cleanly rejected + * rather than served until the phases that implement HTTP/2 land. + */ + @Builder.Default + boolean http2Enabled = false; + /** One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS. */ public record Listener(int port, String host, TlsConfig tls) { public Listener(int port) { this(port, null, null); } diff --git a/flash/src/main/java/dev/relism/flash/http/Http1Limits.java b/flash/src/main/java/dev/relism/flash/http/Http1Limits.java index c01b21c..61fb98e 100644 --- a/flash/src/main/java/dev/relism/flash/http/Http1Limits.java +++ b/flash/src/main/java/dev/relism/flash/http/Http1Limits.java @@ -24,8 +24,15 @@ public final class Http1Limits { * resource-exhaustion vector for any code path that pre-sizes a buffer from it. Requests * declaring a length above this are rejected with {@code 413 Payload Too Large} before any * body byte is read. + * + *
4 GiB — generous enough for legitimate large uploads (Flash is a general-purpose + * server, not an API-only framework with a tiny default), while still bounding a hostile + * peer to a finite, known-in-advance number rather than the effectively unbounded + * {@code Long.MAX_VALUE} the parser accepted before this limit existed. Comfortably above + * {@code Integer.MAX_VALUE} (~2.1 billion) so legitimate very-large declared lengths are + * not confused with the int-overflow bug this same fix (EX-03) also closes. */ - public static final long MAX_CONTENT_LENGTH = 100L * 1024 * 1024; + public static final long MAX_CONTENT_LENGTH = 4L * 1024 * 1024 * 1024; /** * Maximum number of header lines accepted in a single request. Without this bound, a @@ -55,4 +62,38 @@ public final class Http1Limits { * into the generic header-block-too-large case. */ public static final int MAX_REQUEST_LINE_LENGTH = 8_192; + + /** + * Maximum size, in bytes, of a single {@code Transfer-Encoding: chunked} chunk. + * {@code ChunkedInputStream.readChunkSize} previously accepted any value up to 2 GiB before + * rejecting it; a hostile peer can advertise a huge chunk size and then trickle bytes, + * forcing the connection to stay open far longer than any legitimate chunk would need + * (bounded separately by {@code bodyReadTimeoutMs}, but this limit catches the size claim + * itself before that timeout would). + */ + public static final long MAX_CHUNK_SIZE = 16L * 1024 * 1024; + + /** + * Maximum length, in bytes, of the chunk-extension section (the optional + * {@code ;name=value} data after a chunk size and before its CRLF, RFC 9112 §7.1.1). Flash + * does not interpret chunk extensions; without a bound, a peer could send an arbitrarily + * long extension on every chunk purely to waste CPU discarding it. + */ + public static final int MAX_CHUNK_EXT_LENGTH = 256; + + /** + * Maximum number of chunks accepted in a single request body. Without this bound, a peer + * can send an unbounded number of minimal (or zero-length) chunks, each cheap individually + * but collectively forcing unbounded per-chunk framing work — a "death by a thousand + * chunks" variant of a slow-body attack. + */ + public static final int MAX_CHUNKS_PER_BODY = 100_000; + + /** + * Maximum number of trailer header lines accepted after the final chunk of a chunked body + * (RFC 9112 §7.1.2). Bounded for the same reason {@link #MAX_HEADER_COUNT} bounds the + * regular header section; trailer values are separately bounded by + * {@link #MAX_HEADER_VALUE_LENGTH}. + */ + public static final int MAX_TRAILER_COUNT = 50; } diff --git a/flash/src/main/java/dev/relism/flash/http/HttpStatus.java b/flash/src/main/java/dev/relism/flash/http/HttpStatus.java index 58bafba..bb502c4 100644 --- a/flash/src/main/java/dev/relism/flash/http/HttpStatus.java +++ b/flash/src/main/java/dev/relism/flash/http/HttpStatus.java @@ -37,24 +37,42 @@ public enum HttpStatus { CONFLICT (409, "Conflict"), GONE (410, "Gone"), LENGTH_REQUIRED (411, "Length Required"), + PRECONDITION_FAILED (412, "Precondition Failed"), PAYLOAD_TOO_LARGE (413, "Payload Too Large"), URI_TOO_LONG (414, "URI Too Long"), UNSUPPORTED_MEDIA_TYPE (415, "Unsupported Media Type"), + RANGE_NOT_SATISFIABLE (416, "Range Not Satisfiable"), + EXPECTATION_FAILED (417, "Expectation Failed"), + MISDIRECTED_REQUEST (421, "Misdirected Request"), UNPROCESSABLE_ENTITY (422, "Unprocessable Entity"), TOO_MANY_REQUESTS (429, "Too Many Requests"), + REQUEST_HEADER_FIELDS_TOO_LARGE (431, "Request Header Fields Too Large"), // 5xx INTERNAL_SERVER_ERROR (500, "Internal Server Error"), NOT_IMPLEMENTED (501, "Not Implemented"), BAD_GATEWAY (502, "Bad Gateway"), SERVICE_UNAVAILABLE (503, "Service Unavailable"), - GATEWAY_TIMEOUT (504, "Gateway Timeout"); + GATEWAY_TIMEOUT (504, "Gateway Timeout"), + HTTP_VERSION_NOT_SUPPORTED (505, "HTTP Version Not Supported"), + INSUFFICIENT_STORAGE (507, "Insufficient Storage"), + NETWORK_AUTHENTICATION_REQUIRED (511, "Network Authentication Required"); - private static final int MAX_STATUS_CODE = 504; - private static final byte[][] INDEX = new byte[MAX_STATUS_CODE + 1][]; - private static final String[] REASONS = new String[MAX_STATUS_CODE + 1]; + // EX-17: the bound used to be the hand-maintained constant 504, which silently threw + // ArrayIndexOutOfBoundsException from this static initializer the moment any constant + // above it (421, 431, 505, 507, 511 — several of which HTTP/2 needs, see MISDIRECTED_REQUEST + // and REQUEST_HEADER_FIELDS_TOO_LARGE above) was added. Computed from values() instead, so + // adding a status code can never silently break class loading again. + private static final int MAX_STATUS_CODE; + private static final byte[][] INDEX; + private static final String[] REASONS; static { + int max = 0; + for (HttpStatus s : values()) max = Math.max(max, s.code); + MAX_STATUS_CODE = max; + INDEX = new byte[MAX_STATUS_CODE + 1][]; + REASONS = new String[MAX_STATUS_CODE + 1]; for (HttpStatus s : values()) { INDEX[s.code] = s.bytes; REASONS[s.code] = s.reason; diff --git a/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java b/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java index 16935c2..5006019 100644 --- a/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java +++ b/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java @@ -14,6 +14,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; /** * Declarative TLS configuration for a {@link dev.relism.flash.extension.FlashConfiguration.Listener}. @@ -50,6 +53,309 @@ public final class TlsConfig { private static final String[] SECURE_PROTOCOLS = { "TLSv1.3", "TLSv1.2" }; + /** + * {@code EX-31}: RFC 9113 §9.2.2 requires that an HTTP/2 endpoint MUST NOT use any of these + * cipher suites over TLS 1.2 (the list is unchanged from RFC 7540 Appendix A, which 9113 + * carries forward verbatim), and that it MUST support at least + * {@code TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}. TLS 1.3 is unaffected: none of its cipher + * suites (the {@code TLS_AES_*}/{@code TLS_CHACHA20_*} identifiers) appear here, since + * TLS 1.3 removed static/non-ephemeral key exchange and CBC-mode ciphers entirely — the + * exact property this blocklist exists to enforce for TLS 1.2. + * + *
Transcribed from Go's {@code golang.org/x/net/http2} {@code isBadCipher} table
+ * (BSD-licensed, itself an implementation of this exact RFC 9113 requirement, cross-checked
+ * against the IANA TLS Cipher Suite registry) rather than by hand from the RFC text, for the
+ * same reason Appendix D of {@code flash/docs/http2/IMPLEMENTATION-PLAN.md} insists the HPACK
+ * static table be transcribed from the RFC directly and verified: a transcription error in a
+ * ~280-entry list is easy to make and easy to miss, and here the failure mode is silently
+ * permitting a cipher suite RFC 9113 requires rejecting. Built once, in a static
+ * initializer (R4) — never reconstructed per connection.
+ */
+ private static final Set Only valid before anything has been {@link #prependOnce prepended} — in practice this
+ * means it is only ever called once, by {@code ProtocolNegotiator}, at the very start of a
+ * connection before any other read.
+ *
+ * @throws IllegalArgumentException if {@code len} exceeds the internal buffer's capacity —
+ * this class cannot peek further ahead than it buffers.
+ */
+ public int peek(byte[] dst, int off, int len) throws IOException {
+ if (len > buf.length) {
+ throw new IllegalArgumentException(
+ "peek length " + len + " exceeds buffer capacity " + buf.length);
+ }
+ if (prefixLen > 0) {
+ throw new IllegalStateException(
+ "peek() is only valid before any bytes have been prepended to this source");
+ }
+ while (limit - pos < len) {
+ if (pos > 0) {
+ System.arraycopy(buf, pos, buf, 0, limit - pos);
+ limit -= pos;
+ pos = 0;
+ }
+ int n = fillFromUnderlying(buf, limit, buf.length - limit);
+ if (n <= 0) break;
+ limit += n;
+ }
+ int available = Math.min(len, limit - pos);
+ System.arraycopy(buf, pos, dst, off, available);
+ return available;
+ }
+
+ /**
+ * Queues {@code len} bytes, starting at {@code off} in the caller-owned array {@code src},
+ * to be served by the next reads before anything else — zero allocation and zero
+ * copy, since {@code src} is referenced directly, not duplicated. The caller must not
+ * mutate {@code src[off..off+len)} until the prefix is fully consumed.
+ *
+ * Exactly one prefix may be pending at a time. This is intentional: it exists solely to
+ * hand {@code RequestParser}'s header-buffer read-ahead bytes to a fresh
+ * {@code ChunkedInputStream} at the start of a chunked body, a single well-defined moment
+ * per request — it is not a general-purpose pushback stack.
+ *
+ * @throws IllegalStateException if a prefix is already pending
+ */
+ public void prependOnce(byte[] src, int off, int len) {
+ if (prefixLen > 0) {
+ throw new IllegalStateException("a prefix is already pending on this source");
+ }
+ this.prefixBuf = src;
+ this.prefixPos = off;
+ this.prefixLen = len;
+ }
+
+ // ── Internal fill ────────────────────────────────────────────────────────
+
+ /**
+ * The only place this class ever touches the underlying socket stream. When a deadline is
+ * active, computes the exact remaining budget and hands it to {@link Socket#setSoTimeout}
+ * before reading, so a {@link SocketTimeoutException} from {@code in.read} unambiguously
+ * means the deadline — not merely one read — has elapsed; see the class Javadoc.
+ */
+ private int fillFromUnderlying(byte[] dst, int off, int len) throws IOException {
+ if (!deadlineActive) {
+ return in.read(dst, off, len);
+ }
+ long remainingNanos = deadlineNanos - System.nanoTime();
+ if (remainingNanos <= 0) {
+ throw new SocketTimeoutException("Read deadline exceeded");
+ }
+ long remainingMillis = (remainingNanos + 999_999L) / 1_000_000L; // round up
+ int timeoutMs = (int) Math.max(1, Math.min(Integer.MAX_VALUE, remainingMillis));
+ socket.setSoTimeout(timeoutMs);
+ return in.read(dst, off, len);
+ }
+}
diff --git a/flash/src/main/java/dev/relism/flash/transport/NegotiatedProtocol.java b/flash/src/main/java/dev/relism/flash/transport/NegotiatedProtocol.java
new file mode 100644
index 0000000..7d477a4
--- /dev/null
+++ b/flash/src/main/java/dev/relism/flash/transport/NegotiatedProtocol.java
@@ -0,0 +1,10 @@
+package dev.relism.flash.transport;
+
+/**
+ * The result of {@link ProtocolNegotiator#negotiate}: which protocol a connection will speak,
+ * decided once, immediately after ALPN or the h2c preface is inspected, per R1.
+ */
+public enum NegotiatedProtocol {
+ HTTP_1_1,
+ H2
+}
diff --git a/flash/src/main/java/dev/relism/flash/transport/ProtocolNegotiator.java b/flash/src/main/java/dev/relism/flash/transport/ProtocolNegotiator.java
new file mode 100644
index 0000000..ecc96f9
--- /dev/null
+++ b/flash/src/main/java/dev/relism/flash/transport/ProtocolNegotiator.java
@@ -0,0 +1,67 @@
+package dev.relism.flash.transport;
+
+import javax.net.ssl.SSLSocket;
+
+import java.io.IOException;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+/**
+ * Decides, once per connection and before any request is parsed, whether the connection speaks
+ * HTTP/1.1 or HTTP/2 — the single seam R1 requires ("the protocol decision is made once,
+ * immediately after ALPN/preface detection").
+ *
+ * Two independent signals, in order:
+ * This method reports the protocol accurately and unconditionally — it does not consult
+ * {@code FlashConfiguration.http2Enabled}. Gating whether an {@link NegotiatedProtocol#H2}
+ * result is honoured (versus cleanly rejected, which is all Phase 1 can do — there is no
+ * {@code Http2Connection} yet) and whether the h2c peek is even attempted for plaintext
+ * connections are both the caller's responsibility, so that this class stays a pure,
+ * directly-testable detector (see {@code ProtocolNegotiatorTest}).
+ */
+public final class ProtocolNegotiator {
+
+ /**
+ * The HTTP/2 client connection preface (RFC 9113 §3.4) — precompiled once (R4), never
+ * reconstructed per connection.
+ */
+ private static final byte[] H2C_PREFACE =
+ "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
+
+ private ProtocolNegotiator() {
+ }
+
+ public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source) throws IOException {
+ if (socket instanceof SSLSocket ssl) {
+ String applicationProtocol = ssl.getApplicationProtocol();
+ return "h2".equals(applicationProtocol) ? NegotiatedProtocol.H2 : NegotiatedProtocol.HTTP_1_1;
+ }
+
+ byte[] probe = new byte[H2C_PREFACE.length];
+ int n = source.peek(probe, 0, probe.length);
+ if (n == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)) {
+ return NegotiatedProtocol.H2;
+ }
+ return NegotiatedProtocol.HTTP_1_1;
+ }
+}
diff --git a/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java b/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java
index 632e4ae..d906590 100644
--- a/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java
+++ b/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java
@@ -1,5 +1,8 @@
package dev.relism.flash;
+import dev.relism.flash.exceptions.MalformedRequestException;
+import dev.relism.flash.http.Http1Limits;
+import dev.relism.flash.transport.BufferedByteSource;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
@@ -10,9 +13,15 @@ import static org.junit.jupiter.api.Assertions.*;
class ChunkedInputStreamTest {
+ // BufferedByteSource's Socket reference is only touched when a deadline is set — none of
+ // these tests set one, so `null` is safe here.
+ private static BufferedByteSource source(byte[] bytes) {
+ return new BufferedByteSource(new ByteArrayInputStream(bytes), null);
+ }
+
private static ChunkedInputStream wrap(String chunkedEncoded) {
byte[] bytes = chunkedEncoded.getBytes(StandardCharsets.UTF_8);
- return new ChunkedInputStream(new ByteArrayInputStream(bytes), null, 0, 0);
+ return new ChunkedInputStream(source(bytes), null, 0, 0);
}
private static String readAll(ChunkedInputStream in) throws IOException {
@@ -105,7 +114,7 @@ class ChunkedInputStreamTest {
// "5\r\nhello" in preBuf, "\r\n0\r\n\r\n" in socket
byte[] preBuf = "5\r\nhello".getBytes(StandardCharsets.UTF_8);
byte[] socket = "\r\n0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
- ChunkedInputStream in = new ChunkedInputStream(new ByteArrayInputStream(socket), preBuf, 0, preBuf.length);
+ ChunkedInputStream in = new ChunkedInputStream(source(socket), preBuf, 0, preBuf.length);
assertEquals("hello", new String(in.readAllBytes(), StandardCharsets.UTF_8));
}
@@ -114,7 +123,103 @@ class ChunkedInputStreamTest {
byte[] preBuf = "XX2\r\nhi\r\n0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
// offset=2, len=preBuf.length-2 — skip "XX"
ChunkedInputStream in = new ChunkedInputStream(
- new ByteArrayInputStream(new byte[0]), preBuf, 2, preBuf.length - 2);
+ source(new byte[0]), preBuf, 2, preBuf.length - 2);
assertEquals("hi", new String(in.readAllBytes(), StandardCharsets.UTF_8));
}
+
+ // --- EX-10: no per-byte syscalls against the underlying stream ------------
+
+ /** Counts every {@code read} call that reaches the wrapped stream — i.e. every syscall. */
+ private static final class CountingInputStream extends ByteArrayInputStream {
+ int reads = 0;
+ CountingInputStream(byte[] buf) { super(buf); }
+ @Override public synchronized int read() { reads++; return super.read(); }
+ @Override public synchronized int read(byte[] b, int off, int len) { reads++; return super.read(b, off, len); }
+ }
+
+ @Test
+ void byteByByteRead_doesNotSyscallPerByte() throws IOException {
+ // 100 one-byte chunks — the pre-fix implementation would have issued one read() call
+ // per payload byte PLUS one per chunk-size digit PLUS two per chunk terminator PLUS
+ // two for the final trailer-section terminator: hundreds of underlying reads for 100
+ // bytes of payload. Buffered, this must collapse to a small, buffer-size-bound count.
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 100; i++) sb.append("1\r\nx\r\n");
+ sb.append("0\r\n\r\n");
+ CountingInputStream counting = new CountingInputStream(sb.toString().getBytes(StandardCharsets.UTF_8));
+ BufferedByteSource src = new BufferedByteSource(counting, null);
+ ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
+
+ int total = 0;
+ while (in.read() != -1) total++;
+
+ assertEquals(100, total);
+ // The whole message (700 bytes) fits in BufferedByteSource's default 8 KB buffer, so
+ // this must be exactly one underlying read — nowhere near "one per byte".
+ assertEquals(1, counting.reads);
+ }
+
+ // --- EX-02/09 chunk safety limits ------------------------------------------
+
+ @Test
+ void chunkSizeAboveLimit_rejected() {
+ // MAX_CHUNK_SIZE is 16 MiB (0x1000000); one hex digit past that overflows the bound.
+ BufferedByteSource src = source("10000000\r\n".getBytes(StandardCharsets.UTF_8));
+ ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
+ assertThrows(MalformedRequestException.class, in::read);
+ }
+
+ @Test
+ void tooManyHexDigits_rejected() {
+ BufferedByteSource src = source("00000000000000001\r\n".getBytes(StandardCharsets.UTF_8)); // 17 digits
+ ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
+ assertThrows(MalformedRequestException.class, in::read);
+ }
+
+ @Test
+ void chunkExtensionTooLong_rejected() {
+ String ext = ";" + "a".repeat(300);
+ BufferedByteSource src = source(("5" + ext + "\r\nhello\r\n0\r\n\r\n").getBytes(StandardCharsets.UTF_8));
+ ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
+ assertThrows(MalformedRequestException.class, in::read);
+ }
+
+ @Test
+ void malformedChunkSize_rejected() {
+ BufferedByteSource src = source(";novalue\r\n".getBytes(StandardCharsets.UTF_8));
+ ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
+ assertThrows(MalformedRequestException.class, in::read);
+ }
+
+ @Test
+ void bareChunkTerminator_rejected() {
+ // Declares 5 bytes but the terminator after them is not CRLF.
+ BufferedByteSource src = source("5\r\nhelloXX0\r\n\r\n".getBytes(StandardCharsets.UTF_8));
+ ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
+ assertThrows(MalformedRequestException.class, () -> in.readAllBytes());
+ }
+
+ @Test
+ void tooManyChunks_rejected413() {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < Http1Limits.MAX_CHUNKS_PER_BODY + 5; i++) sb.append("1\r\nx\r\n");
+ sb.append("0\r\n\r\n");
+ BufferedByteSource src = source(sb.toString().getBytes(StandardCharsets.UTF_8));
+ ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
+ MalformedRequestException e = assertThrows(MalformedRequestException.class, () -> {
+ while (in.read() != -1) { /* drain */ }
+ });
+ assertEquals(413, e.status());
+ }
+
+ @Test
+ void tooManyTrailers_rejected431() {
+ StringBuilder sb = new StringBuilder("2\r\nhi\r\n0\r\n");
+ for (int i = 0; i < Http1Limits.MAX_TRAILER_COUNT + 5; i++) sb.append("X-").append(i).append(": v\r\n");
+ sb.append("\r\n");
+ BufferedByteSource src = source(sb.toString().getBytes(StandardCharsets.UTF_8));
+ ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
+ MalformedRequestException e = assertThrows(MalformedRequestException.class, in::readAllBytes);
+ assertEquals(431, e.status());
+ }
}
diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java
new file mode 100644
index 0000000..f471736
--- /dev/null
+++ b/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java
@@ -0,0 +1,185 @@
+package dev.relism.flash;
+
+import dev.relism.flash.extension.FlashApp;
+import dev.relism.flash.extension.FlashConfiguration;
+import dev.relism.flash.tls.TestKeystores;
+import dev.relism.flash.tls.TlsConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.OutputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * {@code EX-07}: a socket-level {@code SO_TIMEOUT} alone never trips against a peer that keeps
+ * trickling bytes slower than the timeout window — each individual read still succeeds. These
+ * tests prove the absolute deadline in {@code dev.relism.flash.transport.BufferedByteSource}
+ * actually bounds the total time, not just each read.
+ */
+class HttpServerTimeoutTest {
+
+ private FlashApp app;
+
+ @AfterEach
+ void tearDown() {
+ if (app != null) app.stop();
+ }
+
+ private int freePort() throws Exception {
+ try (ServerSocket s = new ServerSocket(0)) {
+ return s.getLocalPort();
+ }
+ }
+
+ @Test
+ void slowlorisHeaderDribble_disconnectedWithinHeaderReadTimeout() throws Exception {
+ int headerTimeoutMs = 300;
+ int port = freePort();
+ app = FlashApp.create(FlashConfiguration.builder()
+ .port(port).host("127.0.0.1")
+ .headerReadTimeoutMs(headerTimeoutMs)
+ .idleKeepAliveTimeoutMs(60_000)
+ .build());
+ app.get("/", (req, res) -> "ok");
+ app.start();
+
+ long start = System.nanoTime();
+ try (Socket socket = new Socket("127.0.0.1", port)) {
+ socket.setSoTimeout(5_000);
+ OutputStream out = socket.getOutputStream();
+ // One byte of a request line, then silence — never completes the header block.
+ out.write('G');
+ out.flush();
+
+ // The server must close its side within headerReadTimeoutMs (+ generous slack for
+ // scheduling). Detected as EOF (-1) or a reset when the client tries to read.
+ int result = socket.getInputStream().read();
+ long elapsedMs = (System.nanoTime() - start) / 1_000_000;
+
+ assertEquals(-1, result, "server must close, not hang, after the header deadline");
+ assertTrue(elapsedMs < 5_000, "must not have fallen back to the client's own SO_TIMEOUT");
+ assertTrue(elapsedMs >= headerTimeoutMs - 50,
+ "must not close before the configured deadline (was " + elapsedMs + "ms)");
+ }
+ }
+
+ @Test
+ void idleKeepAliveConnection_disconnectedWithinIdleTimeout() throws Exception {
+ int idleTimeoutMs = 300;
+ int port = freePort();
+ app = FlashApp.create(FlashConfiguration.builder()
+ .port(port).host("127.0.0.1")
+ .headerReadTimeoutMs(10_000)
+ .idleKeepAliveTimeoutMs(idleTimeoutMs)
+ .build());
+ app.get("/", (req, res) -> "ok");
+ app.start();
+
+ long start = System.nanoTime();
+ try (Socket socket = new Socket("127.0.0.1", port)) {
+ socket.setSoTimeout(5_000);
+ // Send nothing at all — a connection accepted and then left idle.
+ int result = socket.getInputStream().read();
+ long elapsedMs = (System.nanoTime() - start) / 1_000_000;
+
+ assertEquals(-1, result);
+ assertTrue(elapsedMs < 5_000);
+ assertTrue(elapsedMs >= idleTimeoutMs - 50, "was " + elapsedMs + "ms");
+ }
+ }
+
+ @Test
+ void slowBodyDribble_disconnectedWithinBodyReadTimeout() throws Exception {
+ int bodyTimeoutMs = 300;
+ int port = freePort();
+ app = FlashApp.create(FlashConfiguration.builder()
+ .port(port).host("127.0.0.1")
+ .headerReadTimeoutMs(10_000)
+ .idleKeepAliveTimeoutMs(10_000)
+ .bodyReadTimeoutMs(bodyTimeoutMs)
+ .build());
+ app.post("/echo", (req, res) -> req.body().bytes());
+ app.start();
+
+ long start = System.nanoTime();
+ try (Socket socket = new Socket("127.0.0.1", port)) {
+ socket.setSoTimeout(5_000);
+ OutputStream out = socket.getOutputStream();
+ out.write(("POST /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\n" + "x".repeat(5))
+ .getBytes(StandardCharsets.UTF_8));
+ out.flush();
+ // Only 5 of the declared 100 bytes were sent; the remaining 95 never arrive. Whether
+ // the server responds with an error before closing or simply closes, *something*
+ // must happen within the body deadline rather than a hang until the client's own
+ // (much longer) timeout.
+ socket.getInputStream().read();
+ long elapsedMs = (System.nanoTime() - start) / 1_000_000;
+
+ assertTrue(elapsedMs < 5_000, "must not have fallen back to the client's own SO_TIMEOUT");
+ assertTrue(elapsedMs >= bodyTimeoutMs - 50, "was " + elapsedMs + "ms");
+ }
+ }
+
+ @Test
+ void tlsHandshakeNeverStarted_disconnectedWithinHeaderReadTimeout(@TempDir Path dir) throws Exception {
+ int headerTimeoutMs = 300;
+ Path ks = TestKeystores.build(dir, "timeout.p12", "changeit",
+ TestKeystores.Entry.of("only", "timeout.test"));
+ int port = freePort();
+ app = FlashApp.create(FlashConfiguration.builder()
+ .port(port).host("127.0.0.1")
+ .tls(TlsConfig.keystore(ks, "changeit"))
+ .headerReadTimeoutMs(headerTimeoutMs)
+ .build());
+ app.get("/", (req, res) -> "ok");
+ app.start();
+
+ long start = System.nanoTime();
+ // A plain socket that never speaks TLS at all — the server's explicit
+ // startHandshake() (EX-30) blocks waiting for a ClientHello that is never coming,
+ // and must be bounded by headerReadTimeoutMs rather than hanging forever. Whether the
+ // JSSE implementation sends a TLS alert record before closing or just closes outright
+ // is a JSSE implementation detail, not something this test should pin down — the
+ // property under test is purely the bound on wall-clock time.
+ try (Socket socket = new Socket("127.0.0.1", port)) {
+ socket.setSoTimeout(5_000);
+ socket.getInputStream().read();
+ long elapsedMs = (System.nanoTime() - start) / 1_000_000;
+
+ assertTrue(elapsedMs < 5_000, "must not have fallen back to the client's own SO_TIMEOUT");
+ assertTrue(elapsedMs >= headerTimeoutMs - 50, "was " + elapsedMs + "ms");
+ }
+ }
+
+ @Test
+ void wellBehavedRequest_wellWithinTimeouts_unaffected() throws Exception {
+ int port = freePort();
+ app = FlashApp.create(FlashConfiguration.builder()
+ .port(port).host("127.0.0.1")
+ .headerReadTimeoutMs(300)
+ .idleKeepAliveTimeoutMs(300)
+ .bodyReadTimeoutMs(300)
+ .build());
+ app.get("/ping", (req, res) -> "pong");
+ app.start();
+
+ try (Socket socket = new Socket("127.0.0.1", port)) {
+ socket.setSoTimeout(5_000);
+ socket.getOutputStream().write(
+ "GET /ping HTTP/1.1\r\nHost: localhost\r\n\r\n".getBytes(StandardCharsets.UTF_8));
+ socket.getOutputStream().flush();
+ byte[] buf = new byte[4096];
+ int n = socket.getInputStream().read(buf);
+ assertTrue(n > 0);
+ String response = new String(buf, 0, n, StandardCharsets.UTF_8);
+ assertTrue(response.startsWith("HTTP/1.1 200 OK"));
+ assertTrue(response.contains("pong"));
+ }
+ }
+}
diff --git a/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java b/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java
new file mode 100644
index 0000000..4410a8d
--- /dev/null
+++ b/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java
@@ -0,0 +1,194 @@
+package dev.relism.flash;
+
+import dev.relism.flash.exceptions.MalformedRequestException;
+import dev.relism.flash.http.Http1Limits;
+import dev.relism.flash.models.Request;
+import dev.relism.flash.transport.BufferedByteSource;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * One test per rejection rule added in HTTP/2 plan Phase 1 (EX-02, EX-03, EX-08, EX-18, EX-35,
+ * EX-36), each asserting the specific status code {@link MalformedRequestException} carries —
+ * not merely that some exception was thrown. {@code HttpServer} always closes the connection
+ * after any of these (never keep-alive); that behaviour is exercised at the integration level
+ * by {@code HttpServerTest}.
+ */
+class RequestParserSecurityTest {
+
+ private static BufferedByteSource source(byte[] bytes) {
+ return new BufferedByteSource(new ByteArrayInputStream(bytes), null);
+ }
+
+ private static Request parse(String raw) throws IOException {
+ byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
+ return new RequestParser().parse(source(bytes));
+ }
+
+ private static MalformedRequestException expect(String raw) {
+ return assertThrows(MalformedRequestException.class, () -> parse(raw));
+ }
+
+ // --- EX-02: Content-Length + Transfer-Encoding smuggling -------------------
+
+ @Test
+ void contentLengthAndTransferEncodingBothPresent_rejected400() {
+ MalformedRequestException e = expect(
+ "POST / HTTP/1.1\nHost: h\nContent-Length: 5\nTransfer-Encoding: chunked\n\nhello");
+ assertEquals(400, e.status());
+ }
+
+ @Test
+ void contentLengthAndTransferEncodingBothPresent_rejectedRegardlessOfOrder() {
+ // The check must not be bypassable by which header appears first.
+ MalformedRequestException e = expect(
+ "POST / HTTP/1.1\nHost: h\nTransfer-Encoding: chunked\nContent-Length: 5\n\nhello");
+ assertEquals(400, e.status());
+ }
+
+ @Test
+ void duplicateContentLength_conflictingValues_rejected400() {
+ MalformedRequestException e = expect(
+ "POST / HTTP/1.1\nHost: h\nContent-Length: 5\nContent-Length: 6\n\nhello!");
+ assertEquals(400, e.status());
+ }
+
+ @Test
+ void duplicateContentLength_identicalValues_accepted() throws IOException {
+ Request r = parse("POST / HTTP/1.1\nHost: h\nContent-Length: 5\nContent-Length: 5\n\nhello");
+ assertEquals(5L, r.body().contentLength());
+ }
+
+ @Test
+ void transferEncoding_finalCodingNotChunked_rejected501() {
+ MalformedRequestException e = expect(
+ "POST / HTTP/1.1\nHost: h\nTransfer-Encoding: gzip\n\n");
+ assertEquals(501, e.status());
+ }
+
+ @Test
+ void transferEncoding_chunkedNotFinal_rejected501() {
+ // "chunked, gzip" — chunked must be the LAST coding (RFC 9112 §6.1).
+ MalformedRequestException e = expect(
+ "POST / HTTP/1.1\nHost: h\nTransfer-Encoding: chunked, gzip\n\n");
+ assertEquals(501, e.status());
+ }
+
+ // --- EX-03: strict Content-Length parsing -----------------------------------
+
+ @Test
+ void contentLength_nonDigitSuffix_rejected400() {
+ assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: 5abc\n\n").status());
+ }
+
+ @Test
+ void contentLength_leadingPlus_rejected400() {
+ assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: +5\n\n").status());
+ }
+
+ @Test
+ void contentLength_leadingMinus_rejected400() {
+ assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: -1\n\n").status());
+ }
+
+ @Test
+ void contentLength_empty_rejected400() {
+ assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: \n\n").status());
+ }
+
+ @Test
+ void contentLength_overflowsLong_rejected400() {
+ assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: 99999999999999999999\n\n").status());
+ }
+
+ @Test
+ void contentLength_aboveConfiguredMax_rejected413() {
+ long tooLarge = Http1Limits.MAX_CONTENT_LENGTH + 1;
+ assertEquals(413, expect("POST / HTTP/1.1\nHost: h\nContent-Length: " + tooLarge + "\n\n").status());
+ }
+
+ // --- EX-08: header/request-line limits --------------------------------------
+
+ @Test
+ void tooManyHeaders_rejected431() {
+ StringBuilder sb = new StringBuilder("GET / HTTP/1.1\nHost: h\n");
+ for (int i = 0; i < Http1Limits.MAX_HEADER_COUNT + 5; i++) sb.append("X-").append(i).append(": v\n");
+ sb.append("\n");
+ assertEquals(431, expect(sb.toString()).status());
+ }
+
+ @Test
+ void headerNameTooLong_rejected431() {
+ String name = "X-" + "a".repeat(Http1Limits.MAX_HEADER_NAME_LENGTH + 1);
+ assertEquals(431, expect("GET / HTTP/1.1\nHost: h\n" + name + ": v\n\n").status());
+ }
+
+ @Test
+ void headerValueTooLong_rejected431() {
+ String value = "a".repeat(Http1Limits.MAX_HEADER_VALUE_LENGTH + 1);
+ assertEquals(431, expect("GET / HTTP/1.1\nHost: h\nX-Big: " + value + "\n\n").status());
+ }
+
+ @Test
+ void requestLineTooLong_rejected431() {
+ String path = "/" + "a".repeat(Http1Limits.MAX_REQUEST_LINE_LENGTH + 1);
+ assertEquals(431, expect("GET " + path + " HTTP/1.1\nHost: h\n\n").status());
+ }
+
+ // --- EX-18: bare CR / obs-fold -----------------------------------------------
+
+ @Test
+ void bareLfInsteadOfCrlf_headerLine_rejected() {
+ // A '\r' not immediately followed by '\n' desynchronizes the parse.
+ byte[] raw = "GET / HTTP/1.1\r\nHost: h\r\r\n\r\n".getBytes(StandardCharsets.UTF_8);
+ assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw)));
+ }
+
+ @Test
+ void obsFold_leadingWhitespaceContinuation_rejected400() {
+ MalformedRequestException e = assertThrows(MalformedRequestException.class, () ->
+ new RequestParser().parse(source(
+ "GET / HTTP/1.1\r\nHost: h\r\n Folded: continuation\r\n\r\n"
+ .getBytes(StandardCharsets.UTF_8))));
+ assertEquals(400, e.status());
+ }
+
+ // --- header name tchar validation --------------------------------------------
+
+ @Test
+ void headerNameWithSpace_rejected400() {
+ assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nBad Name: v\n\n").status());
+ }
+
+ @Test
+ void headerNameWithControlChar_rejected400() {
+ String prefix = "GET / HTTP/1.1\r\nHost: h\r\nBad";
+ String suffix = "Name: v\r\n\r\n";
+ byte[] prefixBytes = prefix.getBytes(StandardCharsets.ISO_8859_1);
+ byte[] suffixBytes = suffix.getBytes(StandardCharsets.ISO_8859_1);
+ byte[] raw = new byte[prefixBytes.length + 1 + suffixBytes.length];
+ System.arraycopy(prefixBytes, 0, raw, 0, prefixBytes.length);
+ raw[prefixBytes.length] = 0x01; // control character -- not a valid tchar
+ System.arraycopy(suffixBytes, 0, raw, prefixBytes.length + 1, suffixBytes.length);
+ assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw)));
+ }
+
+ // --- EX-36: header line missing ':' -------------------------------------------
+
+ @Test
+ void headerLineMissingColon_rejected400() {
+ assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nNotAHeader\n\n").status());
+ }
+
+ // --- request-line rejections still carry the right status --------------------
+
+ @Test
+ void emptyMethod_rejected400() {
+ assertEquals(400, expect(" / HTTP/1.1\nHost: h\n\n").status());
+ }
+}
diff --git a/flash/src/test/java/dev/relism/flash/RequestParserTest.java b/flash/src/test/java/dev/relism/flash/RequestParserTest.java
index 2c8dd79..5bb3df5 100644
--- a/flash/src/test/java/dev/relism/flash/RequestParserTest.java
+++ b/flash/src/test/java/dev/relism/flash/RequestParserTest.java
@@ -1,6 +1,8 @@
package dev.relism.flash;
+import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.models.Request;
+import dev.relism.flash.transport.BufferedByteSource;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
@@ -14,9 +16,15 @@ class RequestParserTest {
// --- helpers ---
+ // BufferedByteSource's Socket reference is only touched when a deadline is set — none of
+ // these tests set one, so `null` is safe here.
+ private static BufferedByteSource source(byte[] bytes) {
+ return new BufferedByteSource(new ByteArrayInputStream(bytes), null);
+ }
+
private static Request parse(String raw) throws IOException {
byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
- return new RequestParser().parse(new ByteArrayInputStream(bytes));
+ return new RequestParser().parse(source(bytes));
}
private static String req(String requestLine, String... headers) {
@@ -70,7 +78,7 @@ class RequestParserTest {
void body_parsed() throws IOException {
String body = "hello body";
String raw = "POST / HTTP/1.1\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
- Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
+ Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8));
}
@@ -79,7 +87,7 @@ class RequestParserTest {
void body_parsed_forQueryMethod() throws IOException {
String body = "{\"filter\":\"active\"}";
String raw = "QUERY / HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
- Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
+ Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
assertEquals(dev.relism.flash.http.HttpMethod.QUERY, r.method());
assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8));
@@ -95,34 +103,43 @@ class RequestParserTest {
@Test
void emptyInputStream_returnsNull() throws IOException {
- assertNull(new RequestParser().parse(new ByteArrayInputStream(new byte[0])));
+ assertNull(new RequestParser().parse(source(new byte[0])));
}
@Test
- void missingHeaderTerminator_throwsIOException() {
- // Valid request line but stream ends before \r\n\r\n
+ void missingHeaderTerminator_throwsMalformedRequestException() {
+ // Valid request line but stream ends before \r\n\r\n. Previously a generic IOException;
+ // now the same typed rejection EX-08's over-limit case uses, since both mean "the
+ // header block could never be completed within the allowed buffer" (EX-08).
byte[] raw = "GET / HTTP/1.1\r\nHost: localhost\r\n".getBytes(StandardCharsets.UTF_8);
- assertThrows(IOException.class, () -> new RequestParser().parse(new ByteArrayInputStream(raw)));
+ assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw)));
}
@Test
- void unknownHttpMethod_throwsIOException() {
- assertThrows(IOException.class, () -> parse(req("BREW /coffee HTTP/1.1", "Host: localhost")));
+ void unknownHttpMethod_throwsMalformedRequestException() {
+ // RFC 9110 §9.1 SHOULD 501 an unrecognised method.
+ MalformedRequestException e = assertThrows(MalformedRequestException.class,
+ () -> parse(req("BREW /coffee HTTP/1.1", "Host: localhost")));
+ assertEquals(501, e.status());
}
@Test
- void requestLine_noProtocol_throwsIOException() {
+ void requestLine_noProtocol_throwsMalformedRequestException() {
// No space after path, parser cannot find protocol boundary
- assertThrows(IOException.class, () -> parse(req("GET /noproto")));
+ MalformedRequestException e = assertThrows(MalformedRequestException.class,
+ () -> parse(req("GET /noproto")));
+ assertEquals(400, e.status());
}
@Test
- void headers_exceedingMaxBufferSize_throwsIOException() {
- // Feed more bytes than the configured cap with no \r\n\r\n : must throw
+ void headers_exceedingMaxBufferSize_throwsMalformedRequestException() {
+ // Feed more bytes than the configured cap with no \r\n\r\n : must throw 431 (EX-08).
int cap = 16 * 1024;
byte[] giant = new byte[cap + 1];
Arrays.fill(giant, (byte) 'A');
- assertThrows(IOException.class, () -> new RequestParser(cap).parse(new ByteArrayInputStream(giant)));
+ MalformedRequestException e = assertThrows(MalformedRequestException.class,
+ () -> new RequestParser(cap).parse(source(giant)));
+ assertEquals(431, e.status());
}
@Test
@@ -132,22 +149,38 @@ class RequestParserTest {
"Transfer-Encoding: chunked\r\n" +
"\r\n" +
"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
- Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
+ Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
assertEquals(-1L, r.body().contentLength()); // -1 = chunked
assertArrayEquals("hello world".getBytes(StandardCharsets.UTF_8), r.body().bytes());
}
@Test
- void contentLength_parsedAsLong() throws IOException {
- // 5 GB — too large to materialize, but contentLength must be a long
+ void transferEncoding_multiValueEndingInChunked_recognised() throws IOException {
+ // EX-35: "gzip, chunked" — chunked need only be the FINAL coding (RFC 9112 §6.1). The
+ // old whole-value comparison misclassified this as not chunked at all.
String raw = "POST / HTTP/1.1\r\n" +
"Host: localhost\r\n" +
- "Content-Length: 5000000000\r\n" +
- "\r\n";
- Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
+ "Transfer-Encoding: gzip, chunked\r\n" +
+ "\r\n" +
+ "2\r\nhi\r\n0\r\n\r\n";
+ Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
- assertEquals(5_000_000_000L, r.body().contentLength());
+ assertEquals(-1L, r.body().contentLength());
+ assertArrayEquals("hi".getBytes(StandardCharsets.UTF_8), r.body().bytes());
+ }
+
+ @Test
+ void contentLength_parsedAsLong() throws IOException {
+ // ~3 GB — comfortably above Integer.MAX_VALUE (proving the value is a genuine long, not
+ // silently truncated) while staying within Http1Limits.MAX_CONTENT_LENGTH (4 GiB).
+ String raw = "POST / HTTP/1.1\r\n" +
+ "Host: localhost\r\n" +
+ "Content-Length: 3000000000\r\n" +
+ "\r\n";
+ Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
+ assertNotNull(r);
+ assertEquals(3_000_000_000L, r.body().contentLength());
assertThrows(IllegalStateException.class, r.body()::bytes);
}
@@ -156,7 +189,7 @@ class RequestParserTest {
// Content-Length claims 50 but stream ends after 5 bytes
String body = "hello";
String raw = "POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\n" + body;
- Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
+ Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
assertEquals(50, r.body().bytes().length);
assertEquals(body, new String(r.body().bytes(), 0, body.length(), StandardCharsets.UTF_8));
diff --git a/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java b/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java
index 9256ce0..efe9bdf 100644
--- a/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java
+++ b/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java
@@ -40,4 +40,26 @@ class HttpStatusTest {
assertNull(HttpStatus.reasonForCode(999));
assertNull(HttpStatus.reasonForCode(0));
}
+
+ // --- EX-17: bound computed from values(), not a hand-maintained constant -----
+
+ @Test
+ void statusesAboveThePreviousHandMaintainedBound_workCorrectly() {
+ // The bound used to be hardcoded at 504; any of these (all >504, all needed by h1
+ // hardening or h2) used to throw ArrayIndexOutOfBoundsException from the static
+ // initializer at class-load time.
+ assertArrayEquals("421 Misdirected Request".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(421));
+ assertArrayEquals("431 Request Header Fields Too Large".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(431));
+ assertArrayEquals("505 HTTP Version Not Supported".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(505));
+ assertArrayEquals("507 Insufficient Storage".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(507));
+ assertArrayEquals("511 Network Authentication Required".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(511));
+ }
+
+ @Test
+ void everyEnumConstant_hasAWorkingBytesForCodeEntry() {
+ for (HttpStatus s : HttpStatus.values()) {
+ assertNotNull(HttpStatus.bytesForCode(s.code()), s.name());
+ assertNotNull(HttpStatus.reasonForCode(s.code()), s.name());
+ }
+ }
}
diff --git a/flash/src/test/java/dev/relism/flash/tls/TlsConfigTest.java b/flash/src/test/java/dev/relism/flash/tls/TlsConfigTest.java
index 9fbd56a..d5b5eec 100644
--- a/flash/src/test/java/dev/relism/flash/tls/TlsConfigTest.java
+++ b/flash/src/test/java/dev/relism/flash/tls/TlsConfigTest.java
@@ -120,4 +120,73 @@ class TlsConfigTest {
assertFalse(socket.getNeedClientAuth());
}
}
+
+ // --- EX-31: cipher suite filtering when h2 is offered -----------------------
+
+ @Test
+ void negotiatesH2_trueOnlyWhenH2IsInTheOfferedList() throws Exception {
+ SSLContext ctx = TestKeystores.trustAllClientContext();
+ assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1").negotiatesH2());
+ assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2").negotiatesH2());
+ assertFalse(TlsConfig.ofContext(ctx).applicationProtocols("http/1.1").negotiatesH2());
+ assertFalse(TlsConfig.ofContext(ctx).negotiatesH2()); // no applicationProtocols call at all
+ }
+
+ @Test
+ void applyTo_withH2Offered_removesBlockedTls12CipherSuites() throws Exception {
+ SSLContext ctx = TestKeystores.trustAllClientContext();
+ TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1");
+
+ try (SSLServerSocket socket = unboundSocket(tls)) {
+ tls.applyTo(socket);
+ ListWhy this exists instead of {@link java.io.BufferedInputStream}
+ * A generic buffered stream would already fix the per-byte-syscall problem, but it cannot
+ * "un-consume" bytes without a fragile {@code mark()}/{@code reset()} dance, and it has no way
+ * to bound an individual read by an absolute wall-clock deadline (see below). This class is
+ * purpose-built for exactly the two things this connection loop needs beyond plain buffering:
+ * {@link #peek(byte[], int, int)} (look-ahead without consuming — used once, at connection
+ * start, for h2c prior-knowledge detection) and {@link #prependOnce(byte[], int, int)}
+ * (zero-allocation, zero-copy re-insertion of bytes the caller already read into its own
+ * buffer — used by {@code ChunkedInputStream} to hand back the header-parser's read-ahead
+ * bytes instead of the {@code SequenceInputStream}/{@code ByteArrayInputStream} wrapping this
+ * replaces).
+ *
+ * Deadline, not {@code SO_TIMEOUT} alone
+ * {@link Socket#setSoTimeout(int)} bounds a single {@code read()} call, not a sequence of them —
+ * a peer that trickles one byte every 9 seconds never trips a 10-second {@code SO_TIMEOUT}, since
+ * each individual read succeeds within the window. {@link #setDeadline(long)} instead records an
+ * absolute {@link System#nanoTime()} deadline; every underlying socket read computes the
+ * remaining budget and hands exactly that to {@code setSoTimeout} before reading, so a
+ * {@link SocketTimeoutException} from an underlying read unambiguously means the deadline —
+ * not just one read — has been exceeded. This is what {@code EX-07} requires: "implement that
+ * deadline, do not rely on {@code setSoTimeout} alone."
+ *
+ * Thread-safety
+ * Not thread-safe, by design — exactly one virtual thread ever owns a connection's inbound
+ * bytes at a time (the same invariant {@code RequestParser} and {@code ChunkedInputStream}
+ * already assume).
+ */
+public final class BufferedByteSource extends InputStream {
+
+ /**
+ * Default internal buffer size. Matches the relay-buffer convention already used elsewhere
+ * in this codebase (the 8 KB {@code STREAM_RELAY_BUFFER} in {@code HttpServer}) rather than
+ * introducing a new tuning constant nothing has calibrated yet.
+ */
+ public static final int DEFAULT_BUFFER_SIZE = 8192;
+
+ private final InputStream in;
+ private final Socket socket;
+ private final byte[] buf;
+ private int pos;
+ private int limit;
+
+ // One-shot prepend window (prependOnce) — consumed before buf and before any underlying
+ // read. References the caller's own array; never copies it.
+ private byte[] prefixBuf;
+ private int prefixPos;
+ private int prefixLen;
+
+ private boolean deadlineActive;
+ private long deadlineNanos;
+
+ public BufferedByteSource(InputStream in, Socket socket) {
+ this(in, socket, DEFAULT_BUFFER_SIZE);
+ }
+
+ public BufferedByteSource(InputStream in, Socket socket, int bufferSize) {
+ this.in = in;
+ this.socket = socket;
+ this.buf = new byte[bufferSize];
+ }
+
+ // ── Deadline ─────────────────────────────────────────────────────────────
+
+ /**
+ * Every underlying socket read performed after this call is bounded so that it cannot
+ * still be blocking past {@code deadlineNanoTime} (an absolute value comparable to
+ * {@link System#nanoTime()}). A read that would exceed the deadline throws
+ * {@link SocketTimeoutException} instead of blocking further. Bytes already sitting in the
+ * internal buffer or the prepend window are served immediately regardless of the deadline —
+ * only reads that would otherwise block on the network are bounded.
+ */
+ public void setDeadline(long deadlineNanoTime) {
+ this.deadlineActive = true;
+ this.deadlineNanos = deadlineNanoTime;
+ }
+
+ /**
+ * Removes the deadline and restores the socket to blocking indefinitely
+ * ({@code SO_TIMEOUT = 0}). Must be called before any read the caller wants to be
+ * unbounded (e.g. handing the connection off to a long-lived WebSocket session loop).
+ */
+ public void clearDeadline() throws IOException {
+ this.deadlineActive = false;
+ socket.setSoTimeout(0);
+ }
+
+ // ── InputStream ──────────────────────────────────────────────────────────
+
+ @Override
+ public int read() throws IOException {
+ if (prefixLen > 0) {
+ prefixLen--;
+ return prefixBuf[prefixPos++] & 0xFF;
+ }
+ if (pos >= limit) {
+ int n = fillFromUnderlying(buf, 0, buf.length);
+ if (n <= 0) return -1;
+ pos = 0;
+ limit = n;
+ }
+ return buf[pos++] & 0xFF;
+ }
+
+ @Override
+ public int read(byte[] dst, int off, int len) throws IOException {
+ if (len == 0) return 0;
+ if (prefixLen > 0) {
+ int n = Math.min(len, prefixLen);
+ System.arraycopy(prefixBuf, prefixPos, dst, off, n);
+ prefixPos += n;
+ prefixLen -= n;
+ return n;
+ }
+ if (pos < limit) {
+ int n = Math.min(len, limit - pos);
+ System.arraycopy(buf, pos, dst, off, n);
+ pos += n;
+ return n;
+ }
+ // Buffer empty. A large request (this is the path RequestParser's own bulk
+ // header-buffer fill takes) bypasses the internal buffer entirely — copying it through
+ // `buf` first would cost a full extra memcpy for no benefit, since the caller's own
+ // array is at least as large as what we would have buffered.
+ if (len >= buf.length) {
+ return fillFromUnderlying(dst, off, len);
+ }
+ int n = fillFromUnderlying(buf, 0, buf.length);
+ if (n <= 0) return n;
+ pos = 0;
+ limit = n;
+ int c = Math.min(len, limit);
+ System.arraycopy(buf, 0, dst, off, c);
+ pos = c;
+ return c;
+ }
+
+ @Override
+ public long skip(long n) throws IOException {
+ if (n <= 0) return 0;
+ long remaining = n;
+ if (prefixLen > 0) {
+ int s = (int) Math.min(remaining, prefixLen);
+ prefixPos += s;
+ prefixLen -= s;
+ remaining -= s;
+ }
+ if (remaining > 0 && pos < limit) {
+ int s = (int) Math.min(remaining, limit - pos);
+ pos += s;
+ remaining -= s;
+ }
+ if (remaining > 0) {
+ remaining -= Math.max(0, in.skip(remaining));
+ }
+ return n - remaining;
+ }
+
+ @Override
+ public int available() {
+ return prefixLen + (limit - pos);
+ }
+
+ @Override
+ public void close() throws IOException {
+ in.close();
+ }
+
+ // ── Peek and prepend — the two operations beyond InputStream's contract ────
+
+ /**
+ * Ensures up to {@code len} bytes are buffered and copies them into {@code dst} without
+ * advancing the read position — a subsequent {@code read()} still returns the same
+ * bytes. Blocks (bounded by the active deadline, if any) until {@code len} bytes are
+ * available or the underlying stream reaches EOF. Returns the number of bytes actually made
+ * available, which is less than {@code len} only at EOF.
+ *
+ *
+ *
+ *
+ *