From ab83129f0f189fc699cdcb867551b5bd2447cd04 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Fri, 16 Oct 2026 12:48:05 +0200 Subject: [PATCH 1/3] CAMEL-25704: camel-undertow - refuse a request target or header value with control characters Co-Authored-By: Claude Opus 4.8 Signed-off-by: Andrea Cosentino --- .../undertow/DefaultUndertowHttpBinding.java | 4 + .../component/undertow/UndertowHelper.java | 25 ++++++ .../component/undertow/UndertowProducer.java | 19 +++- .../UndertowProducerEncodedUriTest.java | 88 +++++++++++++++++++ .../pages/camel-4x-upgrade-guide-4_24.adoc | 11 +++ 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHttpBinding.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHttpBinding.java index a669de857ba78..7358bbc4e5333 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHttpBinding.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHttpBinding.java @@ -424,6 +424,10 @@ public Object toHttpRequest(ClientRequest clientRequest, Message message) { String headerValue = tc.convertTo(String.class, it.next()); if (headerValue != null && headerFilterStrategy != null && !headerFilterStrategy.applyFilterToCamelHeaders(key, headerValue, message.getExchange())) { + // the HTTP client writes each character of the value as a single byte with no validation, so a + // CR or LF (or a character that narrows to one) would break the value out of its header line; + // reject such a value rather than send a malformed request + UndertowHelper.checkWriteable("value of HTTP header " + key, headerValue); LOG.trace("HTTP-Header: {}={}", key, headerValue); requestHeaders.add(new HttpString(key), headerValue); } diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowHelper.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowHelper.java index 914e3ccd8e74a..318e407a99a3b 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowHelper.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowHelper.java @@ -153,6 +153,31 @@ public static HttpString createMethod(Exchange exchange, UndertowEndpoint endpoi return answer; } + /** + * Rejects a value that cannot be written to the HTTP request line or a header line as it is. The Undertow client + * writes each character of the request path, query and header values as a single byte without any validation, so a + * CR or LF would break the request into further lines or headers, and a character above {@code 0xFF} is narrowed to + * a single, different byte (for example {@code U+010A} would be written as {@code LF}). A control character, a + * {@code DEL} or a character above {@code 0xFF} is therefore refused; {@code SP} and {@code HTAB}, which do not + * change the framing, are allowed. + * + * @param what a short description of the value, used in the error message + * @param value the value to check, may be {@code null} + * @throws IllegalArgumentException if the value holds a character that cannot be written as it is + */ + public static void checkWriteable(String what, String value) { + if (value == null) { + return; + } + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (ch > 0xFF || ch == 0x7F || (ch < 0x20 && ch != '\t')) { + throw new IllegalArgumentException( + "Illegal character 0x" + Integer.toHexString(ch) + " in " + what); + } + } + } + public static URI makeHttpURI(String httpURI) { return makeHttpURI( URI.create(UnsafeUriCharactersEncoder.encodeHttpURI(httpURI))); diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java index dc52f6bbc9040..645738c560e06 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java @@ -96,18 +96,21 @@ public boolean process(final Exchange camelExchange, final AsyncCallback callbac /* not a WebSocket */ final URI uri; final HttpString method; + final String pathAndQuery; try { final String exchangeUri = UndertowHelper.createURL(camelExchange, getEndpoint()); uri = UndertowHelper.createURI(camelExchange, exchangeUri, getEndpoint()); method = UndertowHelper.createMethod(camelExchange, endpoint, camelExchange.getIn().getBody() != null); - } catch (final URISyntaxException e) { + pathAndQuery = URISupport.pathAndQueryOf(uri); + // the client writes the request target byte by byte with no validation, so a CR or LF (for example from a + // percent-encoded control character in a header) would break the request line; refuse it instead + UndertowHelper.checkWriteable("HTTP request target", pathAndQuery); + } catch (final URISyntaxException | IllegalArgumentException e) { camelExchange.setException(e); callback.done(true); return true; } - final String pathAndQuery = URISupport.pathAndQueryOf(uri); - final UndertowHttpBinding undertowHttpBinding = endpoint.getUndertowHttpBinding(); final CookieHandler cookieHandler = endpoint.getCookieHandler(); @@ -142,7 +145,15 @@ public boolean process(final Exchange camelExchange, final AsyncCallback callbac requestHeaders.putAll(HttpString.tryFromString(key), values); }); - final Object body = undertowHttpBinding.toHttpRequest(request, camelExchange.getIn()); + final Object body; + try { + body = undertowHttpBinding.toHttpRequest(request, camelExchange.getIn()); + } catch (final IllegalArgumentException e) { + // a header value that cannot be written to a header line (see UndertowHelper.checkWriteable) + camelExchange.setException(e); + callback.done(true); + return true; + } final UndertowClientCallback clientCallback; final boolean streaming = getEndpoint().isUseStreaming(); if (streaming && body instanceof InputStream) { diff --git a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java new file mode 100644 index 0000000000000..258b44f74b675 --- /dev/null +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java @@ -0,0 +1,88 @@ +/* + * 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.undertow; + +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The undertow producer must not write a request path, query or header value that it cannot put on the wire as it is: a + * percent-encoded CR/LF in {@code CamelHttpPath} or {@code CamelHttpQuery}, or a CR/LF in a header value, is refused + * rather than written into the request line or a header line. An ordinary query is still sent. + */ +class UndertowProducerEncodedUriTest extends BaseUndertowTest { + + @Test + void rejectsEncodedCrlfInQuery() { + Exchange out = template.request("undertow:http://localhost:{{port}}/echo", + e -> e.getIn().setHeader(Exchange.HTTP_QUERY, "q=x%0D%0AX-Injected:%201")); + assertRejected(out, "request target"); + } + + @Test + void rejectsEncodedCrlfInPath() { + Exchange out = template.request("undertow:http://localhost:{{port}}/echo", + e -> e.getIn().setHeader(Exchange.HTTP_PATH, "a%0D%0Ab")); + assertRejected(out, "request target"); + } + + @Test + void rejectsHeaderValueWithCrlf() { + Exchange out = template.request("undertow:http://localhost:{{port}}/echo", + e -> e.getIn().setHeader("MyHeader", "value\r\nX-Injected: 1")); + assertRejected(out, "HTTP header"); + } + + @Test + void sendsAnOrdinaryQuery() { + String out = template.requestBodyAndHeader("undertow:http://localhost:{{port}}/echo", + null, Exchange.HTTP_QUERY, "q=hello", String.class); + assertEquals("q=hello", out); + } + + private static void assertRejected(Exchange out, String expectedInMessage) { + assertTrue(out.isFailed(), "the exchange should have failed"); + Throwable cause = out.getException(); + assertNotNull(cause); + boolean found = false; + while (cause != null) { + if (cause instanceof IllegalArgumentException && cause.getMessage() != null + && cause.getMessage().contains(expectedInMessage)) { + found = true; + break; + } + cause = cause.getCause(); + } + assertTrue(found, "expected an IllegalArgumentException mentioning '" + expectedInMessage + "'"); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("undertow:http://localhost:{{port}}/echo") + .setBody(simple("${header.CamelHttpQuery}")); + } + }; + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index e11786cc9c922..10c4ee8ef895b 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -1041,6 +1041,17 @@ placeholder or a route template parameter in them never matched (Switch) or did They are now resolved at startup like the other EIP options; a placeholder of a property that does not exist now fails the route at startup. +=== camel-undertow - the producer refuses a request target or header value with control characters + +The undertow producer writes the path, query and header values of the outgoing request to the socket one character at +a time, without validating them. A carriage return or line feed therefore broke the request into further lines, and a +character above `0xFF` was written as a single, different byte (for example `U+010A` became a line feed). The producer +now refuses to send a request path, query or header value that contains a control character or a character above +`0xFF`, and fails the exchange with an `IllegalArgumentException` instead. A space and a tab, which do not change the +framing, are still allowed. Routes that put a percent-encoded control character (such as `%0D%0A`) into the +`CamelHttpPath` or `CamelHttpQuery` header, which the producer decodes before building the request, are the ones most +likely to be affected. + === camel-dapr - the saveBulk and executeTransaction state operations are sent The `saveBulk` and `executeTransaction` operations of `dapr:state` built their request but never sent it to Dapr, so From 4ff646415e46e05b276f0b961f7b5bb67ddbe13a Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Fri, 16 Oct 2026 13:17:02 +0200 Subject: [PATCH 2/3] CAMEL-25704: camel-undertow - validate the Host and Content-Type headers too The producer writes the Host header (the caller's own when preserveHostHeader is set) and the Content-Type header verbatim, outside the header-copy loop, so run them through UndertowHelper.checkWriteable as well. Adds tests for the above-0xFF narrowing branch and for a CR/LF in a preserved Host header. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Andrea Cosentino --- .../undertow/DefaultUndertowHttpBinding.java | 3 +- .../component/undertow/UndertowProducer.java | 29 ++++++++++--------- .../UndertowProducerEncodedUriTest.java | 16 ++++++++++ 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHttpBinding.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHttpBinding.java index 7358bbc4e5333..5890436be2ed0 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHttpBinding.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHttpBinding.java @@ -407,7 +407,8 @@ public Object toHttpRequest(ClientRequest clientRequest, Message message) { // set the content type in the response. String contentType = MessageHelper.getContentType(message); if (contentType != null) { - // set content-type + // set content-type (written verbatim by the client, so validate it like any other header value) + UndertowHelper.checkWriteable("Content-Type header", contentType); requestHeaders.put(Headers.CONTENT_TYPE, contentType); LOG.trace("Content-Type: {}", contentType); } diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java index 645738c560e06..4c973b5632282 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java @@ -133,23 +133,26 @@ public boolean process(final Exchange camelExchange, final AsyncCallback callbac final HeaderMap requestHeaders = request.getRequestHeaders(); - // Set the Host header - final Message message = camelExchange.getIn(); - final String host = message.getHeader(UndertowConstants.HOST_STRING, String.class); - if (endpoint.isPreserveHostHeader()) { - requestHeaders.put(Headers.HOST, Optional.ofNullable(host).orElseGet(uri::getAuthority)); - } else { - requestHeaders.put(Headers.HOST, uri.getAuthority()); - } - cookieHeaders.forEach((key, values) -> { - requestHeaders.putAll(HttpString.tryFromString(key), values); - }); - final Object body; try { + // Set the Host header. When preserveHostHeader is set this is the caller's Host header, which the client + // writes verbatim, so it is validated like any other header value. + final Message message = camelExchange.getIn(); + final String host = message.getHeader(UndertowConstants.HOST_STRING, String.class); + if (endpoint.isPreserveHostHeader()) { + final String hostHeader = Optional.ofNullable(host).orElseGet(uri::getAuthority); + UndertowHelper.checkWriteable("Host header", hostHeader); + requestHeaders.put(Headers.HOST, hostHeader); + } else { + requestHeaders.put(Headers.HOST, uri.getAuthority()); + } + cookieHeaders.forEach((key, values) -> { + requestHeaders.putAll(HttpString.tryFromString(key), values); + }); + body = undertowHttpBinding.toHttpRequest(request, camelExchange.getIn()); } catch (final IllegalArgumentException e) { - // a header value that cannot be written to a header line (see UndertowHelper.checkWriteable) + // a Host or other header value that cannot be written to a header line (see UndertowHelper.checkWriteable) camelExchange.setException(e); callback.done(true); return true; diff --git a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java index 258b44f74b675..bc5505c748530 100644 --- a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java @@ -45,6 +45,14 @@ void rejectsEncodedCrlfInPath() { assertRejected(out, "request target"); } + @Test + void rejectsNarrowingCharacterInQuery() { + // U+010A is above 0xFF; the client would narrow it to the single byte 0x0A (LF). It is refused before that. + Exchange out = template.request("undertow:http://localhost:{{port}}/echo", + e -> e.getIn().setHeader(Exchange.HTTP_QUERY, "q=Ċ")); + assertRejected(out, "request target"); + } + @Test void rejectsHeaderValueWithCrlf() { Exchange out = template.request("undertow:http://localhost:{{port}}/echo", @@ -52,6 +60,14 @@ void rejectsHeaderValueWithCrlf() { assertRejected(out, "HTTP header"); } + @Test + void rejectsCrlfInPreservedHostHeader() { + // with preserveHostHeader the caller's Host header is written verbatim, so it is validated too + Exchange out = template.request("undertow:http://localhost:{{port}}/echo?preserveHostHeader=true", + e -> e.getIn().setHeader("Host", "localhost\r\nX-Injected: 1")); + assertRejected(out, "Host header"); + } + @Test void sendsAnOrdinaryQuery() { String out = template.requestBodyAndHeader("undertow:http://localhost:{{port}}/echo", From 6cbc02bbb9b1412ec0296ed564e01b7485d50285 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Sun, 18 Oct 2026 12:15:41 +0200 Subject: [PATCH 3/3] CAMEL-25704: camel-undertow - add a positive test for the allowed header-value characters Pins the allowed set: a header value with a tab and a Latin-1 (obs-text) character is sent, not refused. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Andrea Cosentino --- .../undertow/UndertowProducerEncodedUriTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java index bc5505c748530..23b947215fa55 100644 --- a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -75,6 +76,14 @@ void sendsAnOrdinaryQuery() { assertEquals("q=hello", out); } + @Test + void sendsAHeaderWithTabAndLatin1() { + // a tab, a space and a Latin-1 (obs-text) character do not change the request framing, so they are NOT refused + Exchange out = template.request("undertow:http://localhost:{{port}}/echo", + e -> e.getIn().setHeader("MyHeader", "café\tau lait")); + assertFalse(out.isFailed(), "a header value with a tab and a Latin-1 character must not be refused"); + } + private static void assertRejected(Exchange out, String expectedInMessage) { assertTrue(out.isFailed(), "the exchange should have failed"); Throwable cause = out.getException();