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 48f499f5e991 CAMEL-25705: camel-netty-http - reject an ambiguous
request path when a security constraint is configured (#27945)
48f499f5e991 is described below
commit 48f499f5e99166b05752a1f3a68a206560866d9a
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Oct 23 07:24:46 2026 +0200
CAMEL-25705: camel-netty-http - reject an ambiguous request path when a
security constraint is configured (#27945)
Co-authored-by: Claus Ibsen <[email protected]>
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../http/handlers/HttpServerChannelHandler.java | 101 ++++++++++++++-
...NettyHttpBasicAuthConstraintDotSegmentTest.java | 141 +++++++++++++++++++++
...ettyHttpBasicAuthConstraintRootContextTest.java | 114 +++++++++++++++++
.../HttpServerChannelHandlerDotSegmentTest.java | 71 +++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 19 +++
5 files changed, 445 insertions(+), 1 deletion(-)
diff --git
a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandler.java
b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandler.java
index b846213a6ebd..109098640dab 100644
---
a/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandler.java
+++
b/components/camel-netty-http/src/main/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandler.java
@@ -163,6 +163,27 @@ public class HttpServerChannelHandler extends
ServerChannelHandler {
// we need the relative path without the hostname and port
URI uri = new URI(request.uri());
+
+ // a request path with . or .. segments is ambiguous: the security
constraint, the consumer dispatch
+ // and the route may each interpret it differently, so a path such
as /foo/../admin/x (or its percent-encoded
+ // /foo/%2e%2e/admin/x form) could be matched against the
constraint inclusions/exclusions in a different
+ // form than the one the route handles. Reject it up front when a
security constraint is configured; a
+ // well-behaved HTTP client normalizes dot-segments before sending
(CAMEL-25705). The same applies to a path
+ // with an empty segment, a ; path parameter or a \ (also in their
percent-encoded forms), which a backend
+ // the route forwards to may collapse, strip or treat as a /
respectively. A request target that starts with
+ // // is parsed as a host followed by a path, so its first segment
never reaches uri.getPath(); check the
+ // request target for that empty segment.
+ if (security.getSecurityConstraint() != null
+ && (request.uri().startsWith("//") ||
isAmbiguousPath(uri.getPath()))) {
+ LOG.debug("Rejecting request with an ambiguous path for
resource: {}", url);
+ HttpResponse response = new DefaultHttpResponse(HTTP_1_1,
BAD_REQUEST);
+ response.headers().set(NettyHttpConstants.CONTENT_TYPE,
"text/plain");
+ response.headers().set(Exchange.CONTENT_LENGTH, 0);
+ ctx.writeAndFlush(response);
+ ctx.channel().close();
+ return;
+ }
+
final String target = extractTarget(uri);
// is it a restricted resource?
@@ -223,7 +244,7 @@ public class HttpServerChannelHandler extends
ServerChannelHandler {
// strip the starting endpoint path so the target is relative to the
endpoint uri.
// the comparison must ignore case on the context-path, the same way
consumer dispatch does
// (RestConsumerContextPathMatcher), so the security constraint is
evaluated against the same
- // normalized target the request is actually routed to
+ // target the request is actually routed to
String path = consumer.getConfiguration().getPath();
if (path != null &&
target.toLowerCase(Locale.US).startsWith(path.toLowerCase(Locale.US))) {
target = target.substring(path.length());
@@ -231,6 +252,84 @@ public class HttpServerChannelHandler extends
ServerChannelHandler {
return target;
}
+ /**
+ * Whether the (decoded) path may be interpreted differently by the
security constraint, the consumer dispatch, the
+ * route or a backend the route forwards to: it contains a {@code .} or
{@code ..} segment, an empty segment
+ * ({@code //}), a {@code ;} path parameter or a {@code \}. Such a path is
rejected up front when a security
+ * constraint is configured (see {@link #channelRead0}).
+ */
+ static boolean isAmbiguousPath(String path) {
+ if (path == null) {
+ return false;
+ }
+ return path.indexOf(';') != -1 || path.indexOf('\\') != -1 ||
path.contains("//") || containsDotSegments(path);
+ }
+
+ /**
+ * Whether the path contains a {@code .} or {@code ..} segment that path
normalization would resolve. Such a path
+ * may be interpreted differently by the security constraint, the consumer
dispatch and the route, so it is rejected
+ * up front when a security constraint is configured (see {@link
#channelRead0}).
+ */
+ private static boolean containsDotSegments(String path) {
+ return path != null && !path.equals(removeDotSegments(path));
+ }
+
+ /**
+ * Removes {@code .} and {@code ..} segments from the given path, as
specified by the {@code remove_dot_segments}
+ * algorithm in RFC 3986 section 5.2.4. Used to detect whether a request
path carries dot-segments. Excess
+ * {@code ..} segments cannot climb above the root, so the returned path
never contains a {@code ..} segment.
+ */
+ static String removeDotSegments(String path) {
+ // fast path: nothing to resolve unless there is a dot segment to
begin with
+ if (path == null || path.isEmpty() || path.indexOf('.') == -1) {
+ return path;
+ }
+
+ String in = path;
+ StringBuilder out = new StringBuilder(path.length());
+ while (!in.isEmpty()) {
+ if (in.startsWith("../")) {
+ in = in.substring(3);
+ } else if (in.startsWith("./")) {
+ in = in.substring(2);
+ } else if (in.startsWith("/./")) {
+ in = in.substring(2);
+ } else if (in.equals("/.")) {
+ in = "/";
+ } else if (in.startsWith("/../")) {
+ in = in.substring(3);
+ removeLastSegment(out);
+ } else if (in.equals("/..")) {
+ in = "/";
+ removeLastSegment(out);
+ } else if (in.equals(".") || in.equals("..")) {
+ in = "";
+ } else {
+ // move the first path segment (including any leading slash,
up to but not including the
+ // next slash) from the input to the output
+ int start = in.charAt(0) == '/' ? 1 : 0;
+ int next = in.indexOf('/', start);
+ if (next < 0) {
+ out.append(in);
+ in = "";
+ } else {
+ out.append(in, 0, next);
+ in = in.substring(next);
+ }
+ }
+ }
+ return out.toString();
+ }
+
+ private static void removeLastSegment(StringBuilder out) {
+ int lastSlash = out.lastIndexOf("/");
+ if (lastSlash >= 0) {
+ out.setLength(lastSlash);
+ } else {
+ out.setLength(0);
+ }
+ }
+
protected boolean matchesRoles(String roles, String userRoles) {
// matches if no role restrictions or any role is accepted
if (roles.equals("*")) {
diff --git
a/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpBasicAuthConstraintDotSegmentTest.java
b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpBasicAuthConstraintDotSegmentTest.java
new file mode 100644
index 000000000000..eb9f655f93ff
--- /dev/null
+++
b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpBasicAuthConstraintDotSegmentTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.netty.http;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.camel.test.junit6.TestSupport.assertIsInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * A request path containing {@code .} or {@code ..} segments may be
interpreted differently by the security constraint,
+ * the consumer dispatch and the route, so with a {@code securityConstraint}
configured such a request is rejected with
+ * HTTP 400. This closes both directions of the mismatch: a dot-segment path
can neither reach a restricted inclusion
+ * (for example {@code /admin/*}) unchallenged, nor be treated as an excluded
(public) path when its raw form targets a
+ * restricted one.
+ */
+class NettyHttpBasicAuthConstraintDotSegmentTest extends BaseNettyTestSupport {
+
+ @Override
+ public void doPreSetup() {
+ System.setProperty("java.security.auth.login.config",
"src/test/resources/myjaas.config");
+ }
+
+ @Override
+ public void doPostTearDown() {
+ System.clearProperty("java.security.auth.login.config");
+ }
+
+ @BindToRegistry("mySecurityConfig")
+ public NettyHttpSecurityConfiguration loadSecConf() {
+ NettyHttpSecurityConfiguration security = new
NettyHttpSecurityConfiguration();
+ security.setRealm("karaf");
+ SecurityAuthenticator auth = new JAASSecurityAuthenticator();
+ auth.setName("karaf");
+ security.setSecurityAuthenticator(auth);
+
+ // a specific inclusion and a public exclusion, both below the
endpoint path
+ SecurityConstraintMapping matcher = new SecurityConstraintMapping();
+ matcher.addInclusion("/admin/*");
+ matcher.addExclusion("/public/*");
+ security.setSecurityConstraint(matcher);
+
+ return security;
+ }
+
+ @Test
+ void restrictedPathIsChallenged() {
+ assertStatus("netty-http:http://localhost:{{port}}/foo/admin/x", 401);
+ }
+
+ @Test
+ void excludedPathIsPublic() {
+ assertEquals("Bye World",
+
template.requestBody("netty-http:http://localhost:{{port}}/foo/public/x",
"Hello", String.class));
+ }
+
+ @Test
+ void literalDotSegmentIsRejected() {
+ // a leading .. reaches /admin/x as the raw target evades /admin/*
+ assertStatus("netty-http:http://localhost:{{port}}/foo/../admin/x",
400);
+ }
+
+ @Test
+ void encodedDotSegmentIsRejected() {
+
assertStatus("netty-http:http://localhost:{{port}}/foo/%2e%2e/admin/x", 400);
+ }
+
+ @Test
+ void inContextDotSegmentIsRejected() {
+ // resolves to /foo/admin/y, which stays below the endpoint context
+ assertStatus("netty-http:http://localhost:{{port}}/foo/x/../admin/y",
400);
+ }
+
+ @Test
+ void reverseDotSegmentIsRejected() {
+ // raw /admin/../public/x matches the /admin/* inclusion, but resolves
to the excluded /public/x; rejecting it
+ // prevents the restricted raw target from being treated as public
+
assertStatus("netty-http:http://localhost:{{port}}/foo/admin/../public/x", 400);
+ }
+
+ @Test
+ void pathParameterIsRejected() {
+ // raw /admin;x/y evades /admin/*, but a backend that strips path
parameters sees /admin/y
+ assertStatus("netty-http:http://localhost:{{port}}/foo/admin;x/y",
400);
+ assertStatus("netty-http:http://localhost:{{port}}/foo/admin%3bx/y",
400);
+ }
+
+ @Test
+ void dotSegmentWithPathParameterIsRejected() {
+ // raw /public/..;/admin/x matches the /public/* exclusion, but a
backend that treats ..; as .. sees /admin/x
+
assertStatus("netty-http:http://localhost:{{port}}/foo/public/..;/admin/x",
400);
+ }
+
+ @Test
+ void backslashIsRejected() {
+ // raw /public/\..\..\admin matches the /public/* exclusion, but a
backend that treats \ as / sees /admin
+
assertStatus("netty-http:http://localhost:{{port}}/foo/public/%5c..%5c..%5cadmin",
400);
+ }
+
+ @Test
+ void emptySegmentIsRejected() {
+ // raw //admin/x evades /admin/*, but a backend that collapses empty
segments sees /admin/x
+ assertStatus("netty-http:http://localhost:{{port}}/foo//admin/x", 400);
+ }
+
+ private void assertStatus(String uri, int expected) {
+ CamelExecutionException e = assertThrows(CamelExecutionException.class,
+ () -> template.requestBody(uri, "Hello", String.class));
+ NettyHttpOperationFailedException cause =
assertIsInstanceOf(NettyHttpOperationFailedException.class, e.getCause());
+ assertEquals(expected, cause.getStatusCode());
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("netty-http:http://0.0.0.0:{{port}}/foo?matchOnUriPrefix=true&securityConfiguration=#mySecurityConfig")
+ .transform().constant("Bye World");
+ }
+ };
+ }
+}
diff --git
a/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpBasicAuthConstraintRootContextTest.java
b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpBasicAuthConstraintRootContextTest.java
new file mode 100644
index 000000000000..5255e6f4b6eb
--- /dev/null
+++
b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/NettyHttpBasicAuthConstraintRootContextTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.netty.http;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * A consumer at the root context with a {@code securityConstraint} rejects
with HTTP 400 a request target that starts
+ * with {@code //}. Such a target is parsed as a host followed by a path, so
the empty segment is not in the decoded
+ * path. The requests are written on a plain socket, so the request target
reaches the consumer as written.
+ */
+class NettyHttpBasicAuthConstraintRootContextTest extends BaseNettyTestSupport
{
+
+ @Override
+ public void doPreSetup() {
+ System.setProperty("java.security.auth.login.config",
"src/test/resources/myjaas.config");
+ }
+
+ @Override
+ public void doPostTearDown() {
+ System.clearProperty("java.security.auth.login.config");
+ }
+
+ @BindToRegistry("mySecurityConfig")
+ public NettyHttpSecurityConfiguration loadSecConf() {
+ NettyHttpSecurityConfiguration security = new
NettyHttpSecurityConfiguration();
+ security.setRealm("karaf");
+ SecurityAuthenticator auth = new JAASSecurityAuthenticator();
+ auth.setName("karaf");
+ security.setSecurityAuthenticator(auth);
+
+ SecurityConstraintMapping matcher = new SecurityConstraintMapping();
+ matcher.addInclusion("/admin/*");
+ security.setSecurityConstraint(matcher);
+
+ return security;
+ }
+
+ @Test
+ void restrictedPathIsChallenged() throws IOException {
+ assertEquals(401, statusOf("/admin/x"));
+ }
+
+ @Test
+ void otherPathIsPublic() throws IOException {
+ assertEquals(200, statusOf("/x"));
+ }
+
+ @Test
+ void leadingEmptySegmentIsRejected() throws IOException {
+ assertEquals(400, statusOf("//admin/x"));
+ }
+
+ @Test
+ void leadingEmptySegmentInAbsoluteFormIsRejected() throws IOException {
+ assertEquals(400, statusOf("http://localhost:" + getPort() +
"//admin/x"));
+ }
+
+ private int statusOf(String requestTarget) throws IOException {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress("localhost", getPort()),
5000);
+ socket.setSoTimeout(5000);
+ String request = "GET " + requestTarget + " HTTP/1.1\r\nHost:
localhost:" + getPort()
+ + "\r\nConnection: close\r\n\r\n";
+
socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII));
+ socket.getOutputStream().flush();
+
+ // the status line is HTTP/1.1 <code> <reason>
+ InputStream in = socket.getInputStream();
+ StringBuilder statusLine = new StringBuilder();
+ int b = in.read();
+ while (b != -1 && b != '\r') {
+ statusLine.append((char) b);
+ b = in.read();
+ }
+ return Integer.parseInt(statusLine.toString().split(" ")[1]);
+ }
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("netty-http:http://0.0.0.0:{{port}}?matchOnUriPrefix=true&securityConfiguration=#mySecurityConfig")
+ .transform().constant("Bye World");
+ }
+ };
+ }
+}
diff --git
a/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandlerDotSegmentTest.java
b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandlerDotSegmentTest.java
new file mode 100644
index 000000000000..573817c376c9
--- /dev/null
+++
b/components/camel-netty-http/src/test/java/org/apache/camel/component/netty/http/handlers/HttpServerChannelHandlerDotSegmentTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.netty.http.handlers;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * A request path carrying {@code .} or {@code ..} segments is rejected when a
security constraint is configured;
+ * {@code removeDotSegments} (RFC 3986 section 5.2.4) resolves the path so
those segments can be detected (the result
+ * differs from the input). A path without dot-segments, including a
leading-dot file name, is returned unchanged.
+ * Excess {@code ..} segments cannot climb above the root, so the result never
contains a {@code ..} segment.
+ */
+class HttpServerChannelHandlerDotSegmentTest {
+
+ @ParameterizedTest
+ @CsvSource(delimiter = '|', textBlock = """
+ # input | expected
+ /foo/admin/x | /foo/admin/x
+ /foo/./admin/x | /foo/admin/x
+ /foo/../admin/x | /admin/x
+ /app/../admin/x | /admin/x
+ /foo/../../admin/x | /admin/x
+ /foo/sub/../admin/x | /foo/admin/x
+ /foo/a/../../../admin | /admin
+ /foo/admin/. | /foo/admin/
+ /foo/admin/.. | /foo/
+ /.. | /
+ /admin/file.txt | /admin/file.txt
+ /admin/.hidden/secret | /admin/.hidden/secret
+ / | /
+ """)
+ void resolvesDotSegments(String input, String expected) {
+ assertEquals(expected,
HttpServerChannelHandler.removeDotSegments(input), () -> "input " + input);
+ }
+
+ @ParameterizedTest
+ @CsvSource(delimiter = '|', textBlock = """
+ # input | expected
+ /foo/admin/x | false
+ /admin/file.txt | false
+ /admin/.hidden/secret | false
+ /foo/admin/x/ | false
+ / | false
+ /foo/../admin/x | true
+ /foo/admin/. | true
+ /foo//admin/x | true
+ /foo/admin;x/y | true
+ /foo/public/..;/admin | true
+ /foo/public/\\..\\admin | true
+ """)
+ void detectsAmbiguousPath(String input, boolean expected) {
+ assertEquals(expected,
HttpServerChannelHandler.isAmbiguousPath(input), () -> "input " + input);
+ }
+}
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 78e56e371433..0ecb5e9d4a4e 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
@@ -1161,6 +1161,25 @@ On Jira Cloud the Jira consumers now read the pages of a
search with the next pa
start index: with `maxResults` above 50, a search with more than 50 results
failed on the second page with
`UnsupportedOperationException`.
+=== camel-netty-http - an ambiguous request path is rejected when a security
constraint is configured
+
+A `netty-http` consumer configured with a `securityConstraint` now rejects,
with HTTP 400, a request whose path
+contains `.` or `..` segments (including their percent-encoded `%2e` form).
Such a path is ambiguous: the security
+constraint, the consumer dispatch and the route may each interpret it
differently, so with `matchOnUriPrefix=true` and a
+specific inclusion such as `/admin/*`, a request like `/foo/../admin/x` (or
its encoded `/foo/%2e%2e/admin/x` form)
+could be matched against the inclusions and exclusions in a different form
than the one the route handles. Rejecting
+it up front keeps the constraint decision and the routed path consistent. A
well-behaved HTTP client normalizes
+dot-segments before sending, so normal traffic is unaffected.
+
+For the same reason, such a consumer also rejects with HTTP 400 a request
whose path contains an empty segment (`//`),
+a `;` path parameter (also as `%3b`) or a `\` (also as `%5c`). A backend the
route forwards the request to may collapse
+the empty segment, strip the path parameter or treat the backslash as a `/`,
and so act on a different path than the one
+the constraint was checked against. A route that needs matrix parameters (`;`)
must not configure a
+`securityConstraint` on the same consumer.
+
+This applies only when a `securityConstraint` is configured; a consumer
without one is unchanged, and continues to
+receive the request path as sent.
+
=== camel-hashicorp-vault - properties without engine use the `secret` engine
A `hashicorp:` property without an engine, such as `{{hashicorp:mysecret}}` or
`{{hashicorp:mysecret#field}}`, now