This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new b168e18c7e6f CAMEL-25571: camel-http-common - uncompress a gzip
request body that arrives after the headers (#27755)
b168e18c7e6f is described below
commit b168e18c7e6fdd36c9789e35561c2fab6d1706d1
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 13 12:24:57 2026 +0530
CAMEL-25571: camel-http-common - uncompress a gzip request body that
arrives after the headers (#27755)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/http/common/HttpConverter.java | 21 ++-
.../camel/http/common/HttpConverterGzipTest.java | 201 +++++++++++++++++++++
.../jetty/JettyGzipRequestEmptyBodyTest.java | 102 +++++++++++
.../jetty/JettyGzipRequestExpectContinueTest.java | 125 +++++++++++++
4 files changed, 443 insertions(+), 6 deletions(-)
diff --git
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/HttpConverter.java
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/HttpConverter.java
index ab248ed57c81..c2518e78c70d 100644
---
a/components/camel-http-common/src/main/java/org/apache/camel/http/common/HttpConverter.java
+++
b/components/camel-http-common/src/main/java/org/apache/camel/http/common/HttpConverter.java
@@ -19,6 +19,8 @@ package org.apache.camel.http.common;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
+import java.io.PushbackInputStream;
+import java.util.zip.GZIPInputStream;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
@@ -84,16 +86,23 @@ public final class HttpConverter {
return null;
}
InputStream is = request.getInputStream();
- if (is != null && is.available() <= 0) {
- // there is no data, so we cannot uncompress etc.
+ if (is == null) {
+ return null;
+ }
+ boolean skip = exchange != null &&
exchange.getProperty(Exchange.SKIP_GZIP_ENCODING, Boolean.FALSE, Boolean.class);
+ if (skip ||
!GZIPHelper.isGzip(request.getHeader(Exchange.CONTENT_ENCODING))) {
return is;
}
- if (exchange == null ||
!exchange.getProperty(Exchange.SKIP_GZIP_ENCODING, Boolean.FALSE,
Boolean.class)) {
- String contentEncoding =
request.getHeader(Exchange.CONTENT_ENCODING);
- return GZIPHelper.uncompressGzip(contentEncoding, is);
- } else {
+ // an empty body cannot be uncompressed. Whether the body is empty is
only known after reading:
+ // available() is 0 as long as no data has arrived, such as when the
client waits for 100 Continue
+ // or sends the body later than the headers
+ PushbackInputStream pushback = new PushbackInputStream(is, 1);
+ int first = pushback.read();
+ if (first == -1) {
return is;
}
+ pushback.unread(first);
+ return new GZIPInputStream(pushback);
}
}
diff --git
a/components/camel-http-common/src/test/java/org/apache/camel/http/common/HttpConverterGzipTest.java
b/components/camel-http-common/src/test/java/org/apache/camel/http/common/HttpConverterGzipTest.java
new file mode 100644
index 000000000000..1ea33f1d48ca
--- /dev/null
+++
b/components/camel-http-common/src/test/java/org/apache/camel/http/common/HttpConverterGzipTest.java
@@ -0,0 +1,201 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.http.common;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.GZIPOutputStream;
+import java.util.zip.ZipException;
+
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class HttpConverterGzipTest {
+
+ private static final String TEXT = "Hello World";
+
+ @Test
+ void gzipBodyIsUncompressedWhenNoByteIsAvailableYet() throws Exception {
+ HttpServletRequest request = request("gzip",
nothingAvailable(gzip(TEXT)));
+
+ try (InputStream is = HttpConverter.toInputStream(request, null)) {
+ assertEquals(TEXT, new String(is.readAllBytes(),
StandardCharsets.UTF_8));
+ }
+ }
+
+ @Test
+ void
gzipBodyIsUncompressedWhenNoByteIsAvailableYetAndTheExchangeDoesNotSkipGzip()
throws Exception {
+ HttpServletRequest request = request("gzip",
nothingAvailable(gzip(TEXT)));
+
+ try (InputStream is = HttpConverter.toInputStream(request,
exchange(null))) {
+ assertEquals(TEXT, new String(is.readAllBytes(),
StandardCharsets.UTF_8));
+ }
+ }
+
+ @Test
+ void gzipBodyIsUncompressedWhenItIsAvailable() throws Exception {
+ HttpServletRequest request = request("gzip", allAvailable(gzip(TEXT)));
+
+ try (InputStream is = HttpConverter.toInputStream(request,
exchange(Boolean.FALSE))) {
+ assertEquals(TEXT, new String(is.readAllBytes(),
StandardCharsets.UTF_8));
+ }
+ }
+
+ @Test
+ void emptyBodyWithGzipContentEncodingIsEmpty() throws Exception {
+ HttpServletRequest request = request("gzip", nothingAvailable(new
byte[0]));
+
+ try (InputStream is = HttpConverter.toInputStream(request, null)) {
+ assertEquals(0, is.readAllBytes().length);
+ }
+ }
+
+ @Test
+ void skipGzipEncodingLeavesTheBodyCompressedWhenNoByteIsAvailableYet()
throws Exception {
+ byte[] compressed = gzip(TEXT);
+ ServletInputStream stream = nothingAvailable(compressed);
+ HttpServletRequest request = request("gzip", stream);
+
+ InputStream is = HttpConverter.toInputStream(request,
exchange(Boolean.TRUE));
+ // the stream of the request itself, of which nothing was read
+ assertSame(stream, is);
+ assertArrayEquals(compressed, is.readAllBytes());
+ }
+
+ @Test
+ void skipGzipEncodingLeavesTheBodyCompressedWhenItIsAvailable() throws
Exception {
+ byte[] compressed = gzip(TEXT);
+ ServletInputStream stream = allAvailable(compressed);
+ HttpServletRequest request = request("gzip", stream);
+
+ InputStream is = HttpConverter.toInputStream(request,
exchange(Boolean.TRUE));
+ assertSame(stream, is);
+ assertArrayEquals(compressed, is.readAllBytes());
+ }
+
+ @Test
+ void bodyWithoutGzipContentEncodingIsNotRead() throws Exception {
+ byte[] data = TEXT.getBytes(StandardCharsets.UTF_8);
+ ServletInputStream stream = nothingAvailable(data);
+ HttpServletRequest request = request(null, stream);
+
+ InputStream is = HttpConverter.toInputStream(request, exchange(null));
+ assertSame(stream, is);
+ assertArrayEquals(data, is.readAllBytes());
+ }
+
+ @Test
+ void bodyThatIsNotGzipFailsWhenItIsAvailable() throws Exception {
+ HttpServletRequest request = request("gzip",
allAvailable(TEXT.getBytes(StandardCharsets.UTF_8)));
+
+ assertThrows(ZipException.class, () ->
HttpConverter.toInputStream(request, exchange(null)));
+ }
+
+ @Test
+ void bodyThatIsNotGzipFailsWhenNoByteIsAvailableYet() throws Exception {
+ HttpServletRequest request = request("gzip",
nothingAvailable(TEXT.getBytes(StandardCharsets.UTF_8)));
+
+ assertThrows(ZipException.class, () ->
HttpConverter.toInputStream(request, exchange(null)));
+ }
+
+ private static HttpServletRequest request(String contentEncoding,
ServletInputStream stream) throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+
when(request.getHeader(Exchange.CONTENT_ENCODING)).thenReturn(contentEncoding);
+ when(request.getInputStream()).thenReturn(stream);
+ return request;
+ }
+
+ /**
+ * An exchange with the given value of the CamelSkipGzipEncoding property;
null is an exchange without it.
+ */
+ private static Exchange exchange(Boolean skipGzipEncoding) {
+ Exchange exchange = new DefaultExchange(new DefaultCamelContext());
+ if (skipGzipEncoding != null) {
+ exchange.setProperty(Exchange.SKIP_GZIP_ENCODING,
skipGzipEncoding);
+ }
+ return exchange;
+ }
+
+ private static byte[] gzip(String text) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzip = new GZIPOutputStream(bytes)) {
+ gzip.write(text.getBytes(StandardCharsets.UTF_8));
+ }
+ return bytes.toByteArray();
+ }
+
+ /**
+ * The stream of a request whose body has not arrived when the consumer
starts to read it: available() is 0, and
+ * read() waits for the data.
+ */
+ private static ServletInputStream nothingAvailable(byte[] data) {
+ return stream(data, false);
+ }
+
+ /**
+ * The stream of a request whose body has arrived: available() is the
number of bytes that are left.
+ */
+ private static ServletInputStream allAvailable(byte[] data) {
+ return stream(data, true);
+ }
+
+ private static ServletInputStream stream(byte[] data, boolean available) {
+ ByteArrayInputStream delegate = new ByteArrayInputStream(data);
+ return new ServletInputStream() {
+ @Override
+ public int read() {
+ return delegate.read();
+ }
+
+ @Override
+ public int available() {
+ return available ? delegate.available() : 0;
+ }
+
+ @Override
+ public boolean isFinished() {
+ return delegate.available() == 0;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setReadListener(ReadListener readListener) {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+}
diff --git
a/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/JettyGzipRequestEmptyBodyTest.java
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/JettyGzipRequestEmptyBodyTest.java
new file mode 100644
index 000000000000..de8376a3b2cf
--- /dev/null
+++
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/JettyGzipRequestEmptyBodyTest.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.jetty;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A request with Content-Encoding: gzip and no body: the consumer must not
wait for a body that does not come, also not
+ * with disableStreamCache=true, where the route gets the stream of the
request.
+ */
+class JettyGzipRequestEmptyBodyTest extends BaseJettyTest {
+
+ @Test
+ void contentLengthZero() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(1);
+
+ String response = send("Content-Length: 0\r\n", "");
+
+ assertTrue(response.startsWith("HTTP/1.1 200"), "Expected 200 OK but
was: " + response);
+ mock.assertIsSatisfied();
+ assertNull(mock.getReceivedExchanges().get(0).getMessage().getBody());
+ }
+
+ @Test
+ void chunkedWithoutData() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(1);
+
+ // the last chunk only
+ String response = send("Transfer-Encoding: chunked\r\n", "0\r\n\r\n");
+
+ assertTrue(response.startsWith("HTTP/1.1 200"), "Expected 200 OK but
was: " + response);
+ mock.assertIsSatisfied();
+ assertEquals("",
mock.getReceivedExchanges().get(0).getMessage().getBody());
+ }
+
+ private String send(String lengthHeader, String body) throws Exception {
+ try (Socket socket = new Socket("localhost", getPort())) {
+ // a consumer that waits for a body makes the read of the response
fail
+ socket.setSoTimeout(20000);
+ OutputStream out = socket.getOutputStream();
+ out.write(("POST /gzip HTTP/1.1\r\n"
+ + "Host: localhost:" + getPort() + "\r\n"
+ + "Content-Type: text/plain; charset=UTF-8\r\n"
+ + "Content-Encoding: gzip\r\n"
+ + lengthHeader
+ + "Connection: close\r\n"
+ + "\r\n"
+ + body)
+ .getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+
+ InputStream in = socket.getInputStream();
+ StringBuilder sb = new StringBuilder();
+ int c;
+ while ((c = in.read()) != -1 &&
!sb.toString().endsWith("\r\n\r\n")) {
+ sb.append((char) c);
+ }
+ return sb.toString();
+ }
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("jetty:http://localhost:{{port}}/gzip?disableStreamCache=true")
+ .convertBodyTo(String.class, "UTF-8")
+ .to("mock:result")
+ .removeHeaders("*")
+ .setBody(constant("ok"));
+ }
+ };
+ }
+}
diff --git
a/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/JettyGzipRequestExpectContinueTest.java
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/JettyGzipRequestExpectContinueTest.java
new file mode 100644
index 000000000000..24f54ad15862
--- /dev/null
+++
b/components/camel-jetty/src/test/java/org/apache/camel/component/jetty/JettyGzipRequestExpectContinueTest.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.jetty;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.GZIPOutputStream;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A client that sends {@code Expect: 100-continue} waits for the interim
response of the server before it sends the
+ * request body, so no byte of the body has arrived when the consumer starts
to read it.
+ */
+class JettyGzipRequestExpectContinueTest extends BaseJettyTest {
+
+ private static final String TEXT = "Hello World, sent with
Content-Encoding: gzip";
+
+ @Test
+ void gzipBodySentAfter100ContinueIsUncompressed() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived(TEXT);
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzip = new GZIPOutputStream(bytes)) {
+ gzip.write(TEXT.getBytes(StandardCharsets.UTF_8));
+ }
+
+ String response = sendAfter100Continue(bytes.toByteArray());
+
+ assertTrue(response.startsWith("HTTP/1.1 200"), "Expected 200 OK but
was: " + response);
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void bodyThatIsNotGzipSentAfter100ContinueIsNotPassedToTheRoute() throws
Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(0);
+
+ // Content-Encoding: gzip, but the body is plain text
+ String response =
sendAfter100Continue(TEXT.getBytes(StandardCharsets.UTF_8));
+
+ assertTrue(response.startsWith("HTTP/1.1 500"), "Expected 500 but was:
" + response);
+ mock.assertIsSatisfied();
+ }
+
+ /**
+ * Sends a POST with Content-Encoding: gzip and Expect: 100-continue, and
the body only after the server asked for
+ * it. Returns the status line and the headers of the final response.
+ */
+ private String sendAfter100Continue(byte[] body) throws Exception {
+ try (Socket socket = new Socket("localhost", getPort())) {
+ socket.setSoTimeout(20000);
+ OutputStream out = socket.getOutputStream();
+ InputStream in = socket.getInputStream();
+ out.write(("POST /gzip HTTP/1.1\r\n"
+ + "Host: localhost:" + getPort() + "\r\n"
+ + "Content-Type: text/plain; charset=UTF-8\r\n"
+ + "Content-Encoding: gzip\r\n"
+ + "Content-Length: " + body.length + "\r\n"
+ + "Expect: 100-continue\r\n"
+ + "Connection: close\r\n"
+ + "\r\n")
+ .getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+
+ String interim = readHead(in);
+ assertTrue(interim.startsWith("HTTP/1.1 100"), "Expected 100
Continue but was: " + interim);
+ out.write(body);
+ out.flush();
+
+ return readHead(in);
+ }
+ }
+
+ /**
+ * Reads the status line and the headers of a response, up to and
including the empty line.
+ */
+ private static String readHead(InputStream in) throws Exception {
+ StringBuilder sb = new StringBuilder();
+ int c;
+ while ((c = in.read()) != -1) {
+ sb.append((char) c);
+ if (sb.length() >= 4 && sb.substring(sb.length() -
4).equals("\r\n\r\n")) {
+ break;
+ }
+ }
+ return sb.toString();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("jetty:http://localhost:{{port}}/gzip")
+ .convertBodyTo(String.class, "UTF-8")
+ .to("mock:result")
+ .removeHeaders("*")
+ .setBody(constant("ok"));
+ }
+ };
+ }
+}