feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p><b>{@code EX-10}:</b> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <em>next</em> 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.
|
||||
*
|
||||
* <h3>Rejection model (RFC 9112 §6.1, {@code EX-02}/{@code EX-03}/{@code EX-08}/{@code EX-18})</h3>
|
||||
* Anything wrong with the request itself — smuggling-relevant ambiguity, an over-limit
|
||||
* header, a malformed byte where the grammar forbids one — is reported as a
|
||||
* {@link MalformedRequestException} carrying the exact status the caller must respond with.
|
||||
* This is distinct from {@link IOException}, which still means "the socket failed" (EOF,
|
||||
* reset, timeout). The caller ({@code HttpServer.process}) must always close the connection
|
||||
* after a {@link MalformedRequestException}, never keep it alive — RFC 9112 §6.1's rationale
|
||||
* for rejecting {@code Content-Length} + {@code Transfer-Encoding} outright is exactly that a
|
||||
* kept-alive connection after a disputed request boundary is what a smuggling attack needs.
|
||||
*/
|
||||
@Slf4j
|
||||
public class RequestParser {
|
||||
private static final int INITIAL_BUFFER_SIZE = 8192;
|
||||
|
||||
/**
|
||||
* RFC 9110 §5.6.2 {@code tchar} set, table-driven so header-name validation is a single
|
||||
* array read per byte rather than a chain of comparisons (R4/R5). Indexed directly by
|
||||
* byte value; only defined for the ASCII range a valid header name can ever occupy.
|
||||
*/
|
||||
private static final boolean[] TCHAR = new boolean[128];
|
||||
|
||||
static {
|
||||
for (byte b : "!#$%&'*+-.^_`|~".getBytes(java.nio.charset.StandardCharsets.US_ASCII)) {
|
||||
TCHAR[b] = true;
|
||||
}
|
||||
for (char c = '0'; c <= '9'; c++) TCHAR[c] = true;
|
||||
for (char c = 'A'; c <= 'Z'; c++) TCHAR[c] = true;
|
||||
for (char c = 'a'; c <= 'z'; c++) TCHAR[c] = true;
|
||||
}
|
||||
|
||||
private static boolean isTChar(byte b) {
|
||||
return b >= 0 && b < 128 && TCHAR[b];
|
||||
}
|
||||
|
||||
private final int maxHeaderBufferSize;
|
||||
private final InetSocketAddress remoteAddress;
|
||||
private final SSLSocket sslSocket;
|
||||
@@ -65,6 +97,19 @@ public class RequestParser {
|
||||
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether bytes from a previous {@link #parse} call are already buffered and ready to be
|
||||
* consumed by the next call without reading anything further from the source — the HTTP
|
||||
* pipelining case. The caller (the connection loop) uses this to decide whether it is safe
|
||||
* to skip waiting for "the next request has started arriving": if bytes are already
|
||||
* buffered, the next request has, by definition, already started (and may even be
|
||||
* complete), so an idle-timeout wait on the underlying source would wait for bytes that
|
||||
* were never going to arrive there — they are already here.
|
||||
*/
|
||||
boolean hasBufferedBytes() {
|
||||
return bufLen > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the next HTTP request from {@code in}.
|
||||
*
|
||||
@@ -75,9 +120,11 @@ public class RequestParser {
|
||||
* the same parser instance is reused after an error.
|
||||
*
|
||||
* @return the parsed {@link Request}, or {@code null} on clean EOF.
|
||||
* @throws IOException on malformed headers or I/O failure.
|
||||
* @throws MalformedRequestException if the request violates the HTTP/1.1 grammar or a
|
||||
* configured safety limit — carries the exact status to respond with.
|
||||
* @throws IOException on genuine I/O failure (socket reset, timeout).
|
||||
*/
|
||||
public Request parse(InputStream in) throws IOException {
|
||||
public Request parse(BufferedByteSource in) throws IOException {
|
||||
// Snapshot leftover bytes from the previous request, then reset immediately.
|
||||
// Any exception thrown below leaves bufBase/bufLen at 0 — safe state.
|
||||
int base = bufBase;
|
||||
@@ -94,7 +141,8 @@ public class RequestParser {
|
||||
System.arraycopy(buffer, base, buffer, 0, totalRead);
|
||||
base = 0;
|
||||
} else if (buffer.length >= maxHeaderBufferSize) {
|
||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
throw new MalformedRequestException(431,
|
||||
"Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
} else {
|
||||
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
|
||||
}
|
||||
@@ -107,20 +155,22 @@ public class RequestParser {
|
||||
}
|
||||
if (totalRead <= 0) return null;
|
||||
if (headerEndIdx == -1) {
|
||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
throw new MalformedRequestException(431,
|
||||
"Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
}
|
||||
|
||||
// ── Request line ─────────────────────────────────────────────────────
|
||||
|
||||
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
|
||||
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
|
||||
if (methodEnd == -1) throw new MalformedRequestException(400, "Invalid request line (method)");
|
||||
if (methodEnd == base) throw new MalformedRequestException(400, "Missing HTTP method");
|
||||
|
||||
HttpMethod method = HttpMethod.fromBytes(buffer, base, methodEnd - base);
|
||||
if (method == null) throw new IOException("Unsupported HTTP method");
|
||||
if (method == null) throw new MalformedRequestException(501, "Unsupported HTTP method");
|
||||
|
||||
int pathStart = methodEnd + 1;
|
||||
int pathEnd = find(buffer, pathStart, headerEndIdx, (byte) ' ');
|
||||
if (pathEnd == -1) throw new IOException("Invalid request line (path)");
|
||||
if (pathEnd == -1) throw new MalformedRequestException(400, "Invalid request line (path)");
|
||||
|
||||
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
|
||||
@@ -131,7 +181,14 @@ public class RequestParser {
|
||||
|
||||
int protocolStart = pathEnd + 1;
|
||||
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
|
||||
if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)");
|
||||
if (protocolEnd == -1) throw new MalformedRequestException(400, "Invalid request line (protocol)");
|
||||
|
||||
// EX-08: the request line itself (method SP target SP version) is bounded separately
|
||||
// from the overall header-block size, so an oversized request line gets its own,
|
||||
// specific rejection rather than being folded into the generic "headers too large" case.
|
||||
if (protocolEnd - base > Http1Limits.MAX_REQUEST_LINE_LENGTH) {
|
||||
throw new MalformedRequestException(431, "Request line exceeds " + Http1Limits.MAX_REQUEST_LINE_LENGTH + " bytes");
|
||||
}
|
||||
|
||||
FastPathViews.RequestByteView protocolView =
|
||||
new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
|
||||
@@ -140,27 +197,98 @@ public class RequestParser {
|
||||
|
||||
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
|
||||
int current = sectionStart;
|
||||
long contentLength = 0;
|
||||
boolean isChunked = false;
|
||||
long contentLength = -1;
|
||||
boolean contentLengthSeen = false;
|
||||
boolean transferEncodingSeen = false;
|
||||
boolean transferEncodingChunked = false;
|
||||
int headerCount = 0;
|
||||
|
||||
while (current < headerEndIdx) {
|
||||
// EX-18 (obs-fold): a header line MUST NOT begin with whitespace. RFC 9112 §5.2
|
||||
// deprecates line folding and treating a folded continuation as part of the
|
||||
// previous header's value is a known request-smuggling vector.
|
||||
byte first = buffer[current];
|
||||
if (first == ' ' || first == '\t') {
|
||||
throw new MalformedRequestException(400, "Obsolete line folding is not supported");
|
||||
}
|
||||
|
||||
int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r');
|
||||
if (lineEnd == -1 || lineEnd == current) break;
|
||||
|
||||
int colon = find(buffer, current, lineEnd, (byte) ':');
|
||||
if (colon != -1) {
|
||||
int valueStart = colon + 1;
|
||||
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
|
||||
// EX-18: verify the '\r' is immediately followed by '\n' instead of blindly
|
||||
// advancing past two bytes — a bare '\r' not followed by '\n' desynchronizes the
|
||||
// parse and is a known bare-CR smuggling surface. Safe to read lineEnd+1: lineEnd
|
||||
// is at most headerEndIdx, and findEndOfHeader already guaranteed 4 readable bytes
|
||||
// (\r\n\r\n) starting at headerEndIdx.
|
||||
if (buffer[lineEnd + 1] != '\n') {
|
||||
throw new MalformedRequestException(400, "Malformed line terminator (bare CR)");
|
||||
}
|
||||
|
||||
if (equalsIgnoreCase(buffer, current, colon, "content-length")) {
|
||||
contentLength = parseLong(buffer, valueStart, lineEnd);
|
||||
} else if (equalsIgnoreCase(buffer, current, colon, "transfer-encoding")) {
|
||||
isChunked = equalsIgnoreCase(buffer, valueStart, lineEnd, "chunked");
|
||||
if (++headerCount > Http1Limits.MAX_HEADER_COUNT) {
|
||||
throw new MalformedRequestException(431, "Too many headers");
|
||||
}
|
||||
|
||||
int colon = find(buffer, current, lineEnd, (byte) ':');
|
||||
if (colon == -1) {
|
||||
throw new MalformedRequestException(400, "Header line missing ':'");
|
||||
}
|
||||
if (colon - current > Http1Limits.MAX_HEADER_NAME_LENGTH) {
|
||||
throw new MalformedRequestException(431, "Header name exceeds " + Http1Limits.MAX_HEADER_NAME_LENGTH + " bytes");
|
||||
}
|
||||
for (int i = current; i < colon; i++) {
|
||||
if (!isTChar(buffer[i])) {
|
||||
throw new MalformedRequestException(400, "Invalid header name character");
|
||||
}
|
||||
}
|
||||
|
||||
int valueStart = colon + 1;
|
||||
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
|
||||
if (lineEnd - valueStart > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
|
||||
throw new MalformedRequestException(431, "Header value exceeds " + Http1Limits.MAX_HEADER_VALUE_LENGTH + " bytes");
|
||||
}
|
||||
|
||||
if (equalsIgnoreCase(buffer, current, colon, "content-length")) {
|
||||
// EX-03: strict, overflow-safe parsing — replaces the old digit-skipping
|
||||
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
|
||||
long parsed = parseContentLengthStrict(buffer, valueStart, lineEnd);
|
||||
// Multiple Content-Length lines with differing values is itself a smuggling
|
||||
// primitive (EX-02); identical repeated values are tolerated (RFC 9110 §8.6
|
||||
// permits a recipient to treat that as one value).
|
||||
if (contentLengthSeen && parsed != contentLength) {
|
||||
throw new MalformedRequestException(400, "Conflicting Content-Length values");
|
||||
}
|
||||
contentLength = parsed;
|
||||
contentLengthSeen = true;
|
||||
} else if (equalsIgnoreCase(buffer, current, colon, "transfer-encoding")) {
|
||||
transferEncodingSeen = true;
|
||||
// Correctness fix found while implementing EX-02 in this exact code path
|
||||
// (registered as EX-35): the old check required the WHOLE value to equal
|
||||
// "chunked", so "gzip, chunked" — valid per RFC 9112 §6.1, where chunked need
|
||||
// only be the FINAL coding — was silently treated as not chunked at all,
|
||||
// corrupting the message boundary. Fixed by inspecting only the last token.
|
||||
transferEncodingChunked = isFinalCodingChunked(buffer, valueStart, lineEnd);
|
||||
}
|
||||
current = lineEnd + 2;
|
||||
}
|
||||
|
||||
// EX-02 (RFC 9112 §6.1): a request with both Content-Length and Transfer-Encoding
|
||||
// MUST be treated as an error by an origin server — this is the canonical CL.TE/TE.CL
|
||||
// smuggling vector. Checked once both headers are known, regardless of the order they
|
||||
// appeared in, so ordering games cannot bypass it.
|
||||
if (contentLengthSeen && transferEncodingSeen) {
|
||||
throw new MalformedRequestException(400, "Content-Length and Transfer-Encoding both present");
|
||||
}
|
||||
boolean isChunked;
|
||||
if (transferEncodingSeen) {
|
||||
if (!transferEncodingChunked) {
|
||||
throw new MalformedRequestException(501, "Unsupported Transfer-Encoding");
|
||||
}
|
||||
isChunked = true;
|
||||
} else {
|
||||
isChunked = false;
|
||||
if (!contentLengthSeen) contentLength = 0;
|
||||
}
|
||||
|
||||
headerMap.reset(buffer, sectionStart, headerEndIdx);
|
||||
|
||||
// ── Body / pipelining accounting ─────────────────────────────────────
|
||||
@@ -219,12 +347,54 @@ public class RequestParser {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static long parseLong(byte[] buf, int start, int end) {
|
||||
/**
|
||||
* Strict, overflow-safe {@code Content-Length} parsing ({@code EX-03}). Rejects: an empty
|
||||
* value, any non-digit byte (including a leading {@code +}/{@code -}, which are not
|
||||
* digits), more than 19 digits (the longest possible {@code Long.MAX_VALUE}), arithmetic
|
||||
* overflow past {@code Long.MAX_VALUE}, and a value above
|
||||
* {@link Http1Limits#MAX_CONTENT_LENGTH}. The pre-existing {@code parseLong} silently
|
||||
* skipped any non-digit character instead of rejecting it — {@code "5abc"} parsed as
|
||||
* {@code 5} and {@code "-1"} parsed as {@code 1}.
|
||||
*/
|
||||
private static long parseContentLengthStrict(byte[] buf, int start, int end) throws MalformedRequestException {
|
||||
int len = end - start;
|
||||
if (len == 0) throw new MalformedRequestException(400, "Empty Content-Length value");
|
||||
if (len > 19) throw new MalformedRequestException(400, "Content-Length value too long");
|
||||
long value = 0;
|
||||
for (int i = start; i < end; i++) {
|
||||
byte c = buf[i];
|
||||
if (c >= '0' && c <= '9') value = value * 10 + (c - '0');
|
||||
if (c < '0' || c > '9') {
|
||||
throw new MalformedRequestException(400, "Malformed Content-Length value");
|
||||
}
|
||||
int digit = c - '0';
|
||||
if (value > (Long.MAX_VALUE - digit) / 10) {
|
||||
throw new MalformedRequestException(400, "Content-Length overflow");
|
||||
}
|
||||
value = value * 10 + digit;
|
||||
}
|
||||
if (value > Http1Limits.MAX_CONTENT_LENGTH) {
|
||||
throw new MalformedRequestException(413, "Content-Length exceeds configured maximum");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9112 §6.1: when {@code Transfer-Encoding} lists multiple codings
|
||||
* ({@code "gzip, chunked"}), {@code chunked} MUST be the final one for the message to be
|
||||
* self-delimiting. Returns whether the last comma-separated token in {@code [start, end)}
|
||||
* is exactly {@code "chunked"} (case-insensitive), ignoring surrounding whitespace around
|
||||
* that token. Registered as {@code EX-35}: the previous whole-value comparison silently
|
||||
* misclassified any multi-coding value as non-chunked.
|
||||
*/
|
||||
private static boolean isFinalCodingChunked(byte[] buf, int start, int end) {
|
||||
int e = end;
|
||||
while (e > start && (buf[e - 1] == ' ' || buf[e - 1] == '\t')) e--;
|
||||
int lastComma = start - 1;
|
||||
for (int i = start; i < e; i++) {
|
||||
if (buf[i] == ',') lastComma = i;
|
||||
}
|
||||
int tokenStart = lastComma + 1;
|
||||
while (tokenStart < e && (buf[tokenStart] == ' ' || buf[tokenStart] == '\t')) tokenStart++;
|
||||
return equalsIgnoreCase(buf, tokenStart, e, "chunked");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.exceptions;
|
||||
|
||||
/**
|
||||
* Thrown by the HTTP/1.1 parser when a request violates a protocol rule that must be rejected
|
||||
* outright — most importantly the request-smuggling defenses of RFC 9112 §6.1 (see
|
||||
* {@code EX-02}/{@code EX-03} in {@code flash/docs/http2/IMPLEMENTATION-PLAN.md}) and the hard
|
||||
* safety limits in {@code Http1Limits} (see {@code EX-08}).
|
||||
*
|
||||
* <p>Distinct from {@link HttpException}, which a <em>handler</em> 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);
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<String> TLS12_H2_BLOCKED_CIPHERS = Set.of(
|
||||
"TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA",
|
||||
"TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA",
|
||||
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA",
|
||||
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA256",
|
||||
"TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA",
|
||||
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA",
|
||||
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256",
|
||||
"TLS_DHE_DSS_WITH_DES_CBC_SHA",
|
||||
"TLS_DHE_DSS_WITH_SEED_CBC_SHA",
|
||||
"TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA",
|
||||
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA",
|
||||
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA384",
|
||||
"TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
||||
"TLS_DHE_PSK_WITH_NULL_SHA",
|
||||
"TLS_DHE_PSK_WITH_NULL_SHA256",
|
||||
"TLS_DHE_PSK_WITH_NULL_SHA384",
|
||||
"TLS_DHE_PSK_WITH_RC4_128_SHA",
|
||||
"TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA",
|
||||
"TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA256",
|
||||
"TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA",
|
||||
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256",
|
||||
"TLS_DHE_RSA_WITH_DES_CBC_SHA",
|
||||
"TLS_DHE_RSA_WITH_SEED_CBC_SHA",
|
||||
"TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA",
|
||||
"TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_DH_DSS_WITH_AES_128_CBC_SHA",
|
||||
"TLS_DH_DSS_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_DH_DSS_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_DH_DSS_WITH_AES_256_CBC_SHA",
|
||||
"TLS_DH_DSS_WITH_AES_256_CBC_SHA256",
|
||||
"TLS_DH_DSS_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256",
|
||||
"TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384",
|
||||
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA",
|
||||
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256",
|
||||
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA",
|
||||
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256",
|
||||
"TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384",
|
||||
"TLS_DH_DSS_WITH_DES_CBC_SHA",
|
||||
"TLS_DH_DSS_WITH_SEED_CBC_SHA",
|
||||
"TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA",
|
||||
"TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_DH_RSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_DH_RSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_DH_RSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_DH_RSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_DH_RSA_WITH_AES_256_CBC_SHA256",
|
||||
"TLS_DH_RSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256",
|
||||
"TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384",
|
||||
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA",
|
||||
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
|
||||
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA",
|
||||
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256",
|
||||
"TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
|
||||
"TLS_DH_RSA_WITH_DES_CBC_SHA",
|
||||
"TLS_DH_RSA_WITH_SEED_CBC_SHA",
|
||||
"TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA",
|
||||
"TLS_DH_anon_EXPORT_WITH_RC4_40_MD5",
|
||||
"TLS_DH_anon_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_DH_anon_WITH_AES_128_CBC_SHA",
|
||||
"TLS_DH_anon_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_DH_anon_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_DH_anon_WITH_AES_256_CBC_SHA",
|
||||
"TLS_DH_anon_WITH_AES_256_CBC_SHA256",
|
||||
"TLS_DH_anon_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_DH_anon_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_DH_anon_WITH_ARIA_128_GCM_SHA256",
|
||||
"TLS_DH_anon_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_DH_anon_WITH_ARIA_256_GCM_SHA384",
|
||||
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA",
|
||||
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256",
|
||||
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA",
|
||||
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256",
|
||||
"TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384",
|
||||
"TLS_DH_anon_WITH_DES_CBC_SHA",
|
||||
"TLS_DH_anon_WITH_RC4_128_MD5",
|
||||
"TLS_DH_anon_WITH_SEED_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384",
|
||||
"TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
|
||||
"TLS_ECDHE_ECDSA_WITH_NULL_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_RC4_128_SHA",
|
||||
"TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA",
|
||||
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA",
|
||||
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384",
|
||||
"TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
||||
"TLS_ECDHE_PSK_WITH_NULL_SHA",
|
||||
"TLS_ECDHE_PSK_WITH_NULL_SHA256",
|
||||
"TLS_ECDHE_PSK_WITH_NULL_SHA384",
|
||||
"TLS_ECDHE_PSK_WITH_RC4_128_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_NULL_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_RC4_128_SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256",
|
||||
"TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256",
|
||||
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384",
|
||||
"TLS_ECDH_ECDSA_WITH_NULL_SHA",
|
||||
"TLS_ECDH_ECDSA_WITH_RC4_128_SHA",
|
||||
"TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384",
|
||||
"TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256",
|
||||
"TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384",
|
||||
"TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
|
||||
"TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384",
|
||||
"TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
|
||||
"TLS_ECDH_RSA_WITH_NULL_SHA",
|
||||
"TLS_ECDH_RSA_WITH_RC4_128_SHA",
|
||||
"TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDH_anon_WITH_AES_128_CBC_SHA",
|
||||
"TLS_ECDH_anon_WITH_AES_256_CBC_SHA",
|
||||
"TLS_ECDH_anon_WITH_NULL_SHA",
|
||||
"TLS_ECDH_anon_WITH_RC4_128_SHA",
|
||||
"TLS_EMPTY_RENEGOTIATION_INFO_SCSV",
|
||||
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5",
|
||||
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA",
|
||||
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5",
|
||||
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA",
|
||||
"TLS_KRB5_EXPORT_WITH_RC4_40_MD5",
|
||||
"TLS_KRB5_EXPORT_WITH_RC4_40_SHA",
|
||||
"TLS_KRB5_WITH_3DES_EDE_CBC_MD5",
|
||||
"TLS_KRB5_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_KRB5_WITH_DES_CBC_MD5",
|
||||
"TLS_KRB5_WITH_DES_CBC_SHA",
|
||||
"TLS_KRB5_WITH_IDEA_CBC_MD5",
|
||||
"TLS_KRB5_WITH_IDEA_CBC_SHA",
|
||||
"TLS_KRB5_WITH_RC4_128_MD5",
|
||||
"TLS_KRB5_WITH_RC4_128_SHA",
|
||||
"TLS_NULL_WITH_NULL_NULL",
|
||||
"TLS_PSK_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_PSK_WITH_AES_128_CBC_SHA",
|
||||
"TLS_PSK_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_PSK_WITH_AES_128_CCM",
|
||||
"TLS_PSK_WITH_AES_128_CCM_8",
|
||||
"TLS_PSK_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_PSK_WITH_AES_256_CBC_SHA",
|
||||
"TLS_PSK_WITH_AES_256_CBC_SHA384",
|
||||
"TLS_PSK_WITH_AES_256_CCM",
|
||||
"TLS_PSK_WITH_AES_256_CCM_8",
|
||||
"TLS_PSK_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_PSK_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_PSK_WITH_ARIA_128_GCM_SHA256",
|
||||
"TLS_PSK_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_PSK_WITH_ARIA_256_GCM_SHA384",
|
||||
"TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256",
|
||||
"TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
||||
"TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384",
|
||||
"TLS_PSK_WITH_NULL_SHA",
|
||||
"TLS_PSK_WITH_NULL_SHA256",
|
||||
"TLS_PSK_WITH_NULL_SHA384",
|
||||
"TLS_PSK_WITH_RC4_128_SHA",
|
||||
"TLS_RSA_EXPORT_WITH_DES40_CBC_SHA",
|
||||
"TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5",
|
||||
"TLS_RSA_EXPORT_WITH_RC4_40_MD5",
|
||||
"TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA",
|
||||
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_RSA_PSK_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA",
|
||||
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA384",
|
||||
"TLS_RSA_PSK_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256",
|
||||
"TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384",
|
||||
"TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256",
|
||||
"TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
||||
"TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384",
|
||||
"TLS_RSA_PSK_WITH_NULL_SHA",
|
||||
"TLS_RSA_PSK_WITH_NULL_SHA256",
|
||||
"TLS_RSA_PSK_WITH_NULL_SHA384",
|
||||
"TLS_RSA_PSK_WITH_RC4_128_SHA",
|
||||
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_RSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_RSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_RSA_WITH_AES_128_CCM",
|
||||
"TLS_RSA_WITH_AES_128_CCM_8",
|
||||
"TLS_RSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_RSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_RSA_WITH_AES_256_CBC_SHA256",
|
||||
"TLS_RSA_WITH_AES_256_CCM",
|
||||
"TLS_RSA_WITH_AES_256_CCM_8",
|
||||
"TLS_RSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||
"TLS_RSA_WITH_ARIA_128_GCM_SHA256",
|
||||
"TLS_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||
"TLS_RSA_WITH_ARIA_256_GCM_SHA384",
|
||||
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA",
|
||||
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||
"TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256",
|
||||
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA",
|
||||
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256",
|
||||
"TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384",
|
||||
"TLS_RSA_WITH_DES_CBC_SHA",
|
||||
"TLS_RSA_WITH_IDEA_CBC_SHA",
|
||||
"TLS_RSA_WITH_NULL_MD5",
|
||||
"TLS_RSA_WITH_NULL_SHA",
|
||||
"TLS_RSA_WITH_NULL_SHA256",
|
||||
"TLS_RSA_WITH_RC4_128_MD5",
|
||||
"TLS_RSA_WITH_RC4_128_SHA",
|
||||
"TLS_RSA_WITH_SEED_CBC_SHA",
|
||||
"TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA",
|
||||
"TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA",
|
||||
"TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_SRP_SHA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_SRP_SHA_WITH_AES_256_CBC_SHA"
|
||||
);
|
||||
|
||||
/** RFC 9113 §9.2.2: an h2 endpoint MUST support this cipher suite. Not enforced (Flash
|
||||
* cannot force a peer to offer it), but documented here as the fact {@link #applyTo}'s
|
||||
* filtering relies on: filtering the blocklist above out of the JDK's default enabled set
|
||||
* never removes this one, because it was never in the blocklist to begin with. */
|
||||
static final String REQUIRED_H2_CIPHER_SUITE = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256";
|
||||
|
||||
private final SSLContext context;
|
||||
private final boolean hardenDefaults;
|
||||
private final ClientAuth clientAuth;
|
||||
@@ -128,5 +434,32 @@ public final class TlsConfig {
|
||||
}
|
||||
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
|
||||
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
|
||||
|
||||
// EX-31: RFC 9113 §9.2.2 — when this listener can negotiate h2, the enabled cipher
|
||||
// suite list must exclude every suite on the TLS 1.2 blocklist. TLS 1.3 suites are
|
||||
// never in that list (see TLS12_H2_BLOCKED_CIPHERS's Javadoc) so this only narrows
|
||||
// which TLS 1.2 suites remain available; TLS 1.3 is unaffected either way.
|
||||
if (negotiatesH2()) {
|
||||
String[] enabled = socket.getEnabledCipherSuites();
|
||||
List<String> filtered = new ArrayList<>(enabled.length);
|
||||
for (String suite : enabled) {
|
||||
if (!TLS12_H2_BLOCKED_CIPHERS.contains(suite)) filtered.add(suite);
|
||||
}
|
||||
socket.setEnabledCipherSuites(filtered.toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this listener's configured ALPN protocol list ({@link #applicationProtocols})
|
||||
* includes {@code "h2"}. Lets a caller (the connection runner, for logging; {@link #applyTo}
|
||||
* itself, for {@code EX-31}'s cipher filtering) know a listener's h2 capability without
|
||||
* duplicating the offered-protocols check.
|
||||
*/
|
||||
public boolean negotiatesH2() {
|
||||
if (applicationProtocols == null) return false;
|
||||
for (String protocol : applicationProtocols) {
|
||||
if ("h2".equals(protocol)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package dev.relism.flash.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
|
||||
/**
|
||||
* The single buffered view over one connection's inbound bytes, for the whole lifetime of the
|
||||
* connection. Fixes {@code EX-10} (one syscall per byte in {@code ChunkedInputStream}) and
|
||||
* gives {@link dev.relism.flash.transport.ProtocolNegotiator} a way to inspect the first bytes
|
||||
* of a plaintext connection (the h2c preface) without consuming them.
|
||||
*
|
||||
* <h3>Why this exists instead of {@link java.io.BufferedInputStream}</h3>
|
||||
* 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).
|
||||
*
|
||||
* <h3>Deadline, not {@code SO_TIMEOUT} alone</h3>
|
||||
* {@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."
|
||||
*
|
||||
* <h3>Thread-safety</h3>
|
||||
* 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} <b>without
|
||||
* advancing the read position</b> — 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.
|
||||
*
|
||||
* <p>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 <b>before</b> 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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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").
|
||||
*
|
||||
* <p>Two independent signals, in order:
|
||||
* <ol>
|
||||
* <li><b>ALPN</b> (TLS connections). If the socket is an {@link SSLSocket} and the TLS
|
||||
* handshake already resolved {@code "h2"} as the application protocol, this connection is
|
||||
* {@link NegotiatedProtocol#H2}. Anything else negotiated — {@code "http/1.1"}, no
|
||||
* protocol at all (a peer that doesn't speak ALPN), or an empty string — is
|
||||
* {@link NegotiatedProtocol#HTTP_1_1}. This costs nothing beyond a field read: ALPN is
|
||||
* resolved during the handshake, which must already have completed (see
|
||||
* {@code TlsConfig}'s Javadoc on why {@code startHandshake()} must be called explicitly
|
||||
* before this method runs — {@code EX-30}).</li>
|
||||
* <li><b>h2c prior knowledge</b> (plaintext connections, RFC 9113 §3.4). The first 24 bytes of
|
||||
* the connection are compared, without being consumed, against the client connection
|
||||
* preface {@code "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"}. A match is
|
||||
* {@link NegotiatedProtocol#H2}; anything else — including a partial match followed by
|
||||
* EOF, or a preface look-alike that diverges partway through — is
|
||||
* {@link NegotiatedProtocol#HTTP_1_1}. This is why {@link BufferedByteSource#peek} exists:
|
||||
* the bytes must remain available for {@code RequestParser} if they turn out not to be an
|
||||
* h2 preface after all.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
List<String> enabled = Arrays.asList(socket.getEnabledCipherSuites());
|
||||
// Spot-check a handful of RFC 9113 Appendix A entries across different families
|
||||
// (RSA key exchange, 3DES, plain ECDHE-CBC) rather than the full ~280-entry list —
|
||||
// TLS12_H2_BLOCKED_CIPHERS itself is the source of truth for the complete set.
|
||||
assertFalse(enabled.contains("TLS_RSA_WITH_AES_128_CBC_SHA"));
|
||||
assertFalse(enabled.contains("TLS_RSA_WITH_3DES_EDE_CBC_SHA"));
|
||||
assertFalse(enabled.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"));
|
||||
assertFalse(enabled.contains("TLS_NULL_WITH_NULL_NULL"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyTo_withH2Offered_keepsTheRequiredCipherSuiteWhenTheJdkEnabledIt() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2");
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
boolean jdkEnabledItByDefault =
|
||||
Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE);
|
||||
tls.applyTo(socket);
|
||||
if (jdkEnabledItByDefault) {
|
||||
assertTrue(Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE),
|
||||
"RFC 9113 §9.2.2 requires supporting this suite — filtering must never remove it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyTo_withoutH2Offered_leavesCipherSuitesUntouched() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("http/1.1");
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
|
||||
tls.applyTo(socket);
|
||||
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyTo_noApplicationProtocolsAtAll_leavesCipherSuitesUntouched() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx);
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
|
||||
tls.applyTo(socket);
|
||||
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package dev.relism.flash.transport;
|
||||
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.net.ssl.SSLServerSocket;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link ProtocolNegotiator#negotiate} is a pure, directly-testable detector (see its Javadoc
|
||||
* for why it does not itself consult {@code FlashConfiguration.http2Enabled}) — every case here
|
||||
* calls it directly rather than through {@code HttpServer}.
|
||||
*/
|
||||
class ProtocolNegotiatorTest {
|
||||
|
||||
// ── Plaintext (h2c prior knowledge) — no real networking needed ────────────
|
||||
|
||||
private static BufferedByteSource plaintextSource(String bytes) {
|
||||
return new BufferedByteSource(
|
||||
new ByteArrayInputStream(bytes.getBytes(StandardCharsets.US_ASCII)), new Socket());
|
||||
}
|
||||
|
||||
@Test
|
||||
void h2cPrefaceExact_negotiatesH2() throws IOException {
|
||||
BufferedByteSource src = plaintextSource("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n");
|
||||
assertEquals(NegotiatedProtocol.H2, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
}
|
||||
|
||||
@Test
|
||||
void h2cPrefaceFollowedByMoreData_stillNegotiatesH2_andDoesNotConsume() throws IOException {
|
||||
BufferedByteSource src = plaintextSource("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\nEXTRA");
|
||||
assertEquals(NegotiatedProtocol.H2, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
// peek() must not have consumed anything — the full 24-byte preface is still there for
|
||||
// whatever reads next (Http2Connection, once it exists).
|
||||
byte[] readBack = new byte[24];
|
||||
assertEquals(24, src.read(readBack, 0, 24));
|
||||
assertEquals("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n",
|
||||
new String(readBack, StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialPreface_thenEof_negotiatesHttp1_notConsumed() throws IOException {
|
||||
// Fewer than 24 bytes total, then EOF — not a match, and RequestParser must still see
|
||||
// every byte that was actually sent.
|
||||
BufferedByteSource src = plaintextSource("PRI * HTTP/2.0\r\n");
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
byte[] readBack = new byte[16];
|
||||
assertEquals(16, src.read(readBack, 0, 16));
|
||||
assertEquals("PRI * HTTP/2.0\r\n", new String(readBack, StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefaceLookalike_divergesPartway_negotiatesHttp1() throws IOException {
|
||||
// "PRI " matches, then diverges — must not be misdetected as h2c.
|
||||
BufferedByteSource src = plaintextSource("PRI * HTTP/9.9\r\n\r\nXX\r\n\r\n");
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainGetRequest_negotiatesHttp1() throws IOException {
|
||||
BufferedByteSource src = plaintextSource("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
byte[] readBack = new byte[15];
|
||||
assertEquals(15, src.read(readBack, 0, 15));
|
||||
assertEquals("GET / HTTP/1.1\r", new String(readBack, StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
// ── TLS/ALPN — a real loopback handshake, since ALPN is resolved during it ─
|
||||
|
||||
private interface ThrowingConsumer<T> { void accept(T t) throws Exception; }
|
||||
|
||||
/**
|
||||
* Binds a real TLS listener offering {@code serverAlpn}, connects a client offering
|
||||
* {@code clientAlpn}, forces the handshake on both sides (mirroring {@code HttpServer}'s
|
||||
* EX-30 fix), and hands the accepted server-side socket to {@code assertion}.
|
||||
*/
|
||||
private static void withNegotiatedAlpn(Path dir, String[] serverAlpn, String[] clientAlpn,
|
||||
ThrowingConsumer<SSLSocket> assertion) throws Exception {
|
||||
Path ks = TestKeystores.build(dir, "negotiator.p12", "changeit",
|
||||
TestKeystores.Entry.of("only", "negotiator.test"));
|
||||
TlsConfig serverTls = TlsConfig.keystore(ks, "changeit");
|
||||
if (serverAlpn != null) serverTls = serverTls.applicationProtocols(serverAlpn);
|
||||
|
||||
try (SSLServerSocket serverSocket = (SSLServerSocket) serverTls.serverSocketFactory().createServerSocket()) {
|
||||
serverTls.applyTo(serverSocket);
|
||||
serverSocket.bind(new InetSocketAddress("127.0.0.1", 0));
|
||||
int port = serverSocket.getLocalPort();
|
||||
|
||||
CompletableFuture<SSLSocket> accepted = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
SSLSocket s = (SSLSocket) serverSocket.accept();
|
||||
s.startHandshake();
|
||||
return s;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
SSLSocketFactory clientFactory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||
try (SSLSocket client = (SSLSocket) clientFactory.createSocket("127.0.0.1", port)) {
|
||||
if (clientAlpn != null) {
|
||||
javax.net.ssl.SSLParameters params = client.getSSLParameters();
|
||||
params.setApplicationProtocols(clientAlpn);
|
||||
client.setSSLParameters(params);
|
||||
}
|
||||
client.startHandshake();
|
||||
|
||||
try (SSLSocket server = accepted.get()) {
|
||||
assertion.accept(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void alpnH2_negotiatesH2(@TempDir Path dir) throws Exception {
|
||||
withNegotiatedAlpn(dir, new String[]{"h2", "http/1.1"}, new String[]{"h2", "http/1.1"}, server -> {
|
||||
assertEquals("h2", server.getApplicationProtocol());
|
||||
assertEquals(NegotiatedProtocol.H2,
|
||||
ProtocolNegotiator.negotiate(server, new BufferedByteSource(server.getInputStream(), server)));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void alpnHttp11_negotiatesHttp1(@TempDir Path dir) throws Exception {
|
||||
withNegotiatedAlpn(dir, new String[]{"h2", "http/1.1"}, new String[]{"http/1.1"}, server -> {
|
||||
assertEquals("http/1.1", server.getApplicationProtocol());
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1,
|
||||
ProtocolNegotiator.negotiate(server, new BufferedByteSource(server.getInputStream(), server)));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void alpnAbsent_negotiatesHttp1(@TempDir Path dir) throws Exception {
|
||||
// Neither side offers ALPN at all — the common case today.
|
||||
withNegotiatedAlpn(dir, null, null, server -> {
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1,
|
||||
ProtocolNegotiator.negotiate(server, new BufferedByteSource(server.getInputStream(), server)));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user