feat(core): HTTP/2 Phase 1 — HTTP/1.1 hardening and protocol negotiation

Fixes the request-smuggling and resource-exhaustion debt in the existing
HTTP/1.1 parser, and adds the ALPN/h2c-preface negotiation seam so a
connection's protocol is decided once, before any request is parsed, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 1.

Existing-code defects fixed (EX-nn):
- EX-02: reject Content-Length + Transfer-Encoding together (RFC 9112 6.1
  CL.TE/TE.CL smuggling), and conflicting duplicate Content-Length values.
- EX-03: strict, overflow-safe Content-Length parsing, replacing a parser
  that silently skipped non-digit bytes ("5abc" -> 5, "-1" -> 1).
- EX-07: header-read / idle-keep-alive / body-read timeouts enforced by an
  absolute deadline (dev.relism.flash.transport.BufferedByteSource), not
  merely Socket#setSoTimeout, which never trips against a peer trickling
  one byte per read within the window.
- EX-08: header count / name length / value length / request-line length
  bounds (Http1Limits), 431 on violation.
- EX-10: ChunkedInputStream now reads through BufferedByteSource instead
  of the raw unbuffered socket stream, and the header-parser's read-ahead
  bytes are handed over via a zero-copy prependOnce() instead of a
  SequenceInputStream/ByteArrayInputStream pair.
- EX-17: HttpStatus's status-code bound is computed from values() instead
  of a hand-maintained constant that silently threw
  ArrayIndexOutOfBoundsException when a code above it was added; added
  421, 431, 505, 507, 511 and others HTTP/2 and this hardening need.
- EX-18: bare-CR desync and obsolete line folding rejected.
- EX-30: the TLS handshake is forced explicitly, under a timeout, before
  any protocol decision -- SSLSocket#getApplicationProtocol() returned
  null until the handshake had run, and nothing previously forced it.
- EX-31: TLS 1.2 cipher suites on the RFC 9113 Appendix A blocklist are
  filtered out of a listener's enabled set whenever it offers h2 via ALPN.
- EX-35 (found in this phase): Transfer-Encoding values listing multiple
  codings ("gzip, chunked") were silently treated as not chunked at all,
  corrupting the message boundary -- only the whole value was compared.
- EX-36 (found in this phase): a header line with no ':' was silently
  skipped instead of rejected.

New:
- dev.relism.flash.transport.BufferedByteSource: the single buffered,
  deadline-aware, peekable view over a connection's inbound bytes.
- dev.relism.flash.transport.ProtocolNegotiator/NegotiatedProtocol: ALPN
  and h2c prior-knowledge detection. In this phase an H2 result is always
  closed cleanly -- there is no Http2Connection to hand off to until
  Phase 8. FlashConfiguration.http2Enabled gates the h2c preface peek.
- dev.relism.flash.exceptions.MalformedRequestException: a typed,
  status-carrying rejection distinct from HttpException, caught at the
  parse site so a malformed request never reaches the handler chain or
  the user's exception handler, and the connection is always closed.

Two small plan-document corrections recorded as DEC-12 (Phase 1's Files
list omitted BufferedByteSource.java and MalformedRequestException.java;
the request-line-length check description pointed at the wrong offset).
DEC-13/DEC-14 record the deadline and exception-hierarchy designs.

277/277 tests green (flash module), run twice for stability of the new
wall-clock-based HttpServerTimeoutTest cases. Whole-repo build green.
h1 benchmark regression check is left unverified in the plan's DoD: no
JMH harness exists yet (Phase 3 deliverable).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-13 11:40:03 +00:00
co-authored by Claude Sonnet 5
parent db6e4a4d0c
commit 5a2aaf5a07
22 changed files with 2252 additions and 93 deletions
@@ -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;
}
}