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 5326d4071104 CAMEL-25162: camel-oauth - confine the post login url to 
the configured redirect uri origin (#27118)
5326d4071104 is described below

commit 5326d4071104ecfb0babf83d9fbe64fe34119bb5
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Sep 30 18:54:54 2026 +0200

    CAMEL-25162: camel-oauth - confine the post login url to the configured 
redirect uri origin (#27118)
    
    The post-login URL was rebuilt from caller-controlled X-Forwarded-* and 
Host headers and later emitted as the Location of the post-login redirect, so a 
client could point the redirect at an arbitrary origin.
    
    The URL is now always built from the origin of the operator-configured 
camel.oauth.redirect-uri plus the path and query of the current request, so an 
absolute or protocol-relative CamelHttpUri cannot move the redirect either. 
Proxy deployments keep working (CAMEL-21899) as the redirect uri already 
carries the public origin.
    
    Closes #27118
    
    Co-authored-by: Claude Opus 5 <[email protected]>
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../apache/camel/oauth/OAuthCodeFlowProcessor.java | 160 +++++++++++++--
 .../camel/oauth/OAuthCodeFlowPostLoginUrlTest.java | 222 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  10 +
 3 files changed, 372 insertions(+), 20 deletions(-)

diff --git 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowProcessor.java
 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowProcessor.java
index 0ef855c34c04..de806ec50c10 100644
--- 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowProcessor.java
+++ 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthCodeFlowProcessor.java
@@ -16,8 +16,12 @@
  */
 package org.apache.camel.oauth;
 
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.security.SecureRandom;
 import java.util.Base64;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import org.apache.camel.Exchange;
 import org.apache.camel.Message;
@@ -34,6 +38,10 @@ public class OAuthCodeFlowProcessor extends 
AbstractOAuthProcessor {
 
     private final Logger log = LoggerFactory.getLogger(getClass());
 
+    // every candidate origin is caller controlled, so warn at most once and 
keep further mismatches at DEBUG -
+    // otherwise a forged Host or X-Forwarded-Host header would let anyone 
flood the diagnostic log
+    private final AtomicBoolean foreignOriginWarned = new AtomicBoolean();
+
     @Override
     public void process(Exchange exchange) {
         var context = exchange.getContext();
@@ -67,7 +75,7 @@ public class OAuthCodeFlowProcessor extends 
AbstractOAuthProcessor {
 
         // Fallback to the authorization code flow
         //
-        var postLoginUrl = getPostLoginUrl(msg);
+        var postLoginUrl = getPostLoginUrl(exchange);
         log.info("Register post login url: {}", postLoginUrl);
         session.putValue("OAuthPostLoginUrl", postLoginUrl);
 
@@ -92,28 +100,140 @@ public class OAuthCodeFlowProcessor extends 
AbstractOAuthProcessor {
         return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
     }
 
-    private String getPostLoginUrl(Message msg) {
-        String postLoginUrl;
+    /**
+     * Rebuilds the absolute url the browser should be sent back to once the 
login completed.
+     *
+     * The result is later emitted as the Location header of the post login 
redirect, so it has to stay on the
+     * deployment's own origin. Every candidate the request offers - the 
X-Forwarded-* headers, and the Host header
+     * behind {@link Exchange#HTTP_URL} - comes from the untrusted caller, so 
none of them is used to build the url. The
+     * origin is always taken from the operator controlled redirect uri and 
only the request path is carried over, which
+     * means the redirect can never leave the deployment.
+     *
+     * This preserves CAMEL-21899: behind an ingress or an OpenShift Route the 
internally observed request url is not
+     * the externally reachable one, and the configured redirect uri names the 
external address - it is the url the
+     * identity provider sends the browser back to - so the post login 
redirect still targets the external address.
+     */
+    String getPostLoginUrl(Exchange exchange) {
+        var msg = exchange.getMessage();
+        var redirectUri = getRequiredProperty(exchange.getContext(), 
CAMEL_OAUTH_REDIRECT_URI);
+        var expectedOrigin = originOf(redirectUri);
+        if (expectedOrigin == null) {
+            throw new IllegalStateException(
+                    "Cannot derive an origin from " + CAMEL_OAUTH_REDIRECT_URI 
+ ": " + redirectUri);
+        }
+
+        warnOnForeignOrigin(msg, expectedOrigin);
+
+        return expectedOrigin + requestPath(msg);
+    }
+
+    /**
+     * Warns when the origin the caller announces is not the configured one. 
This is purely diagnostic - the post login
+     * url is built from the configured origin either way - but behind an 
ingress or an OpenShift Route a mismatch is
+     * the usual symptom of {@link OAuth#CAMEL_OAUTH_REDIRECT_URI} not naming 
the address the browser actually reaches.
+     * <p>
+     * Every candidate origin is caller controlled, so the warning fires at 
most once and any further mismatch drops to
+     * DEBUG - otherwise a forged Host or X-Forwarded-Host header would let 
anyone flood the log.
+     */
+    private void warnOnForeignOrigin(Message msg, String expectedOrigin) {
+        var observedOrigin = forwardedOrigin(msg);
+        if (observedOrigin == null) {
+            // No usable X-Forwarded-* headers, fall back to the request url 
as observed by this instance
+            observedOrigin = originOf(msg.getHeader(Exchange.HTTP_URL, 
String.class));
+        }
+        if (observedOrigin != null && !expectedOrigin.equals(observedOrigin)) {
+            if (foreignOriginWarned.compareAndSet(false, true)) {
+                log.warn("Post login origin {} does not match the configured 
{}, now using: {}."
+                         + " Further mismatches are logged at DEBUG.",
+                        observedOrigin, CAMEL_OAUTH_REDIRECT_URI, 
expectedOrigin);
+            } else if (log.isDebugEnabled()) {
+                log.debug("Post login origin {} does not match the configured 
{}, now using: {}",
+                        observedOrigin, CAMEL_OAUTH_REDIRECT_URI, 
expectedOrigin);
+            }
+        }
+    }
+
+    /**
+     * The origin (scheme://host[:port]) the X-Forwarded-* headers describe, 
or null when they are absent or unusable.
+     */
+    private static String forwardedOrigin(Message msg) {
         var xProto = msg.getHeader("X-Forwarded-Proto", String.class);
         var xHost = msg.getHeader("X-Forwarded-Host", String.class);
         var xPort = msg.getHeader("X-Forwarded-Port", Integer.class);
-        if (xProto != null && xHost != null) {
-            postLoginUrl = xProto + "://" + xHost;
-            if (xPort != null) {
-                if (xProto.equals("https") && xPort != 443) {
-                    postLoginUrl += ":" + xPort;
-                }
-                if (xProto.equals("http") && xPort != 80) {
-                    postLoginUrl += ":" + xPort;
-                }
-            }
-            var httpUri = msg.getHeader(Exchange.HTTP_URI, String.class);
-            if (httpUri != null && !httpUri.isEmpty()) {
-                postLoginUrl += httpUri;
-            }
-        } else {
-            postLoginUrl = msg.getHeader(Exchange.HTTP_URL, String.class);
+        if (xProto == null || xHost == null) {
+            return null;
+        }
+        // Chained proxies append to these headers, the client facing entry is 
the first one
+        var firstHost = xHost.split(",", 2)[0].trim();
+        var firstProto = xProto.split(",", 2)[0].trim();
+        if (firstHost.isEmpty() || firstProto.isEmpty()) {
+            return null;
+        }
+        URI uri;
+        try {
+            uri = new URI(firstProto + "://" + firstHost);
+        } catch (URISyntaxException ex) {
+            return null;
+        }
+        // X-Forwarded-Host may already carry the port, in which case it wins 
over X-Forwarded-Port
+        var port = uri.getPort() > 0 ? uri.getPort() : (xPort != null ? xPort 
: -1);
+        return originOf(uri.getScheme(), uri.getHost(), port);
+    }
+
+    /**
+     * The origin (scheme://host[:port]) of the given url, with a default port 
omitted, or null when the url is not
+     * absolute or cannot be parsed.
+     */
+    private static String originOf(String url) {
+        if (url == null || url.isEmpty()) {
+            return null;
+        }
+        URI uri;
+        try {
+            uri = new URI(url);
+        } catch (URISyntaxException ex) {
+            return null;
+        }
+        return originOf(uri.getScheme(), uri.getHost(), uri.getPort());
+    }
+
+    private static String originOf(String scheme, String host, int port) {
+        if (scheme == null || host == null) {
+            return null;
+        }
+        var lcScheme = scheme.toLowerCase(Locale.ROOT);
+        var origin = lcScheme + "://" + host.toLowerCase(Locale.ROOT);
+        if (port > 0 && !(port == 443 && lcScheme.equals("https")) && !(port 
== 80 && lcScheme.equals("http"))) {
+            origin += ":" + port;
+        }
+        return origin;
+    }
+
+    /**
+     * The path (and query) of the current request, never an absolute or 
protocol relative url, so that appending it to
+     * an origin cannot move the redirect to another host.
+     */
+    private static String requestPath(Message msg) {
+        var httpUri = msg.getHeader(Exchange.HTTP_URI, String.class);
+        if (httpUri == null || httpUri.isEmpty()) {
+            httpUri = msg.getHeader(Exchange.HTTP_URL, String.class);
+        }
+        if (httpUri == null || httpUri.isEmpty()) {
+            return "";
+        }
+        URI uri;
+        try {
+            uri = new URI(httpUri);
+        } catch (URISyntaxException ex) {
+            return "";
+        }
+        var path = uri.getRawPath();
+        if (path == null || path.isEmpty()) {
+            path = "/";
+        } else if (!path.startsWith("/")) {
+            path = "/" + path;
         }
-        return postLoginUrl;
+        var query = uri.getRawQuery();
+        return query != null ? path + "?" + query : path;
     }
 }
diff --git 
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthCodeFlowPostLoginUrlTest.java
 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthCodeFlowPostLoginUrlTest.java
new file mode 100644
index 000000000000..b53cc5bcc85e
--- /dev/null
+++ 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthCodeFlowPostLoginUrlTest.java
@@ -0,0 +1,222 @@
+/*
+ * 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.oauth;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.camel.oauth.OAuth.CAMEL_OAUTH_REDIRECT_URI;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * The post login url is emitted as the Location header of the post login 
redirect. Every origin the request offers -
+ * the X-Forwarded-* headers, and the Host header behind CamelHttpUrl - is 
caller controlled, so the url is always built
+ * from the origin of the configured redirect uri. Whatever the caller sends, 
the expected url below is therefore that
+ * same origin plus the requested path: that is the property these tests pin.
+ *
+ * CAMEL-21899 is preserved by construction: the configured redirect uri is 
the address the identity provider sends the
+ * browser back to, so behind an ingress or an OpenShift Route it is the 
externally reachable one.
+ */
+class OAuthCodeFlowPostLoginUrlTest {
+
+    private static final String REDIRECT_URI = "https://app.example.com/auth";;
+
+    private DefaultCamelContext context;
+    private Exchange exchange;
+
+    @BeforeEach
+    void setUp() {
+        context = new DefaultCamelContext();
+        
context.getPropertiesComponent().addInitialProperty(CAMEL_OAUTH_REDIRECT_URI, 
REDIRECT_URI);
+        exchange = new DefaultExchange(context);
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        context.close();
+    }
+
+    private String postLoginUrl() {
+        return new OAuthCodeFlowProcessor().getPostLoginUrl(exchange);
+    }
+
+    /**
+     * CAMEL-21899: the browser is sent to the externally reachable url, not 
to the internally observed one.
+     */
+    @Test
+    void theExternallyReachableUrlIsUsedBehindAProxy() {
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https");
+        msg.setHeader("X-Forwarded-Host", "app.example.com");
+        msg.setHeader(Exchange.HTTP_URI, "/hello");
+        msg.setHeader(Exchange.HTTP_URL, "http://10.0.0.7:8080/hello";);
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    @Test
+    void aNonDefaultPortOfTheConfiguredRedirectUriIsKept() {
+        
context.getPropertiesComponent().addInitialProperty(CAMEL_OAUTH_REDIRECT_URI,
+                "https://app.example.com:8443/auth";);
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https");
+        msg.setHeader("X-Forwarded-Host", "app.example.com");
+        msg.setHeader("X-Forwarded-Port", 8443);
+        msg.setHeader(Exchange.HTTP_URI, "/hello");
+
+        assertEquals("https://app.example.com:8443/hello";, postLoginUrl());
+    }
+
+    @Test
+    void theDefaultPortIsNotAppendedToTheOrigin() {
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https");
+        msg.setHeader("X-Forwarded-Host", "app.example.com");
+        msg.setHeader("X-Forwarded-Port", 443);
+        msg.setHeader(Exchange.HTTP_URI, "/hello");
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    @Test
+    void aForwardedHostOnAnotherOriginIsConfinedToTheConfiguredOrigin() {
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https");
+        msg.setHeader("X-Forwarded-Host", "evil.example.net");
+        msg.setHeader(Exchange.HTTP_URI, "/hello");
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    /**
+     * A mismatched origin is only a diagnostic, so the warning fires at most 
once - a forged Host must not let anyone
+     * flood the log. The later calls take the warn-once branch that drops to 
DEBUG, and every call still confines the
+     * url to the configured origin, not just the first.
+     */
+    @Test
+    void repeatedForeignOriginRequestsStayConfinedOnTheSameProcessor() {
+        var processor = new OAuthCodeFlowProcessor();
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https");
+        msg.setHeader("X-Forwarded-Host", "evil.example.net");
+        msg.setHeader(Exchange.HTTP_URI, "/hello");
+
+        assertEquals("https://app.example.com/hello";, 
processor.getPostLoginUrl(exchange));
+        assertEquals("https://app.example.com/hello";, 
processor.getPostLoginUrl(exchange));
+        assertEquals("https://app.example.com/hello";, 
processor.getPostLoginUrl(exchange));
+    }
+
+    @Test
+    void aForwardedProtoOnAnotherSchemeIsConfinedToTheConfiguredOrigin() {
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "http");
+        msg.setHeader("X-Forwarded-Host", "app.example.com");
+        msg.setHeader(Exchange.HTTP_URI, "/hello");
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    @Test
+    void aForwardedPortOnAnotherPortIsConfinedToTheConfiguredOrigin() {
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https");
+        msg.setHeader("X-Forwarded-Host", "app.example.com");
+        msg.setHeader("X-Forwarded-Port", 9443);
+        msg.setHeader(Exchange.HTTP_URI, "/hello");
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    /**
+     * Chained proxies produce "host1, host2". Such a list must never be 
concatenated into the url.
+     */
+    @Test
+    void aCommaSeparatedForwardedHostIsNotConcatenatedIntoTheUrl() {
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https, http");
+        msg.setHeader("X-Forwarded-Host", "app.example.com, 
internal.example.net");
+        msg.setHeader(Exchange.HTTP_URI, "/hello");
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    @Test
+    void aCommaSeparatedForwardedHostStartingOnAnotherOriginIsConfined() {
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https");
+        msg.setHeader("X-Forwarded-Host", "evil.example.net, app.example.com");
+        msg.setHeader(Exchange.HTTP_URI, "/hello");
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    @Test
+    void withoutForwardedHeadersAMatchingRequestUrlIsKept() {
+        var msg = exchange.getMessage();
+        msg.setHeader(Exchange.HTTP_URL, "https://app.example.com/hello";);
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    @Test
+    void withoutForwardedHeadersARequestUrlOnAnotherOriginIsConfined() {
+        var msg = exchange.getMessage();
+        msg.setHeader(Exchange.HTTP_URL, "https://evil.example.net/hello";);
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    @Test
+    void withoutAnyRequestHeadersTheConfiguredOriginIsUsed() {
+        assertEquals("https://app.example.com";, postLoginUrl());
+    }
+
+    /**
+     * A protocol relative request uri must not be able to move the redirect 
to another host.
+     */
+    @Test
+    void aProtocolRelativeRequestUriCannotChangeTheOrigin() {
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https");
+        msg.setHeader("X-Forwarded-Host", "app.example.com");
+        msg.setHeader(Exchange.HTTP_URI, "//evil.example.net/hello");
+
+        assertEquals("https://app.example.com/hello";, postLoginUrl());
+    }
+
+    @Test
+    void theQueryOfTheRequestUriIsKept() {
+        var msg = exchange.getMessage();
+        msg.setHeader("X-Forwarded-Proto", "https");
+        msg.setHeader("X-Forwarded-Host", "app.example.com");
+        msg.setHeader(Exchange.HTTP_URI, "/hello?greeting=hi");
+
+        assertEquals("https://app.example.com/hello?greeting=hi";, 
postLoginUrl());
+    }
+
+    @Test
+    void anUnparsableRedirectUriIsRejected() {
+        
context.getPropertiesComponent().addInitialProperty(CAMEL_OAUTH_REDIRECT_URI, 
"not-a-url");
+
+        assertThrows(IllegalStateException.class, this::postLoginUrl);
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index b62a9a431e19..7cb38d69733e 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -68,6 +68,16 @@ camel.resource.http.read-timeout = 30000
 
 OAuth client credentials token caching now distinguishes profiles by client 
secret and requested scope, in addition to token endpoint and client ID. 
Profiles with different credentials or scopes request separate tokens instead 
of reusing the same cached token. Applications using such profiles may make 
additional token requests after upgrading.
 
+The post login url of the authorization code flow is now always built from the 
origin (`scheme://host[:port]`)
+of the configured `camel.oauth.redirect-uri`, plus the requested path. 
`OAuthCodeFlowProcessor` previously
+rebuilt that url from the `X-Forwarded-Proto` / `X-Forwarded-Host` / 
`X-Forwarded-Port` request headers, or
+from the `Host` header behind `CamelHttpUrl` when those were absent. All of 
those are set by the caller, so
+a request could point the post login redirect at any origin. A deployment 
behind an ingress or an OpenShift
+Route still redirects to its externally reachable address, because 
`camel.oauth.redirect-uri` is the address
+the identity provider sends the browser back to. Deployments whose external 
address differs from that
+property must set it to the address the browser actually reaches; a request 
announcing another origin is now
+redirected to the configured one and a warning is logged.
+
 === Simple language
 
 `${ }` may now hold a predicate, as the braces do in Jakarta EL, Groovy and a 
JavaScript template:

Reply via email to