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:
co-authored by
Claude Sonnet 5
parent
db6e4a4d0c
commit
5a2aaf5a07
@@ -1,5 +1,8 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.exceptions.MalformedRequestException;
|
||||
import dev.relism.flash.http.Http1Limits;
|
||||
import dev.relism.flash.transport.BufferedByteSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
@@ -10,9 +13,15 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ChunkedInputStreamTest {
|
||||
|
||||
// BufferedByteSource's Socket reference is only touched when a deadline is set — none of
|
||||
// these tests set one, so `null` is safe here.
|
||||
private static BufferedByteSource source(byte[] bytes) {
|
||||
return new BufferedByteSource(new ByteArrayInputStream(bytes), null);
|
||||
}
|
||||
|
||||
private static ChunkedInputStream wrap(String chunkedEncoded) {
|
||||
byte[] bytes = chunkedEncoded.getBytes(StandardCharsets.UTF_8);
|
||||
return new ChunkedInputStream(new ByteArrayInputStream(bytes), null, 0, 0);
|
||||
return new ChunkedInputStream(source(bytes), null, 0, 0);
|
||||
}
|
||||
|
||||
private static String readAll(ChunkedInputStream in) throws IOException {
|
||||
@@ -105,7 +114,7 @@ class ChunkedInputStreamTest {
|
||||
// "5\r\nhello" in preBuf, "\r\n0\r\n\r\n" in socket
|
||||
byte[] preBuf = "5\r\nhello".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] socket = "\r\n0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
ChunkedInputStream in = new ChunkedInputStream(new ByteArrayInputStream(socket), preBuf, 0, preBuf.length);
|
||||
ChunkedInputStream in = new ChunkedInputStream(source(socket), preBuf, 0, preBuf.length);
|
||||
assertEquals("hello", new String(in.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@@ -114,7 +123,103 @@ class ChunkedInputStreamTest {
|
||||
byte[] preBuf = "XX2\r\nhi\r\n0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
// offset=2, len=preBuf.length-2 — skip "XX"
|
||||
ChunkedInputStream in = new ChunkedInputStream(
|
||||
new ByteArrayInputStream(new byte[0]), preBuf, 2, preBuf.length - 2);
|
||||
source(new byte[0]), preBuf, 2, preBuf.length - 2);
|
||||
assertEquals("hi", new String(in.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// --- EX-10: no per-byte syscalls against the underlying stream ------------
|
||||
|
||||
/** Counts every {@code read} call that reaches the wrapped stream — i.e. every syscall. */
|
||||
private static final class CountingInputStream extends ByteArrayInputStream {
|
||||
int reads = 0;
|
||||
CountingInputStream(byte[] buf) { super(buf); }
|
||||
@Override public synchronized int read() { reads++; return super.read(); }
|
||||
@Override public synchronized int read(byte[] b, int off, int len) { reads++; return super.read(b, off, len); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void byteByByteRead_doesNotSyscallPerByte() throws IOException {
|
||||
// 100 one-byte chunks — the pre-fix implementation would have issued one read() call
|
||||
// per payload byte PLUS one per chunk-size digit PLUS two per chunk terminator PLUS
|
||||
// two for the final trailer-section terminator: hundreds of underlying reads for 100
|
||||
// bytes of payload. Buffered, this must collapse to a small, buffer-size-bound count.
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 100; i++) sb.append("1\r\nx\r\n");
|
||||
sb.append("0\r\n\r\n");
|
||||
CountingInputStream counting = new CountingInputStream(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
BufferedByteSource src = new BufferedByteSource(counting, null);
|
||||
ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
|
||||
|
||||
int total = 0;
|
||||
while (in.read() != -1) total++;
|
||||
|
||||
assertEquals(100, total);
|
||||
// The whole message (700 bytes) fits in BufferedByteSource's default 8 KB buffer, so
|
||||
// this must be exactly one underlying read — nowhere near "one per byte".
|
||||
assertEquals(1, counting.reads);
|
||||
}
|
||||
|
||||
// --- EX-02/09 chunk safety limits ------------------------------------------
|
||||
|
||||
@Test
|
||||
void chunkSizeAboveLimit_rejected() {
|
||||
// MAX_CHUNK_SIZE is 16 MiB (0x1000000); one hex digit past that overflows the bound.
|
||||
BufferedByteSource src = source("10000000\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
|
||||
assertThrows(MalformedRequestException.class, in::read);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooManyHexDigits_rejected() {
|
||||
BufferedByteSource src = source("00000000000000001\r\n".getBytes(StandardCharsets.UTF_8)); // 17 digits
|
||||
ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
|
||||
assertThrows(MalformedRequestException.class, in::read);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chunkExtensionTooLong_rejected() {
|
||||
String ext = ";" + "a".repeat(300);
|
||||
BufferedByteSource src = source(("5" + ext + "\r\nhello\r\n0\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
|
||||
assertThrows(MalformedRequestException.class, in::read);
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedChunkSize_rejected() {
|
||||
BufferedByteSource src = source(";novalue\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
|
||||
assertThrows(MalformedRequestException.class, in::read);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareChunkTerminator_rejected() {
|
||||
// Declares 5 bytes but the terminator after them is not CRLF.
|
||||
BufferedByteSource src = source("5\r\nhelloXX0\r\n\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
|
||||
assertThrows(MalformedRequestException.class, () -> in.readAllBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooManyChunks_rejected413() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < Http1Limits.MAX_CHUNKS_PER_BODY + 5; i++) sb.append("1\r\nx\r\n");
|
||||
sb.append("0\r\n\r\n");
|
||||
BufferedByteSource src = source(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
|
||||
MalformedRequestException e = assertThrows(MalformedRequestException.class, () -> {
|
||||
while (in.read() != -1) { /* drain */ }
|
||||
});
|
||||
assertEquals(413, e.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooManyTrailers_rejected431() {
|
||||
StringBuilder sb = new StringBuilder("2\r\nhi\r\n0\r\n");
|
||||
for (int i = 0; i < Http1Limits.MAX_TRAILER_COUNT + 5; i++) sb.append("X-").append(i).append(": v\r\n");
|
||||
sb.append("\r\n");
|
||||
BufferedByteSource src = source(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0);
|
||||
MalformedRequestException e = assertThrows(MalformedRequestException.class, in::readAllBytes);
|
||||
assertEquals(431, e.status());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@code EX-07}: a socket-level {@code SO_TIMEOUT} alone never trips against a peer that keeps
|
||||
* trickling bytes slower than the timeout window — each individual read still succeeds. These
|
||||
* tests prove the absolute deadline in {@code dev.relism.flash.transport.BufferedByteSource}
|
||||
* actually bounds the total time, not just each read.
|
||||
*/
|
||||
class HttpServerTimeoutTest {
|
||||
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
private int freePort() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
return s.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void slowlorisHeaderDribble_disconnectedWithinHeaderReadTimeout() throws Exception {
|
||||
int headerTimeoutMs = 300;
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.headerReadTimeoutMs(headerTimeoutMs)
|
||||
.idleKeepAliveTimeoutMs(60_000)
|
||||
.build());
|
||||
app.get("/", (req, res) -> "ok");
|
||||
app.start();
|
||||
|
||||
long start = System.nanoTime();
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(5_000);
|
||||
OutputStream out = socket.getOutputStream();
|
||||
// One byte of a request line, then silence — never completes the header block.
|
||||
out.write('G');
|
||||
out.flush();
|
||||
|
||||
// The server must close its side within headerReadTimeoutMs (+ generous slack for
|
||||
// scheduling). Detected as EOF (-1) or a reset when the client tries to read.
|
||||
int result = socket.getInputStream().read();
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
assertEquals(-1, result, "server must close, not hang, after the header deadline");
|
||||
assertTrue(elapsedMs < 5_000, "must not have fallen back to the client's own SO_TIMEOUT");
|
||||
assertTrue(elapsedMs >= headerTimeoutMs - 50,
|
||||
"must not close before the configured deadline (was " + elapsedMs + "ms)");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void idleKeepAliveConnection_disconnectedWithinIdleTimeout() throws Exception {
|
||||
int idleTimeoutMs = 300;
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.headerReadTimeoutMs(10_000)
|
||||
.idleKeepAliveTimeoutMs(idleTimeoutMs)
|
||||
.build());
|
||||
app.get("/", (req, res) -> "ok");
|
||||
app.start();
|
||||
|
||||
long start = System.nanoTime();
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(5_000);
|
||||
// Send nothing at all — a connection accepted and then left idle.
|
||||
int result = socket.getInputStream().read();
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
assertEquals(-1, result);
|
||||
assertTrue(elapsedMs < 5_000);
|
||||
assertTrue(elapsedMs >= idleTimeoutMs - 50, "was " + elapsedMs + "ms");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void slowBodyDribble_disconnectedWithinBodyReadTimeout() throws Exception {
|
||||
int bodyTimeoutMs = 300;
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.headerReadTimeoutMs(10_000)
|
||||
.idleKeepAliveTimeoutMs(10_000)
|
||||
.bodyReadTimeoutMs(bodyTimeoutMs)
|
||||
.build());
|
||||
app.post("/echo", (req, res) -> req.body().bytes());
|
||||
app.start();
|
||||
|
||||
long start = System.nanoTime();
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(5_000);
|
||||
OutputStream out = socket.getOutputStream();
|
||||
out.write(("POST /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\n" + "x".repeat(5))
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
// Only 5 of the declared 100 bytes were sent; the remaining 95 never arrive. Whether
|
||||
// the server responds with an error before closing or simply closes, *something*
|
||||
// must happen within the body deadline rather than a hang until the client's own
|
||||
// (much longer) timeout.
|
||||
socket.getInputStream().read();
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
assertTrue(elapsedMs < 5_000, "must not have fallen back to the client's own SO_TIMEOUT");
|
||||
assertTrue(elapsedMs >= bodyTimeoutMs - 50, "was " + elapsedMs + "ms");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void tlsHandshakeNeverStarted_disconnectedWithinHeaderReadTimeout(@TempDir Path dir) throws Exception {
|
||||
int headerTimeoutMs = 300;
|
||||
Path ks = TestKeystores.build(dir, "timeout.p12", "changeit",
|
||||
TestKeystores.Entry.of("only", "timeout.test"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.headerReadTimeoutMs(headerTimeoutMs)
|
||||
.build());
|
||||
app.get("/", (req, res) -> "ok");
|
||||
app.start();
|
||||
|
||||
long start = System.nanoTime();
|
||||
// A plain socket that never speaks TLS at all — the server's explicit
|
||||
// startHandshake() (EX-30) blocks waiting for a ClientHello that is never coming,
|
||||
// and must be bounded by headerReadTimeoutMs rather than hanging forever. Whether the
|
||||
// JSSE implementation sends a TLS alert record before closing or just closes outright
|
||||
// is a JSSE implementation detail, not something this test should pin down — the
|
||||
// property under test is purely the bound on wall-clock time.
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(5_000);
|
||||
socket.getInputStream().read();
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
assertTrue(elapsedMs < 5_000, "must not have fallen back to the client's own SO_TIMEOUT");
|
||||
assertTrue(elapsedMs >= headerTimeoutMs - 50, "was " + elapsedMs + "ms");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wellBehavedRequest_wellWithinTimeouts_unaffected() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.headerReadTimeoutMs(300)
|
||||
.idleKeepAliveTimeoutMs(300)
|
||||
.bodyReadTimeoutMs(300)
|
||||
.build());
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(5_000);
|
||||
socket.getOutputStream().write(
|
||||
"GET /ping HTTP/1.1\r\nHost: localhost\r\n\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
socket.getOutputStream().flush();
|
||||
byte[] buf = new byte[4096];
|
||||
int n = socket.getInputStream().read(buf);
|
||||
assertTrue(n > 0);
|
||||
String response = new String(buf, 0, n, StandardCharsets.UTF_8);
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(response.contains("pong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.exceptions.MalformedRequestException;
|
||||
import dev.relism.flash.http.Http1Limits;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.transport.BufferedByteSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* One test per rejection rule added in HTTP/2 plan Phase 1 (EX-02, EX-03, EX-08, EX-18, EX-35,
|
||||
* EX-36), each asserting the specific status code {@link MalformedRequestException} carries —
|
||||
* not merely that some exception was thrown. {@code HttpServer} always closes the connection
|
||||
* after any of these (never keep-alive); that behaviour is exercised at the integration level
|
||||
* by {@code HttpServerTest}.
|
||||
*/
|
||||
class RequestParserSecurityTest {
|
||||
|
||||
private static BufferedByteSource source(byte[] bytes) {
|
||||
return new BufferedByteSource(new ByteArrayInputStream(bytes), null);
|
||||
}
|
||||
|
||||
private static Request parse(String raw) throws IOException {
|
||||
byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
return new RequestParser().parse(source(bytes));
|
||||
}
|
||||
|
||||
private static MalformedRequestException expect(String raw) {
|
||||
return assertThrows(MalformedRequestException.class, () -> parse(raw));
|
||||
}
|
||||
|
||||
// --- EX-02: Content-Length + Transfer-Encoding smuggling -------------------
|
||||
|
||||
@Test
|
||||
void contentLengthAndTransferEncodingBothPresent_rejected400() {
|
||||
MalformedRequestException e = expect(
|
||||
"POST / HTTP/1.1\nHost: h\nContent-Length: 5\nTransfer-Encoding: chunked\n\nhello");
|
||||
assertEquals(400, e.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLengthAndTransferEncodingBothPresent_rejectedRegardlessOfOrder() {
|
||||
// The check must not be bypassable by which header appears first.
|
||||
MalformedRequestException e = expect(
|
||||
"POST / HTTP/1.1\nHost: h\nTransfer-Encoding: chunked\nContent-Length: 5\n\nhello");
|
||||
assertEquals(400, e.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateContentLength_conflictingValues_rejected400() {
|
||||
MalformedRequestException e = expect(
|
||||
"POST / HTTP/1.1\nHost: h\nContent-Length: 5\nContent-Length: 6\n\nhello!");
|
||||
assertEquals(400, e.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateContentLength_identicalValues_accepted() throws IOException {
|
||||
Request r = parse("POST / HTTP/1.1\nHost: h\nContent-Length: 5\nContent-Length: 5\n\nhello");
|
||||
assertEquals(5L, r.body().contentLength());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferEncoding_finalCodingNotChunked_rejected501() {
|
||||
MalformedRequestException e = expect(
|
||||
"POST / HTTP/1.1\nHost: h\nTransfer-Encoding: gzip\n\n");
|
||||
assertEquals(501, e.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferEncoding_chunkedNotFinal_rejected501() {
|
||||
// "chunked, gzip" — chunked must be the LAST coding (RFC 9112 §6.1).
|
||||
MalformedRequestException e = expect(
|
||||
"POST / HTTP/1.1\nHost: h\nTransfer-Encoding: chunked, gzip\n\n");
|
||||
assertEquals(501, e.status());
|
||||
}
|
||||
|
||||
// --- EX-03: strict Content-Length parsing -----------------------------------
|
||||
|
||||
@Test
|
||||
void contentLength_nonDigitSuffix_rejected400() {
|
||||
assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: 5abc\n\n").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_leadingPlus_rejected400() {
|
||||
assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: +5\n\n").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_leadingMinus_rejected400() {
|
||||
assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: -1\n\n").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_empty_rejected400() {
|
||||
assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: \n\n").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_overflowsLong_rejected400() {
|
||||
assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: 99999999999999999999\n\n").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_aboveConfiguredMax_rejected413() {
|
||||
long tooLarge = Http1Limits.MAX_CONTENT_LENGTH + 1;
|
||||
assertEquals(413, expect("POST / HTTP/1.1\nHost: h\nContent-Length: " + tooLarge + "\n\n").status());
|
||||
}
|
||||
|
||||
// --- EX-08: header/request-line limits --------------------------------------
|
||||
|
||||
@Test
|
||||
void tooManyHeaders_rejected431() {
|
||||
StringBuilder sb = new StringBuilder("GET / HTTP/1.1\nHost: h\n");
|
||||
for (int i = 0; i < Http1Limits.MAX_HEADER_COUNT + 5; i++) sb.append("X-").append(i).append(": v\n");
|
||||
sb.append("\n");
|
||||
assertEquals(431, expect(sb.toString()).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void headerNameTooLong_rejected431() {
|
||||
String name = "X-" + "a".repeat(Http1Limits.MAX_HEADER_NAME_LENGTH + 1);
|
||||
assertEquals(431, expect("GET / HTTP/1.1\nHost: h\n" + name + ": v\n\n").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void headerValueTooLong_rejected431() {
|
||||
String value = "a".repeat(Http1Limits.MAX_HEADER_VALUE_LENGTH + 1);
|
||||
assertEquals(431, expect("GET / HTTP/1.1\nHost: h\nX-Big: " + value + "\n\n").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestLineTooLong_rejected431() {
|
||||
String path = "/" + "a".repeat(Http1Limits.MAX_REQUEST_LINE_LENGTH + 1);
|
||||
assertEquals(431, expect("GET " + path + " HTTP/1.1\nHost: h\n\n").status());
|
||||
}
|
||||
|
||||
// --- EX-18: bare CR / obs-fold -----------------------------------------------
|
||||
|
||||
@Test
|
||||
void bareLfInsteadOfCrlf_headerLine_rejected() {
|
||||
// A '\r' not immediately followed by '\n' desynchronizes the parse.
|
||||
byte[] raw = "GET / HTTP/1.1\r\nHost: h\r\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void obsFold_leadingWhitespaceContinuation_rejected400() {
|
||||
MalformedRequestException e = assertThrows(MalformedRequestException.class, () ->
|
||||
new RequestParser().parse(source(
|
||||
"GET / HTTP/1.1\r\nHost: h\r\n Folded: continuation\r\n\r\n"
|
||||
.getBytes(StandardCharsets.UTF_8))));
|
||||
assertEquals(400, e.status());
|
||||
}
|
||||
|
||||
// --- header name tchar validation --------------------------------------------
|
||||
|
||||
@Test
|
||||
void headerNameWithSpace_rejected400() {
|
||||
assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nBad Name: v\n\n").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void headerNameWithControlChar_rejected400() {
|
||||
String prefix = "GET / HTTP/1.1\r\nHost: h\r\nBad";
|
||||
String suffix = "Name: v\r\n\r\n";
|
||||
byte[] prefixBytes = prefix.getBytes(StandardCharsets.ISO_8859_1);
|
||||
byte[] suffixBytes = suffix.getBytes(StandardCharsets.ISO_8859_1);
|
||||
byte[] raw = new byte[prefixBytes.length + 1 + suffixBytes.length];
|
||||
System.arraycopy(prefixBytes, 0, raw, 0, prefixBytes.length);
|
||||
raw[prefixBytes.length] = 0x01; // control character -- not a valid tchar
|
||||
System.arraycopy(suffixBytes, 0, raw, prefixBytes.length + 1, suffixBytes.length);
|
||||
assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw)));
|
||||
}
|
||||
|
||||
// --- EX-36: header line missing ':' -------------------------------------------
|
||||
|
||||
@Test
|
||||
void headerLineMissingColon_rejected400() {
|
||||
assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nNotAHeader\n\n").status());
|
||||
}
|
||||
|
||||
// --- request-line rejections still carry the right status --------------------
|
||||
|
||||
@Test
|
||||
void emptyMethod_rejected400() {
|
||||
assertEquals(400, expect(" / HTTP/1.1\nHost: h\n\n").status());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.exceptions.MalformedRequestException;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.transport.BufferedByteSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
@@ -14,9 +16,15 @@ class RequestParserTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// BufferedByteSource's Socket reference is only touched when a deadline is set — none of
|
||||
// these tests set one, so `null` is safe here.
|
||||
private static BufferedByteSource source(byte[] bytes) {
|
||||
return new BufferedByteSource(new ByteArrayInputStream(bytes), null);
|
||||
}
|
||||
|
||||
private static Request parse(String raw) throws IOException {
|
||||
byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
return new RequestParser().parse(new ByteArrayInputStream(bytes));
|
||||
return new RequestParser().parse(source(bytes));
|
||||
}
|
||||
|
||||
private static String req(String requestLine, String... headers) {
|
||||
@@ -70,7 +78,7 @@ class RequestParserTest {
|
||||
void body_parsed() throws IOException {
|
||||
String body = "hello body";
|
||||
String raw = "POST / HTTP/1.1\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
|
||||
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
@@ -79,7 +87,7 @@ class RequestParserTest {
|
||||
void body_parsed_forQueryMethod() throws IOException {
|
||||
String body = "{\"filter\":\"active\"}";
|
||||
String raw = "QUERY / HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
|
||||
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(dev.relism.flash.http.HttpMethod.QUERY, r.method());
|
||||
assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8));
|
||||
@@ -95,34 +103,43 @@ class RequestParserTest {
|
||||
|
||||
@Test
|
||||
void emptyInputStream_returnsNull() throws IOException {
|
||||
assertNull(new RequestParser().parse(new ByteArrayInputStream(new byte[0])));
|
||||
assertNull(new RequestParser().parse(source(new byte[0])));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingHeaderTerminator_throwsIOException() {
|
||||
// Valid request line but stream ends before \r\n\r\n
|
||||
void missingHeaderTerminator_throwsMalformedRequestException() {
|
||||
// Valid request line but stream ends before \r\n\r\n. Previously a generic IOException;
|
||||
// now the same typed rejection EX-08's over-limit case uses, since both mean "the
|
||||
// header block could never be completed within the allowed buffer" (EX-08).
|
||||
byte[] raw = "GET / HTTP/1.1\r\nHost: localhost\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
assertThrows(IOException.class, () -> new RequestParser().parse(new ByteArrayInputStream(raw)));
|
||||
assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownHttpMethod_throwsIOException() {
|
||||
assertThrows(IOException.class, () -> parse(req("BREW /coffee HTTP/1.1", "Host: localhost")));
|
||||
void unknownHttpMethod_throwsMalformedRequestException() {
|
||||
// RFC 9110 §9.1 SHOULD 501 an unrecognised method.
|
||||
MalformedRequestException e = assertThrows(MalformedRequestException.class,
|
||||
() -> parse(req("BREW /coffee HTTP/1.1", "Host: localhost")));
|
||||
assertEquals(501, e.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestLine_noProtocol_throwsIOException() {
|
||||
void requestLine_noProtocol_throwsMalformedRequestException() {
|
||||
// No space after path, parser cannot find protocol boundary
|
||||
assertThrows(IOException.class, () -> parse(req("GET /noproto")));
|
||||
MalformedRequestException e = assertThrows(MalformedRequestException.class,
|
||||
() -> parse(req("GET /noproto")));
|
||||
assertEquals(400, e.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void headers_exceedingMaxBufferSize_throwsIOException() {
|
||||
// Feed more bytes than the configured cap with no \r\n\r\n : must throw
|
||||
void headers_exceedingMaxBufferSize_throwsMalformedRequestException() {
|
||||
// Feed more bytes than the configured cap with no \r\n\r\n : must throw 431 (EX-08).
|
||||
int cap = 16 * 1024;
|
||||
byte[] giant = new byte[cap + 1];
|
||||
Arrays.fill(giant, (byte) 'A');
|
||||
assertThrows(IOException.class, () -> new RequestParser(cap).parse(new ByteArrayInputStream(giant)));
|
||||
MalformedRequestException e = assertThrows(MalformedRequestException.class,
|
||||
() -> new RequestParser(cap).parse(source(giant)));
|
||||
assertEquals(431, e.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -132,22 +149,38 @@ class RequestParserTest {
|
||||
"Transfer-Encoding: chunked\r\n" +
|
||||
"\r\n" +
|
||||
"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
|
||||
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(-1L, r.body().contentLength()); // -1 = chunked
|
||||
assertArrayEquals("hello world".getBytes(StandardCharsets.UTF_8), r.body().bytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_parsedAsLong() throws IOException {
|
||||
// 5 GB — too large to materialize, but contentLength must be a long
|
||||
void transferEncoding_multiValueEndingInChunked_recognised() throws IOException {
|
||||
// EX-35: "gzip, chunked" — chunked need only be the FINAL coding (RFC 9112 §6.1). The
|
||||
// old whole-value comparison misclassified this as not chunked at all.
|
||||
String raw = "POST / HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Content-Length: 5000000000\r\n" +
|
||||
"\r\n";
|
||||
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
"Transfer-Encoding: gzip, chunked\r\n" +
|
||||
"\r\n" +
|
||||
"2\r\nhi\r\n0\r\n\r\n";
|
||||
Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(5_000_000_000L, r.body().contentLength());
|
||||
assertEquals(-1L, r.body().contentLength());
|
||||
assertArrayEquals("hi".getBytes(StandardCharsets.UTF_8), r.body().bytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_parsedAsLong() throws IOException {
|
||||
// ~3 GB — comfortably above Integer.MAX_VALUE (proving the value is a genuine long, not
|
||||
// silently truncated) while staying within Http1Limits.MAX_CONTENT_LENGTH (4 GiB).
|
||||
String raw = "POST / HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Content-Length: 3000000000\r\n" +
|
||||
"\r\n";
|
||||
Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(3_000_000_000L, r.body().contentLength());
|
||||
assertThrows(IllegalStateException.class, r.body()::bytes);
|
||||
}
|
||||
|
||||
@@ -156,7 +189,7 @@ class RequestParserTest {
|
||||
// Content-Length claims 50 but stream ends after 5 bytes
|
||||
String body = "hello";
|
||||
String raw = "POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\n" + body;
|
||||
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(50, r.body().bytes().length);
|
||||
assertEquals(body, new String(r.body().bytes(), 0, body.length(), StandardCharsets.UTF_8));
|
||||
|
||||
@@ -40,4 +40,26 @@ class HttpStatusTest {
|
||||
assertNull(HttpStatus.reasonForCode(999));
|
||||
assertNull(HttpStatus.reasonForCode(0));
|
||||
}
|
||||
|
||||
// --- EX-17: bound computed from values(), not a hand-maintained constant -----
|
||||
|
||||
@Test
|
||||
void statusesAboveThePreviousHandMaintainedBound_workCorrectly() {
|
||||
// The bound used to be hardcoded at 504; any of these (all >504, all needed by h1
|
||||
// hardening or h2) used to throw ArrayIndexOutOfBoundsException from the static
|
||||
// initializer at class-load time.
|
||||
assertArrayEquals("421 Misdirected Request".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(421));
|
||||
assertArrayEquals("431 Request Header Fields Too Large".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(431));
|
||||
assertArrayEquals("505 HTTP Version Not Supported".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(505));
|
||||
assertArrayEquals("507 Insufficient Storage".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(507));
|
||||
assertArrayEquals("511 Network Authentication Required".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(511));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyEnumConstant_hasAWorkingBytesForCodeEntry() {
|
||||
for (HttpStatus s : HttpStatus.values()) {
|
||||
assertNotNull(HttpStatus.bytesForCode(s.code()), s.name());
|
||||
assertNotNull(HttpStatus.reasonForCode(s.code()), s.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,4 +120,73 @@ class TlsConfigTest {
|
||||
assertFalse(socket.getNeedClientAuth());
|
||||
}
|
||||
}
|
||||
|
||||
// --- EX-31: cipher suite filtering when h2 is offered -----------------------
|
||||
|
||||
@Test
|
||||
void negotiatesH2_trueOnlyWhenH2IsInTheOfferedList() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1").negotiatesH2());
|
||||
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2").negotiatesH2());
|
||||
assertFalse(TlsConfig.ofContext(ctx).applicationProtocols("http/1.1").negotiatesH2());
|
||||
assertFalse(TlsConfig.ofContext(ctx).negotiatesH2()); // no applicationProtocols call at all
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyTo_withH2Offered_removesBlockedTls12CipherSuites() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1");
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
tls.applyTo(socket);
|
||||
List<String> enabled = Arrays.asList(socket.getEnabledCipherSuites());
|
||||
// Spot-check a handful of RFC 9113 Appendix A entries across different families
|
||||
// (RSA key exchange, 3DES, plain ECDHE-CBC) rather than the full ~280-entry list —
|
||||
// TLS12_H2_BLOCKED_CIPHERS itself is the source of truth for the complete set.
|
||||
assertFalse(enabled.contains("TLS_RSA_WITH_AES_128_CBC_SHA"));
|
||||
assertFalse(enabled.contains("TLS_RSA_WITH_3DES_EDE_CBC_SHA"));
|
||||
assertFalse(enabled.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"));
|
||||
assertFalse(enabled.contains("TLS_NULL_WITH_NULL_NULL"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyTo_withH2Offered_keepsTheRequiredCipherSuiteWhenTheJdkEnabledIt() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2");
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
boolean jdkEnabledItByDefault =
|
||||
Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE);
|
||||
tls.applyTo(socket);
|
||||
if (jdkEnabledItByDefault) {
|
||||
assertTrue(Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE),
|
||||
"RFC 9113 §9.2.2 requires supporting this suite — filtering must never remove it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyTo_withoutH2Offered_leavesCipherSuitesUntouched() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("http/1.1");
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
|
||||
tls.applyTo(socket);
|
||||
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyTo_noApplicationProtocolsAtAll_leavesCipherSuitesUntouched() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx);
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
|
||||
tls.applyTo(socket);
|
||||
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package dev.relism.flash.transport;
|
||||
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.net.ssl.SSLServerSocket;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link ProtocolNegotiator#negotiate} is a pure, directly-testable detector (see its Javadoc
|
||||
* for why it does not itself consult {@code FlashConfiguration.http2Enabled}) — every case here
|
||||
* calls it directly rather than through {@code HttpServer}.
|
||||
*/
|
||||
class ProtocolNegotiatorTest {
|
||||
|
||||
// ── Plaintext (h2c prior knowledge) — no real networking needed ────────────
|
||||
|
||||
private static BufferedByteSource plaintextSource(String bytes) {
|
||||
return new BufferedByteSource(
|
||||
new ByteArrayInputStream(bytes.getBytes(StandardCharsets.US_ASCII)), new Socket());
|
||||
}
|
||||
|
||||
@Test
|
||||
void h2cPrefaceExact_negotiatesH2() throws IOException {
|
||||
BufferedByteSource src = plaintextSource("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n");
|
||||
assertEquals(NegotiatedProtocol.H2, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
}
|
||||
|
||||
@Test
|
||||
void h2cPrefaceFollowedByMoreData_stillNegotiatesH2_andDoesNotConsume() throws IOException {
|
||||
BufferedByteSource src = plaintextSource("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\nEXTRA");
|
||||
assertEquals(NegotiatedProtocol.H2, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
// peek() must not have consumed anything — the full 24-byte preface is still there for
|
||||
// whatever reads next (Http2Connection, once it exists).
|
||||
byte[] readBack = new byte[24];
|
||||
assertEquals(24, src.read(readBack, 0, 24));
|
||||
assertEquals("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n",
|
||||
new String(readBack, StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialPreface_thenEof_negotiatesHttp1_notConsumed() throws IOException {
|
||||
// Fewer than 24 bytes total, then EOF — not a match, and RequestParser must still see
|
||||
// every byte that was actually sent.
|
||||
BufferedByteSource src = plaintextSource("PRI * HTTP/2.0\r\n");
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
byte[] readBack = new byte[16];
|
||||
assertEquals(16, src.read(readBack, 0, 16));
|
||||
assertEquals("PRI * HTTP/2.0\r\n", new String(readBack, StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefaceLookalike_divergesPartway_negotiatesHttp1() throws IOException {
|
||||
// "PRI " matches, then diverges — must not be misdetected as h2c.
|
||||
BufferedByteSource src = plaintextSource("PRI * HTTP/9.9\r\n\r\nXX\r\n\r\n");
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainGetRequest_negotiatesHttp1() throws IOException {
|
||||
BufferedByteSource src = plaintextSource("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1, ProtocolNegotiator.negotiate(new Socket(), src));
|
||||
byte[] readBack = new byte[15];
|
||||
assertEquals(15, src.read(readBack, 0, 15));
|
||||
assertEquals("GET / HTTP/1.1\r", new String(readBack, StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
// ── TLS/ALPN — a real loopback handshake, since ALPN is resolved during it ─
|
||||
|
||||
private interface ThrowingConsumer<T> { void accept(T t) throws Exception; }
|
||||
|
||||
/**
|
||||
* Binds a real TLS listener offering {@code serverAlpn}, connects a client offering
|
||||
* {@code clientAlpn}, forces the handshake on both sides (mirroring {@code HttpServer}'s
|
||||
* EX-30 fix), and hands the accepted server-side socket to {@code assertion}.
|
||||
*/
|
||||
private static void withNegotiatedAlpn(Path dir, String[] serverAlpn, String[] clientAlpn,
|
||||
ThrowingConsumer<SSLSocket> assertion) throws Exception {
|
||||
Path ks = TestKeystores.build(dir, "negotiator.p12", "changeit",
|
||||
TestKeystores.Entry.of("only", "negotiator.test"));
|
||||
TlsConfig serverTls = TlsConfig.keystore(ks, "changeit");
|
||||
if (serverAlpn != null) serverTls = serverTls.applicationProtocols(serverAlpn);
|
||||
|
||||
try (SSLServerSocket serverSocket = (SSLServerSocket) serverTls.serverSocketFactory().createServerSocket()) {
|
||||
serverTls.applyTo(serverSocket);
|
||||
serverSocket.bind(new InetSocketAddress("127.0.0.1", 0));
|
||||
int port = serverSocket.getLocalPort();
|
||||
|
||||
CompletableFuture<SSLSocket> accepted = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
SSLSocket s = (SSLSocket) serverSocket.accept();
|
||||
s.startHandshake();
|
||||
return s;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
SSLSocketFactory clientFactory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||
try (SSLSocket client = (SSLSocket) clientFactory.createSocket("127.0.0.1", port)) {
|
||||
if (clientAlpn != null) {
|
||||
javax.net.ssl.SSLParameters params = client.getSSLParameters();
|
||||
params.setApplicationProtocols(clientAlpn);
|
||||
client.setSSLParameters(params);
|
||||
}
|
||||
client.startHandshake();
|
||||
|
||||
try (SSLSocket server = accepted.get()) {
|
||||
assertion.accept(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void alpnH2_negotiatesH2(@TempDir Path dir) throws Exception {
|
||||
withNegotiatedAlpn(dir, new String[]{"h2", "http/1.1"}, new String[]{"h2", "http/1.1"}, server -> {
|
||||
assertEquals("h2", server.getApplicationProtocol());
|
||||
assertEquals(NegotiatedProtocol.H2,
|
||||
ProtocolNegotiator.negotiate(server, new BufferedByteSource(server.getInputStream(), server)));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void alpnHttp11_negotiatesHttp1(@TempDir Path dir) throws Exception {
|
||||
withNegotiatedAlpn(dir, new String[]{"h2", "http/1.1"}, new String[]{"http/1.1"}, server -> {
|
||||
assertEquals("http/1.1", server.getApplicationProtocol());
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1,
|
||||
ProtocolNegotiator.negotiate(server, new BufferedByteSource(server.getInputStream(), server)));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void alpnAbsent_negotiatesHttp1(@TempDir Path dir) throws Exception {
|
||||
// Neither side offers ALPN at all — the common case today.
|
||||
withNegotiatedAlpn(dir, null, null, server -> {
|
||||
assertEquals(NegotiatedProtocol.HTTP_1_1,
|
||||
ProtocolNegotiator.negotiate(server, new BufferedByteSource(server.getInputStream(), server)));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user