This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.18.x by this push:
     new e0e59b4d07e1 [backport camel-4.18.x] CAMEL-25162: camel-oauth - 
confine the post login url to the configured redirect uri origin (#27180)
e0e59b4d07e1 is described below

commit e0e59b4d07e1d00bda29707da71c8d100e6bbb98
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Oct 1 17:42:24 2026 +0200

    [backport camel-4.18.x] CAMEL-25162: camel-oauth - confine the post login 
url to the configured redirect uri origin (#27180)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../apache/camel/oauth/OAuthCodeFlowProcessor.java | 160 +++++++++++++--
 .../camel/oauth/OAuthCodeFlowPostLoginUrlTest.java | 222 +++++++++++++++++++++
 2 files changed, 362 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);
+    }
+}

Reply via email to