feat(core): add HTTP/2 cleartext proxy support

This commit is contained in:
Zakaria El Orche
2026-08-13 20:00:59 +00:00
parent 5755ef77fe
commit 3c1eb0d0df
27 changed files with 1593 additions and 109 deletions
@@ -0,0 +1,55 @@
package dev.relism.flash.http;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http.HopByHopHeaders.Protocol;
import dev.relism.flash.models.MutableHeaderMap;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class HopByHopHeaderTest {
@Test
void sharedPolicyCoversAllFourProtocolConversions() {
for (Protocol sourceProtocol : Protocol.values()) {
for (Protocol targetProtocol : Protocol.values()) {
MutableHeaderMap source = new MutableHeaderMap();
add(source, "connection", "x-private, keep-alive");
add(source, "x-private", "secret");
add(source, "upgrade", "websocket");
add(source, "te", "trailers");
add(source, "x-end-to-end", "yes");
assertFalse(forward(source, "connection", "x-private", sourceProtocol, targetProtocol));
assertFalse(forward(source, "x-private", "secret", sourceProtocol, targetProtocol));
assertFalse(forward(source, "upgrade", "websocket", sourceProtocol, targetProtocol));
assertTrue(forward(source, "x-end-to-end", "yes", sourceProtocol, targetProtocol));
assertTrue(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_2));
assertFalse(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_1_1));
}
}
}
private static boolean forward(
MutableHeaderMap source,
String name,
String value,
Protocol sourceProtocol,
Protocol targetProtocol) {
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
PooledSlice nameView = new PooledSlice();
PooledSlice valueView = new PooledSlice();
nameView.reset(nameBytes, 0, nameBytes.length);
valueView.reset(valueBytes, 0, valueBytes.length);
return HopByHopHeaders.shouldForward(
source, nameView, valueView, sourceProtocol, targetProtocol);
}
private static void add(MutableHeaderMap headers, String name, String value) {
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
}
}
@@ -29,8 +29,13 @@ class GrpcInteropTest {
@Test
void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.host("127.0.0.1").port(port).http2Enabled(true).build());
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.post("/flash.test.Echo/Unary", (request, response) ->
response.type("application/grpc")
.body(request.body().bytes())
@@ -0,0 +1,71 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http2.client.Http2Client;
import dev.relism.flash.http2.client.Http2ClientResponse;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
class H2cPriorKnowledgeTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void priorKnowledgeRequiresItsIndependentOptIn() throws Exception {
int disabledPort = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(disabledPort)
.http2Enabled(true)
.build());
app.get("/", (request, response) -> "wrong protocol");
app.start();
try (Socket socket = new Socket("127.0.0.1", disabledPort)) {
socket.setSoTimeout(2_000);
socket.getOutputStream().write(Http2Preface.clientPreface());
byte[] prefix = socket.getInputStream().readNBytes(5);
assertArrayEquals("HTTP/".getBytes(StandardCharsets.US_ASCII), prefix);
}
app.stop().join();
int enabledPort = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(enabledPort)
.http2CleartextEnabled(true)
.build());
app.get("/", (request, response) -> "h2c");
app.start();
try (Http2Client client = new Http2Client()) {
Http2ClientResponse response =
client.get(URI.create("http://127.0.0.1:" + enabledPort + "/"));
assertEquals(200, response.statusCode());
assertEquals("h2c", new String(response.body(), StandardCharsets.UTF_8));
}
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -184,8 +184,14 @@ class Http2AbuseTest {
@Test
void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception {
int port = freePort();
FlashApp app = FlashApp.create(FlashConfiguration.builder()
.host("127.0.0.1").port(port).http2Enabled(true).h2StreamIdleTimeoutMs(20).build());
FlashApp app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.h2StreamIdleTimeoutMs(20)
.build());
app.post("/idle", (request, response) -> request.body().bytes());
app.start();
ByteWriter headers = new ByteWriter(64);
@@ -0,0 +1,18 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class Http2AuthorityTest {
@Test
void matchesExactIpPortAndSingleLabelWildcardAuthorities() {
assertTrue(Http2Authority.matches("api.example.com:443", "api.example.com"));
assertTrue(Http2Authority.matches("127.0.0.1:8443", "127.0.0.1"));
assertTrue(Http2Authority.matches("one.example.com", "*.example.com"));
assertFalse(Http2Authority.matches("example.com", "*.example.com"));
assertFalse(Http2Authority.matches("two.one.example.com", "*.example.com"));
assertFalse(Http2Authority.matches("other.example.net", "*.example.com"));
}
}
@@ -29,8 +29,13 @@ class Http2ConnectTest {
@Test
void connectTunnelCanExchangeDataBeforeEitherSideCloses() throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.host("127.0.0.1").port(port).http2Enabled(true).build());
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.connect("tunnel", (request, response) ->
response.type(ContentType.NONE).streaming(output -> {
byte[] bytes = new byte[16];
@@ -253,7 +253,11 @@ class Http2ConnectionIntegrationTest {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.http2CleartextEnabled(true)
.build());
app.get("/api/ping", (request, response) -> "pong");
app.start();
@@ -314,7 +318,11 @@ class Http2ConnectionIntegrationTest {
AtomicInteger calls = new AtomicInteger();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.http2CleartextEnabled(true)
.build());
app.get(
"/queued",
(request, response) -> {
@@ -380,7 +388,11 @@ class Http2ConnectionIntegrationTest {
AtomicBoolean handlerEntered = new AtomicBoolean();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.http2CleartextEnabled(true)
.build());
app.get(
"/",
(request, response) -> {
@@ -436,7 +448,7 @@ class Http2ConnectionIntegrationTest {
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.http2Enabled(true)
.http2CleartextEnabled(true)
.shutdownDrainTimeoutMs(5_000)
.build());
app.start();
@@ -476,7 +488,11 @@ class Http2ConnectionIntegrationTest {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.http2CleartextEnabled(true)
.build());
app.start();
try (Socket first = new Socket("127.0.0.1", port)) {
@@ -0,0 +1,122 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackEncoder;
import dev.relism.flash.tls.TestKeystores;
import dev.relism.flash.tls.TlsConfig;
import java.io.InputStream;
import java.net.ServerSocket;
import java.nio.file.Path;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class Http2MisdirectedRequestTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void authorityOutsideSelectedCertificateReceives421(@TempDir Path directory) throws Exception {
int port = freePort();
Path keystore =
TestKeystores.build(
directory,
"misdirected.p12",
"changeit",
TestKeystores.Entry.of("server", "localhost", "localhost"));
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.tls(TlsConfig.keystore(keystore, "changeit"))
.http2Enabled(true)
.build());
app.get("/", (request, response) -> "must not run");
app.start();
try (SSLSocket socket =
(SSLSocket)
TestKeystores.trustAllClientContext()
.getSocketFactory()
.createSocket("localhost", port)) {
SSLParameters parameters = socket.getSSLParameters();
parameters.setApplicationProtocols(new String[] {"h2"});
socket.setSSLParameters(parameters);
socket.startHandshake();
socket.getOutputStream().write(request("other.example"));
assertEquals(421, readStatus(socket.getInputStream()));
}
}
private static byte[] request(String authority) {
ByteWriter bytes = new ByteWriter(128);
bytes.writeBytes(Http2Preface.clientPreface());
FrameWriteBuffer frames = new FrameWriteBuffer(bytes);
frames.beginFrame(FrameType.SETTINGS, 0, 0);
frames.endFrame();
frames.beginFrame(
FrameType.HEADERS, FrameFlags.END_HEADERS | FrameFlags.END_STREAM, 1);
HpackEncoder.writeIndexed(bytes, 2);
HpackEncoder.writeIndexed(bytes, 7);
HpackEncoder.writeLiteralWithNameIndex(
bytes, 1, authority.getBytes(java.nio.charset.StandardCharsets.US_ASCII), false);
HpackEncoder.writeIndexed(bytes, 4);
frames.endFrame();
byte[] result = new byte[bytes.length()];
System.arraycopy(bytes.array(), 0, result, 0, result.length);
return result;
}
private static int readStatus(InputStream input) throws Exception {
HpackDecoder decoder = new HpackDecoder();
byte[] header = new byte[9];
while (true) {
input.readNBytes(header, 0, header.length);
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
int type = header[3] & 0xff;
int streamId =
((header[5] & 0x7f) << 24)
| ((header[6] & 0xff) << 16)
| ((header[7] & 0xff) << 8)
| (header[8] & 0xff);
byte[] payload = input.readNBytes(length);
if (type != FrameType.HEADERS.code() || streamId != 1) continue;
int[] status = {0};
decoder.decode(
payload,
0,
payload.length,
(name, value, never) -> {
if (name.length() == 7 && name.byteAt(0) == ':') {
status[0] =
(value.byteAt(0) - '0') * 100
+ (value.byteAt(1) - '0') * 10
+ value.byteAt(2)
- '0';
}
});
return status[0];
}
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -28,8 +28,13 @@ class Http2TrailersTest {
@Test
void requestTrailersReachHandlerAfterBodyEof() throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.host("127.0.0.1").port(port).http2Enabled(true).build());
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.post("/trailers", (request, response) -> {
assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII));
return request.trailers().first("grpc-status");
@@ -95,8 +100,13 @@ class Http2TrailersTest {
private int startBlockingRoute() throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.host("127.0.0.1").port(port).http2Enabled(true).build());
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.post("/trailers", (request, response) -> request.body().bytes());
app.start();
return port;
@@ -0,0 +1,131 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.proxy.HttpProxy;
import dev.relism.flash.http2.client.Http2Client;
import dev.relism.flash.http2.client.Http2ClientResponse;
import dev.relism.flash.models.MutableHeaderMap;
import java.io.ByteArrayOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
class ProxyTrailerRelayTest {
private FlashApp upstream;
private FlashApp proxy;
private Http2Client proxyUpstream;
@AfterEach
void stop() {
if (proxyUpstream != null) proxyUpstream.close();
if (proxy != null) proxy.stop().join();
if (upstream != null) upstream.stop().join();
}
@Test
void requestAndResponseTrailersSurviveH2AndH1DownstreamProxyHops() throws Exception {
int upstreamPort = freePort();
upstream =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(upstreamPort)
.http2CleartextEnabled(true)
.build());
upstream.post(
"/relay",
(request, response) ->
response
.header("x-query", request.query("mode"))
.header("x-private-seen", String.valueOf(request.header("x-private") != null))
.body(request.body().bytes())
.trailer("x-relayed-trailer", request.trailers().first("x-request-trailer")));
upstream.start();
int proxyPort = freePort();
proxyUpstream = new Http2Client();
proxy =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(proxyPort)
.http2CleartextEnabled(true)
.build());
proxy.post(
"/relay",
HttpProxy.toHttp2(URI.create("http://127.0.0.1:" + upstreamPort), proxyUpstream));
proxy.start();
MutableHeaderMap h2Headers = fields("connection", "x-private");
add(h2Headers, "x-private", "must-not-cross");
MutableHeaderMap h2Trailers = fields("x-request-trailer", "from-h2");
try (Http2Client downstream = new Http2Client()) {
Http2ClientResponse response =
downstream.exchange(
URI.create("http://127.0.0.1:" + proxyPort + "/relay?mode=h2"),
HttpMethod.POST,
h2Headers,
"hello-h2".getBytes(StandardCharsets.UTF_8),
h2Trailers);
assertEquals("hello-h2", new String(response.body(), StandardCharsets.UTF_8));
assertEquals("h2", response.headers().first("x-query"));
assertEquals("false", response.headers().first("x-private-seen"));
assertEquals("from-h2", response.trailers().first("x-relayed-trailer"));
}
String h1 = h1Exchange(proxyPort);
assertTrue(h1.contains("hello-h1"), h1);
assertTrue(h1.toLowerCase().contains("x-query: h1"), h1);
assertTrue(h1.toLowerCase().contains("x-private-seen: false"), h1);
assertTrue(h1.toLowerCase().contains("x-relayed-trailer: from-h1"), h1);
assertFalse(h1.contains("must-not-cross"), h1);
}
private static String h1Exchange(int port) throws Exception {
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(2_000);
socket
.getOutputStream()
.write(
("POST /relay?mode=h1 HTTP/1.1\r\n"
+ "Host: 127.0.0.1\r\n"
+ "Connection: x-private, close\r\n"
+ "X-Private: must-not-cross\r\n"
+ "Transfer-Encoding: chunked\r\n"
+ "Trailer: x-request-trailer\r\n\r\n"
+ "8\r\nhello-h1\r\n"
+ "0\r\nX-Request-Trailer: from-h1\r\n\r\n")
.getBytes(StandardCharsets.US_ASCII));
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
socket.getInputStream().transferTo(bytes);
return bytes.toString(StandardCharsets.UTF_8);
}
}
private static MutableHeaderMap fields(String name, String value) {
MutableHeaderMap headers = new MutableHeaderMap();
add(headers, name, value);
return headers;
}
private static void add(MutableHeaderMap headers, String name, String value) {
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -0,0 +1,117 @@
package dev.relism.flash.http2.client;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.tls.TestKeystores;
import dev.relism.flash.tls.TlsConfig;
import java.net.ServerSocket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class Http2ClientTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void reusesOriginConnectionAndExchangesFlowControlledBodiesAndTrailers() throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.post(
"/relay",
(request, response) -> {
byte[] body = request.body().bytes();
String checksum = request.trailers().first("x-request-checksum");
return response
.header("x-upstream", request.header("x-forwarded-test"))
.body(body)
.trailer("x-response-checksum", checksum);
});
app.start();
byte[] body = new byte[2 * 1024 * 1024 + 31];
for (int i = 0; i < body.length; i++) body[i] = (byte) (i * 29);
MutableHeaderMap requestHeaders = fields("x-forwarded-test", "yes");
MutableHeaderMap requestTrailers = fields("x-request-checksum", "valid");
try (Http2Client client = new Http2Client()) {
URI uri = URI.create("http://127.0.0.1:" + port + "/relay");
Http2ClientResponse first =
client.exchange(uri, HttpMethod.POST, requestHeaders, body, requestTrailers);
Http2ClientResponse second =
client.exchange(
uri,
HttpMethod.POST,
requestHeaders,
"again".getBytes(StandardCharsets.UTF_8),
requestTrailers);
assertEquals(200, first.statusCode());
assertEquals("yes", first.headers().first("x-upstream"));
assertArrayEquals(body, first.body());
assertEquals("valid", first.trailers().first("x-response-checksum"));
assertArrayEquals("again".getBytes(StandardCharsets.UTF_8), second.body());
assertEquals(1, client.pooledConnectionCount());
}
}
@Test
void negotiatesTlsAlpnAndVerifiesTheUpstreamHostname(@TempDir Path directory) throws Exception {
int port = freePort();
Path keystore =
TestKeystores.build(
directory,
"http2-client.p12",
"changeit",
TestKeystores.Entry.of("server", "localhost", "localhost"));
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.tls(TlsConfig.keystore(keystore, "changeit"))
.http2Enabled(true)
.build());
app.get("/secure", (request, response) -> "tls-h2");
app.start();
try (Http2Client client = new Http2Client(TestKeystores.trustAllClientContext())) {
Http2ClientResponse response =
client.get(URI.create("https://localhost:" + port + "/secure"));
assertEquals(200, response.statusCode());
assertEquals("tls-h2", new String(response.body(), StandardCharsets.UTF_8));
}
}
private static MutableHeaderMap fields(String name, String value) {
MutableHeaderMap headers = new MutableHeaderMap();
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
return headers;
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -21,7 +21,7 @@ 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
* for why it does not itself consult {@code FlashConfiguration}) — every case here
* calls it directly rather than through {@code Http1Connection}/{@code ConnectionRunner}.
*/
class ProtocolNegotiatorTest {