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,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)));
});
}
}