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"));
+            }
+        };
+    }
+}

Reply via email to