You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ol...@apache.org on 2026/09/18 14:47:13 UTC
(httpcomponents-client) branch master updated: HTTPCLIENT-2433: Prevent concurrent HTTP/2 request body replay
This is an automated email from the ASF dual-hosted git repository.
ok2c pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git
The following commit(s) were added to refs/heads/master by this push:
new 3849cf963 HTTPCLIENT-2433: Prevent concurrent HTTP/2 request body replay
3849cf963 is described below
commit 3849cf963f02dcff6627269782f674e88cd9053f
Author: Matthias Kurz <m....@irregular.at>
AuthorDate: Thu Sep 17 13:54:10 2026 +0200
HTTPCLIENT-2433: Prevent concurrent HTTP/2 request body replay
Wait for both sides of an HTTP/2 exchange to terminate before completing it, including graceful request termination after RST_STREAM(NO_ERROR).
Cover authentication replay with a large file and with a body-less 401 followed by a graceful stream reset.
---
.../testing/async/TestH2ClientAuthentication.java | 181 ++++++++++++++++++++-
.../http/impl/async/H2AsyncMainClientExec.java | 52 +++++-
2 files changed, 227 insertions(+), 6 deletions(-)
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestH2ClientAuthentication.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestH2ClientAuthentication.java
index 0c4c9c06b..35ab81dbb 100644
--- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestH2ClientAuthentication.java
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestH2ClientAuthentication.java
@@ -26,9 +26,48 @@
*/
package org.apache.hc.client5.testing.async;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.client5.testing.BasicTestAuthenticator;
+import org.apache.hc.client5.testing.auth.BasicAuthenticationHandler;
import org.apache.hc.client5.testing.extension.async.ClientProtocolLevel;
import org.apache.hc.client5.testing.extension.async.ServerProtocolLevel;
+import org.apache.hc.client5.testing.extension.async.TestAsyncClient;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Message;
+import org.apache.hc.core5.http.Method;
import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+import org.apache.hc.core5.http.nio.ResponseChannel;
+import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityConsumer;
+import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityProducer;
+import org.apache.hc.core5.http.nio.entity.FileEntityProducer;
+import org.apache.hc.core5.http.nio.support.BasicRequestProducer;
+import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http2.H2Error;
+import org.apache.hc.core5.http2.H2StreamResetException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
abstract class TestH2ClientAuthentication extends AbstractHttpAsyncClientAuthenticationTest {
@@ -36,4 +75,144 @@ public TestH2ClientAuthentication(final URIScheme scheme) {
super(scheme, ClientProtocolLevel.H2_ONLY, ServerProtocolLevel.H2_ONLY);
}
-}
\ No newline at end of file
+ @Test
+ void testBasicAuthenticationReplayWithLargeFile(@TempDir final Path tempDir) throws Exception {
+ final byte[] expected = new byte[2_000_000];
+ for (int i = 0; i < expected.length; i++) {
+ expected[i] = (byte) (i * 31 + 7);
+ }
+ final Path source = tempDir.resolve("request.bin");
+ Files.write(source, expected);
+
+ configureServerWithBasicAuth(bootstrap -> bootstrap.register("*", AsyncEchoHandler::new));
+ final HttpHost target = startServer();
+
+ final TestAsyncClient client = startClient();
+ final HttpClientContext context = HttpClientContext.create();
+ context.setCredentialsProvider(CredentialsProviderBuilder.create()
+ .add(target, "test", "test".toCharArray())
+ .build());
+
+ final Future<Message<HttpResponse, byte[]>> future = client.execute(
+ new BasicRequestProducer(Method.PUT, target, "/", new FileEntityProducer(source.toFile())),
+ new BasicResponseConsumer<>(new BasicAsyncEntityConsumer()), context, null);
+ final Message<HttpResponse, byte[]> response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit());
+
+ Assertions.assertEquals(HttpStatus.SC_OK, response.getHead().getCode());
+ Assertions.assertArrayEquals(expected, response.getBody());
+ }
+
+ @Test
+ void testBasicAuthenticationReplayAfterNoErrorReset() throws Exception {
+ final byte[] expected = new byte[2_000_000];
+ for (int i = 0; i < expected.length; i++) {
+ expected[i] = (byte) (i * 31 + 7);
+ }
+
+ final AtomicBoolean resetSent = new AtomicBoolean();
+ configureServer(bootstrap -> bootstrap.register("*",
+ () -> new ResettingBasicAuthHandler(new AsyncEchoHandler(), resetSent)));
+ final HttpHost target = startServer();
+
+ final TestAsyncClient client = startClient();
+ final HttpClientContext context = HttpClientContext.create();
+ context.setCredentialsProvider(CredentialsProviderBuilder.create()
+ .add(target, "test", "test".toCharArray())
+ .build());
+
+ final Future<Message<HttpResponse, byte[]>> future = client.execute(
+ new BasicRequestProducer(Method.PUT, target, "/", new BasicAsyncEntityProducer(expected)),
+ new BasicResponseConsumer<>(new BasicAsyncEntityConsumer()), context, null);
+ final Message<HttpResponse, byte[]> response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit());
+
+ Assertions.assertTrue(resetSent.get());
+ Assertions.assertEquals(HttpStatus.SC_OK, response.getHead().getCode());
+ Assertions.assertArrayEquals(expected, response.getBody());
+ }
+
+ private static final class ResettingBasicAuthHandler implements AsyncServerExchangeHandler {
+
+ private final AsyncServerExchangeHandler exchangeHandler;
+ private final AtomicBoolean resetSent;
+ private final BasicAuthenticationHandler authenticationHandler;
+ private final BasicTestAuthenticator authenticator;
+ private boolean authenticated;
+
+ ResettingBasicAuthHandler(final AsyncServerExchangeHandler exchangeHandler, final AtomicBoolean resetSent) {
+ this.exchangeHandler = exchangeHandler;
+ this.resetSent = resetSent;
+ this.authenticationHandler = new BasicAuthenticationHandler();
+ this.authenticator = new BasicTestAuthenticator("test:test", "test realm");
+ }
+
+ @Override
+ public void handleRequest(
+ final HttpRequest request,
+ final EntityDetails entityDetails,
+ final ResponseChannel responseChannel,
+ final HttpContext context) throws HttpException, IOException {
+ final Header authorization = request.getFirstHeader(HttpHeaders.AUTHORIZATION);
+ final String credentials = authorization != null
+ ? authenticationHandler.extractAuthToken(authorization.getValue())
+ : null;
+ authenticated = authenticator.authenticate(request.getAuthority(), request.getRequestUri(), credentials);
+ if (authenticated) {
+ exchangeHandler.handleRequest(request, entityDetails, responseChannel, context);
+ } else {
+ final HttpResponse unauthorized = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED);
+ unauthorized.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"test realm\"");
+ responseChannel.sendResponse(unauthorized, null, context);
+ }
+ }
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ if (authenticated) {
+ exchangeHandler.updateCapacity(capacityChannel);
+ } else {
+ capacityChannel.update(Integer.MAX_VALUE);
+ }
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ if (authenticated) {
+ exchangeHandler.consume(src);
+ } else {
+ resetSent.set(true);
+ throw new H2StreamResetException(H2Error.NO_ERROR, "Stop upload after authentication challenge");
+ }
+ }
+
+ @Override
+ public void streamEnd(final List<? extends Header> trailers) throws HttpException, IOException {
+ if (authenticated) {
+ exchangeHandler.streamEnd(trailers);
+ }
+ }
+
+ @Override
+ public int available() {
+ return authenticated ? exchangeHandler.available() : 0;
+ }
+
+ @Override
+ public void produce(final DataStreamChannel channel) throws IOException {
+ if (authenticated) {
+ exchangeHandler.produce(channel);
+ }
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ exchangeHandler.failed(cause);
+ }
+
+ @Override
+ public void releaseResources() {
+ exchangeHandler.releaseResources();
+ }
+
+ }
+
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncMainClientExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncMainClientExec.java
index 95a3ceace..f08df2e32 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncMainClientExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/H2AsyncMainClientExec.java
@@ -30,6 +30,7 @@
import java.io.InterruptedIOException;
import java.nio.ByteBuffer;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hc.client5.http.HttpRoute;
@@ -95,16 +96,26 @@ public void execute(
LOG.debug("{} executing {} {}", exchangeId, request.getMethod(), request.getRequestUri());
}
+ final AtomicInteger messageCountDown = new AtomicInteger(2);
final AsyncClientExchangeHandler internalExchangeHandler = new AsyncClientExchangeHandler() {
private final AtomicReference<AsyncDataConsumer> entityConsumerRef = new AtomicReference<>();
+ private void messageCompleted() {
+ if (messageCountDown.decrementAndGet() == 0) {
+ asyncExecCallback.completed();
+ }
+ }
+
@Override
public void releaseResources() {
final AsyncDataConsumer entityConsumer = entityConsumerRef.getAndSet(null);
if (entityConsumer != null) {
entityConsumer.releaseResources();
}
+ if (messageCountDown.get() > 0) {
+ messageCompleted();
+ }
}
@Override
@@ -114,12 +125,16 @@ public void failed(final Exception cause) {
entityConsumer.releaseResources();
}
execRuntime.markConnectionNonReusable();
- asyncExecCallback.failed(cause);
+ if (messageCountDown.getAndSet(0) > 0) {
+ asyncExecCallback.failed(cause);
+ }
}
@Override
public void cancel() {
- failed(new InterruptedIOException());
+ if (messageCountDown.get() > 0) {
+ failed(new InterruptedIOException());
+ }
}
@Override
@@ -129,6 +144,9 @@ public void produceRequest(final RequestChannel channel, final HttpContext conte
httpProcessor.process(request, entityProducer, clientContext);
channel.sendRequest(request, entityProducer, context);
+ if (entityProducer == null) {
+ messageCompleted();
+ }
}
@Override
@@ -138,7 +156,31 @@ public int available() {
@Override
public void produce(final DataStreamChannel channel) throws IOException {
- entityProducer.produce(channel);
+ entityProducer.produce(new DataStreamChannel() {
+
+ @Override
+ public void requestOutput() {
+ channel.requestOutput();
+ }
+
+ @Override
+ public int write(final ByteBuffer src) throws IOException {
+ return channel.write(src);
+ }
+
+ @Override
+ public void endStream(final List<? extends Header> trailers) throws IOException {
+ channel.endStream(trailers);
+ messageCompleted();
+ }
+
+ @Override
+ public void endStream() throws IOException {
+ channel.endStream();
+ messageCompleted();
+ }
+
+ });
}
@Override
@@ -157,7 +199,7 @@ public void consumeResponse(
entityConsumerRef.set(asyncExecCallback.handleResponse(response, entityDetails));
if (entityDetails == null) {
execRuntime.validateConnection();
- asyncExecCallback.completed();
+ messageCompleted();
}
}
@@ -187,7 +229,7 @@ public void streamEnd(final List<? extends Header> trailers) throws HttpExceptio
} else {
execRuntime.validateConnection();
}
- asyncExecCallback.completed();
+ messageCompleted();
}
};