Browse Source

Fix compressed HEAD requests handling in JDK client

Prior to this commit, the `JdkClientHttpRequestFactory` would support
decompressing gziped/deflate encoded response bodies but would fail if
the response has no body but has a "Content-Encoding" response header.
This happens as a response to HEAD requests.

This commit ensures that only responses with actual message bodies are
decompressed.

Fixes gh-35966
pull/35990/head
Brian Clozel 1 week ago
parent
commit
12c3dc0cbe
  1. 55
      spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java
  2. 9
      spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java
  3. 19
      spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java

55
spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java

@ -19,6 +19,7 @@ package org.springframework.http.client;
import java.io.FilterInputStream; import java.io.FilterInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.PushbackInputStream;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@ -60,6 +61,7 @@ import org.springframework.util.StringUtils;
* *
* @author Marten Deinum * @author Marten Deinum
* @author Arjen Poutsma * @author Arjen Poutsma
* @author Brian Clozel
* @since 6.1 * @since 6.1
*/ */
class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest {
@ -325,30 +327,61 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest {
*/ */
private static final class DecompressingBodyHandler implements BodyHandler<InputStream> { private static final class DecompressingBodyHandler implements BodyHandler<InputStream> {
@Override @Override
public BodySubscriber<InputStream> apply(ResponseInfo responseInfo) { public BodySubscriber<InputStream> apply(ResponseInfo responseInfo) {
String contentEncoding = responseInfo.headers().firstValue(HttpHeaders.CONTENT_ENCODING).orElse("");
if (contentEncoding.equalsIgnoreCase("gzip")) { String contentEncoding = responseInfo.headers()
return BodySubscribers.mapping( .firstValue(HttpHeaders.CONTENT_ENCODING)
.orElse("")
.toLowerCase(Locale.ROOT);
return switch (contentEncoding) {
case "gzip", "deflate" -> BodySubscribers.mapping(
BodySubscribers.ofInputStream(), BodySubscribers.ofInputStream(),
(InputStream is) -> { (InputStream is) -> decompressStream(is, contentEncoding));
default -> BodySubscribers.ofInputStream();
};
}
private static InputStream decompressStream(InputStream original, String contentEncoding) {
PushbackInputStream wrapped = new PushbackInputStream(original);
try { try {
return new GZIPInputStream(is); if (hasResponseBody(wrapped)) {
if (contentEncoding.equals("gzip")) {
return new GZIPInputStream(wrapped);
}
else if (contentEncoding.equals("deflate")) {
return new InflaterInputStream(wrapped);
}
}
else {
return wrapped;
}
} }
catch (IOException ex) { catch (IOException ex) {
throw new UncheckedIOException(ex); throw new UncheckedIOException(ex);
} }
}); return wrapped;
} }
else if (contentEncoding.equalsIgnoreCase("deflate")) {
return BodySubscribers.mapping( private static boolean hasResponseBody(PushbackInputStream inputStream) {
BodySubscribers.ofInputStream(), try {
InflaterInputStream::new); int b = inputStream.read();
if (b == -1) {
return false;
} }
else { else {
return BodySubscribers.ofInputStream(); inputStream.unread(b);
return true;
} }
}
catch (IOException exc) {
return false;
} }
} }
}
} }

9
spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java

@ -112,7 +112,7 @@ public abstract class AbstractMockWebServerTests {
String headerName = request.getTarget().replace("/header/",""); String headerName = request.getTarget().replace("/header/","");
return new MockResponse.Builder().body(headerName + ":" + request.getHeaders().get(headerName)).code(200).build(); return new MockResponse.Builder().body(headerName + ":" + request.getHeaders().get(headerName)).code(200).build();
} }
else if(request.getTarget().startsWith("/compress/") && request.getBody() != null) { else if(request.getMethod().equals("POST") && request.getTarget().startsWith("/compress/") && request.getBody() != null) {
String encoding = request.getTarget().replace("/compress/",""); String encoding = request.getTarget().replace("/compress/","");
String requestBody = request.getBody().utf8(); String requestBody = request.getBody().utf8();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
@ -139,6 +139,13 @@ public abstract class AbstractMockWebServerTests {
builder.setHeader(HttpHeaders.CONTENT_LENGTH, buffer.size()); builder.setHeader(HttpHeaders.CONTENT_LENGTH, buffer.size());
return builder.build(); return builder.build();
} }
else if (request.getMethod().equals("HEAD") && request.getTarget().startsWith("/headforcompress/")) {
String encoding = request.getTarget().replace("/headforcompress/","");
MockResponse.Builder builder = new MockResponse.Builder().code(200)
.setHeader(HttpHeaders.CONTENT_LENGTH, 500)
.setHeader(HttpHeaders.CONTENT_ENCODING, encoding);
return builder.build();
}
return new MockResponse.Builder().code(404).build(); return new MockResponse.Builder().code(404).build();
} }
catch (Throwable ex) { catch (Throwable ex) {

19
spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java

@ -26,6 +26,8 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledForJreRange; import org.junit.jupiter.api.condition.EnabledForJreRange;
import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.api.condition.JRE;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -159,6 +161,23 @@ class JdkClientHttpRequestFactoryTests extends AbstractHttpRequestFactoryTests {
} }
} }
@ParameterizedTest
@ValueSource(strings = {"gzip", "deflate"})
void gzipCompressionWithHeadRequest(String compression) throws IOException {
URI uri = URI.create(baseUrl + "/headforcompress/" + compression);
JdkClientHttpRequestFactory requestFactory = (JdkClientHttpRequestFactory) this.factory;
requestFactory.enableCompression(true);
ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.HEAD);
try (ClientHttpResponse response = request.execute()) {
assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getFirst("Content-Encoding"))
.as("Content Encoding should be removed").isNull();
assertThat(response.getHeaders().getFirst("Content-Length"))
.as("Content-Length should be removed").isNull();
assertThat(response.getBody()).as("Invalid response body").isEmpty();
}
}
@Test // gh-34971 @Test // gh-34971
@EnabledForJreRange(min = JRE.JAVA_19) // behavior fixed in Java 19 @EnabledForJreRange(min = JRE.JAVA_19) // behavior fixed in Java 19
void requestContentLengthHeaderWhenNoBody() throws Exception { void requestContentLengthHeaderWhenNoBody() throws Exception {

Loading…
Cancel
Save