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 dad7b548904b CAMEL-25704: camel-undertow - refuse a request target or
header value with control characters (#27944)
dad7b548904b is described below
commit dad7b548904bba4deef52f06ee2c1e6cda4c0864
Author: Andrea Cosentino <[email protected]>
AuthorDate: Sun Oct 18 17:31:21 2026 +0200
CAMEL-25704: camel-undertow - refuse a request target or header value with
control characters (#27944)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
.../undertow/DefaultUndertowHttpBinding.java | 7 +-
.../camel/component/undertow/UndertowHelper.java | 25 +++++
.../camel/component/undertow/UndertowProducer.java | 44 +++++---
.../undertow/UndertowProducerEncodedUriTest.java | 113 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 11 ++
5 files changed, 184 insertions(+), 16 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 a669de857ba7..5890436be2ed 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 class DefaultUndertowHttpBinding implements
UndertowHttpBinding {
// 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);
}
@@ -424,6 +425,10 @@ public class DefaultUndertowHttpBinding implements
UndertowHttpBinding {
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 914e3ccd8e74..318e407a99a3 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 final class UndertowHelper {
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 dc52f6bbc904..4c973b563228 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 class UndertowProducer extends DefaultAsyncProducer {
/* 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();
@@ -130,19 +133,30 @@ public class UndertowProducer extends
DefaultAsyncProducer {
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);
+ });
- final Object body = undertowHttpBinding.toHttpRequest(request,
camelExchange.getIn());
+ body = undertowHttpBinding.toHttpRequest(request,
camelExchange.getIn());
+ } catch (final IllegalArgumentException e) {
+ // 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;
+ }
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 000000000000..23b947215fa5
--- /dev/null
+++
b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/UndertowProducerEncodedUriTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.assertFalse;
+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 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",
+ e -> e.getIn().setHeader("MyHeader", "value\r\nX-Injected:
1"));
+ 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",
+ null, Exchange.HTTP_QUERY, "q=hello", String.class);
+ 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();
+ 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 74d6f61a5466..53e6a5cdea2d 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
@@ -1068,6 +1068,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