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);
+ }
+}