Skip to content

Commit c572c67

Browse files
committed
Preserve proxy CONNECT tunnel bytes
Attempting to resolve a rare CI flake that has hit us on Ubuntu Latest JDK 25: ProxyTest.canAuthToProxyAndServer:180 » SSL Unsupported or unrecognized SSL message. The Netty proxy test server can receive the CONNECT request and the first TLS ClientHello bytes in the same socket read. During the async CONNECT handoff, removing the HTTP codec before the raw relay was ready could drop or misroute those already-buffered bytes. Split request decoding from response encoding, stop decoding after CONNECT, and install the tunnel relay before removing the decoder so buffered TLS bytes are forwarded through the tunnel.
1 parent 6d8f80d commit c572c67

1 file changed

Lines changed: 67 additions & 11 deletions

File tree

src/test/java/org/jsoup/integration/netty/NettyProxyServer.java

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.netty.bootstrap.Bootstrap;
44
import io.netty.bootstrap.ServerBootstrap;
5+
import io.netty.buffer.ByteBuf;
56
import io.netty.buffer.ByteBufUtil;
67
import io.netty.buffer.Unpooled;
78
import io.netty.channel.*;
@@ -20,6 +21,7 @@
2021
import java.io.ByteArrayOutputStream;
2122
import java.net.URI;
2223
import java.net.URISyntaxException;
24+
import java.util.List;
2325

2426
/**
2527
Hosts the jsoup HTTP proxy listeners on Netty.
@@ -97,7 +99,7 @@ public static int closeAuthedConnections() {
9799
}
98100

99101
/**
100-
Binds one proxy listener with the supplied auth mode
102+
Binds a proxy listener with the supplied auth mode
101103
*/
102104
private static Channel bind(boolean alwaysAuth) throws InterruptedException {
103105
return new ServerBootstrap()
@@ -110,7 +112,9 @@ protected void initChannel(SocketChannel ch) {
110112
if (alwaysAuth) {
111113
AuthedConnections.add(ch);
112114
}
113-
ch.pipeline().addLast(new HttpServerCodec());
115+
// split decode/encode so that CONNECT can pause request decoding while the 200 response is writing:
116+
ch.pipeline().addLast(new ProxyRequestDecoder());
117+
ch.pipeline().addLast(new HttpResponseEncoder());
114118
ch.pipeline().addLast(new ProxyFrontendHandler(alwaysAuth));
115119
}
116120
})
@@ -119,6 +123,27 @@ protected void initChannel(SocketChannel ch) {
119123
.channel();
120124
}
121125

126+
/**
127+
Decodes proxy requests and stops after CONNECT, so tunnel bytes stay buffered for handoff
128+
*/
129+
private static final class ProxyRequestDecoder extends HttpRequestDecoder {
130+
@Override
131+
protected void decode(ChannelHandlerContext ctx, ByteBuf input, List<Object> output) throws Exception {
132+
int firstOutput = output.size();
133+
setSingleDecode(false);
134+
super.decode(ctx, input, output);
135+
136+
for (int i = firstOutput; i < output.size(); i++) {
137+
Object message = output.get(i);
138+
if (message instanceof HttpRequest && HttpMethod.CONNECT.equals(((HttpRequest) message).method())) {
139+
// keep anyy already buffered TLS bytes here until the tunnel relay is in place:
140+
setSingleDecode(true);
141+
return;
142+
}
143+
}
144+
}
145+
}
146+
122147
/**
123148
Handles parsed proxy requests on the client-facing listener
124149
*/
@@ -212,6 +237,7 @@ private void startConnectRequest(ChannelHandlerContext ctx, HttpRequest request)
212237
return;
213238
}
214239

240+
pauseClientReads(ctx);
215241
Bootstrap bootstrap = new Bootstrap()
216242
.group(ioGroup)
217243
.channel(NioSocketChannel.class)
@@ -223,14 +249,29 @@ protected void initChannel(SocketChannel ch) {
223249
}
224250
});
225251

226-
bootstrap.connect(target.host, target.port).addListener((ChannelFutureListener) future -> {
227-
if (!future.isSuccess()) {
228-
sendSimpleResponse(ctx, request, HttpResponseStatus.BAD_GATEWAY, "Bad Gateway");
229-
return;
252+
bootstrap.connect(target.host, target.port)
253+
.addListener((ChannelFutureListener) future ->
254+
ctx.executor().execute(() -> finishConnectRequest(ctx, request, future)));
255+
}
256+
257+
/**
258+
Finishes the asynchronous CONNECT upstream connection on the client channel event loop.
259+
*/
260+
private void finishConnectRequest(ChannelHandlerContext ctx, HttpRequest request, ChannelFuture future) {
261+
if (!ctx.channel().isActive()) {
262+
if (future.isSuccess()) {
263+
future.channel().close();
230264
}
265+
return;
266+
}
231267

232-
establishTunnel(ctx, future.channel());
233-
});
268+
if (!future.isSuccess()) {
269+
resumeClientReads(ctx);
270+
sendSimpleResponse(ctx, request, HttpResponseStatus.BAD_GATEWAY, "Bad Gateway");
271+
return;
272+
}
273+
274+
establishTunnel(ctx, future.channel());
234275
}
235276

236277
/**
@@ -285,14 +326,29 @@ private void establishTunnel(ChannelHandlerContext ctx, Channel upstream) {
285326
}
286327

287328
ChannelPipeline pipeline = ctx.pipeline();
288-
if (pipeline.get(HttpServerCodec.class) != null) {
289-
pipeline.remove(HttpServerCodec.class);
290-
}
329+
// install the relay before removing the decoder. removal forwards buffered tunnel bytes:
291330
pipeline.replace(this, "proxyTunnelRelay", new TunnelRelayHandler(upstream));
331+
pipeline.remove(ProxyRequestDecoder.class);
332+
pipeline.remove(HttpResponseEncoder.class);
333+
resumeClientReads(ctx);
292334
});
293335
}
294336
}
295337

338+
/**
339+
Pauses future client reads while a CONNECT tunnel is being installed
340+
*/
341+
private static void pauseClientReads(ChannelHandlerContext ctx) {
342+
ctx.channel().config().setAutoRead(false);
343+
}
344+
345+
/**
346+
Resumes client reads after a paused CONNECT handoff is complete
347+
*/
348+
private static void resumeClientReads(ChannelHandlerContext ctx) {
349+
ctx.channel().config().setAutoRead(true);
350+
}
351+
296352
/**
297353
Buffers a parsed HTTP proxy request body before forwarding it upstream
298354
*/

0 commit comments

Comments
 (0)