This is an automated email from the ASF dual-hosted git repository.
Croway 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 3a6c27bfae04 CAMEL-24452: camel-http - do not send credentials to an
authority the endpoint was not configured with (#25830)
3a6c27bfae04 is described below
commit 3a6c27bfae04e528f5a76494db6d6d97b9234551
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Aug 28 11:52:39 2026 +0200
CAMEL-24452: camel-http - do not send credentials to an authority the
endpoint was not configured with (#25830)
CAMEL-24452: camel-http - do not send credentials to a host the endpoint
was not configured with
Two paths handed credentials to a redirect target, which is a host chosen
by the remote
server rather than by the route, once followRedirects=true.
OAuth2ClientConfigurer registers its interceptor with
addRequestInterceptorFirst, and
HttpClient runs protocol-level request interceptors inside ProtocolExec,
which sits below
RedirectExec in the exec chain. The interceptor therefore ran again for
every redirect hop
and re-attached Authorization: Bearer <token> to whatever host the Location
header named.
It now attaches the token only for the endpoint's own host.
HttpCredentialsHelper.getCredentialsProvider() was called with the
endpoint's authHost,
which is optional and null in the common basic-auth configuration, making
the scope
new AuthScope(null, -1) - any host, any port, any scheme. HttpClient then
offered the
credentials to whichever host issued a 401 challenge. The scope now falls
back to the
endpoint's host when authHost is not set; an explicit authHost still takes
precedence.
Both need to know the host the endpoint addresses, which
createHttpClientConfigurer did
not receive. Rather than change that protected signature, a three argument
overload
carries the target URI and the existing two argument form delegates to it
with null, so
any subclass overriding or calling it keeps the previous behaviour.
The added test drives a real redirect from a server answering to localhost
to a second one
answering to 127.0.0.1 - a single server will not do, because the bootstrap
sets a
canonical host name and answers 421 to a mismatched Host. Without the fix
the first case
delivers "Bearer xxx.yyy.zzz" and the second "Basic c2NvdHQ6dGlnZXI=" to
the redirect
target.
Signed-off-by: Andrea Cosentino <[email protected]>
Signed-off-by: Croway <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Croway <[email protected]>
---
.../DefaultAuthenticationHttpClientConfigurer.java | 12 +-
.../apache/camel/component/http/HttpComponent.java | 66 +++++++--
.../component/http/HttpCredentialsHelper.java | 10 +-
.../component/http/OAuth2ClientConfigurer.java | 53 +++++++
.../http/HttpClientConfigurerOverrideTest.java | 48 +++++++
.../http/HttpOAuth2RedirectTokenLeakTest.java | 155 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 21 +++
7 files changed, 354 insertions(+), 11 deletions(-)
diff --git
a/components/camel-http/src/main/java/org/apache/camel/component/http/DefaultAuthenticationHttpClientConfigurer.java
b/components/camel-http/src/main/java/org/apache/camel/component/http/DefaultAuthenticationHttpClientConfigurer.java
index 8bcad925eb64..13a1a9b4f160 100644
---
a/components/camel-http/src/main/java/org/apache/camel/component/http/DefaultAuthenticationHttpClientConfigurer.java
+++
b/components/camel-http/src/main/java/org/apache/camel/component/http/DefaultAuthenticationHttpClientConfigurer.java
@@ -37,16 +37,26 @@ public class DefaultAuthenticationHttpClientConfigurer
implements HttpClientConf
private final String username;
private final char[] password;
private final String domain;
+ private final String scheme;
private final String host;
+ private final Integer port;
private final String bearerToken;
private final HttpCredentialsHelper credentialsHelper;
public DefaultAuthenticationHttpClientConfigurer(String user, String pwd,
String domain, String host, String bearerToken,
HttpCredentialsHelper
credentialsHelper) {
+ this(user, pwd, domain, null, host, null, bearerToken,
credentialsHelper);
+ }
+
+ DefaultAuthenticationHttpClientConfigurer(String user, String pwd, String
domain, String scheme, String host,
+ Integer port, String bearerToken,
+ HttpCredentialsHelper
credentialsHelper) {
this.username = user;
this.password = pwd == null ? new char[0] : pwd.toCharArray();
this.domain = domain;
+ this.scheme = scheme;
this.host = host;
+ this.port = port;
this.bearerToken = bearerToken;
this.credentialsHelper = credentialsHelper;
}
@@ -80,7 +90,7 @@ public class DefaultAuthenticationHttpClientConfigurer
implements HttpClientConf
defaultcreds = new UsernamePasswordCredentials(username, password);
}
clientBuilder.setDefaultCredentialsProvider(credentialsHelper
- .getCredentialsProvider(host, null, defaultcreds));
+ .getCredentialsProvider(scheme, host, port, defaultcreds));
}
}
diff --git
a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpComponent.java
b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpComponent.java
index d7706108022e..72f74b56e534 100644
---
a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpComponent.java
+++
b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpComponent.java
@@ -84,6 +84,7 @@ import org.slf4j.LoggerFactory;
public class HttpComponent extends HttpCommonComponent implements
RestProducerFactory, SSLContextParametersAware {
private static final Logger LOG =
LoggerFactory.getLogger(HttpComponent.class);
+ private static final String TARGET_URI_PARAMETER =
HttpComponent.class.getName() + ".targetUri";
@Metadata(label = "advanced",
description = "To use the custom HttpClientConfigurer to perform
configuration of the HttpClient that will be used.")
@@ -247,6 +248,12 @@ public class HttpComponent extends HttpCommonComponent
implements RestProducerFa
* @throws Exception is thrown if error creating configurer
*/
protected HttpClientConfigurer createHttpClientConfigurer(Map<String,
Object> parameters, boolean secure) throws Exception {
+ URI targetUri = (URI) parameters.remove(TARGET_URI_PARAMETER);
+ return createHttpClientConfigurer(parameters, secure, targetUri);
+ }
+
+ private HttpClientConfigurer createHttpClientConfigurer(Map<String,
Object> parameters, boolean secure, URI targetUri)
+ throws Exception {
// prefer to use endpoint configured over component configured
HttpClientConfigurer configurer
= resolveAndRemoveReferenceParameter(parameters,
"httpClientConfigurer", HttpClientConfigurer.class);
@@ -255,15 +262,15 @@ public class HttpComponent extends HttpCommonComponent
implements RestProducerFa
configurer = getHttpClientConfigurer();
}
HttpCredentialsHelper credentialsProvider = new
HttpCredentialsHelper();
- configurer = configureBasicAuthentication(parameters, configurer,
credentialsProvider);
+ configurer = configureBasicAuthentication(parameters, configurer,
credentialsProvider, targetUri);
configurer = configureHttpProxy(parameters, configurer, secure,
credentialsProvider);
- configurer = configureOAuth2Authentication(parameters, configurer);
+ configurer = configureOAuth2Authentication(parameters, configurer,
targetUri);
return configurer;
}
private HttpClientConfigurer configureOAuth2Authentication(
- Map<String, Object> parameters, HttpClientConfigurer configurer) {
+ Map<String, Object> parameters, HttpClientConfigurer configurer,
URI targetUri) {
String clientId = getParameter(parameters, "oauth2ClientId",
String.class);
String clientSecret = getParameter(parameters, "oauth2ClientSecret",
String.class);
@@ -302,14 +309,15 @@ public class HttpComponent extends HttpCommonComponent
implements RestProducerFa
cacheTokens,
cachedTokensDefaultExpirySeconds,
cachedTokensExpirationMarginSeconds,
- useBodyAuthentication));
+ useBodyAuthentication,
+ targetUri));
}
return configurer;
}
private HttpClientConfigurer configureBasicAuthentication(
Map<String, Object> parameters, HttpClientConfigurer configurer,
- HttpCredentialsHelper credentialsProvider) {
+ HttpCredentialsHelper credentialsProvider, URI targetUri) {
String authUsername = getParameter(parameters, "authUsername",
String.class);
String authPassword = getParameter(parameters, "authPassword",
String.class);
@@ -319,7 +327,9 @@ public class HttpComponent extends HttpCommonComponent
implements RestProducerFa
return CompositeHttpConfigurer.combineConfigurers(configurer,
new DefaultAuthenticationHttpClientConfigurer(
- authUsername, authPassword, authDomain, authHost,
null, credentialsProvider));
+ authUsername, authPassword, authDomain,
authScopeScheme(authHost, targetUri),
+ authScopeHost(authHost, targetUri),
authScopePort(authHost, targetUri), null,
+ credentialsProvider));
} else if (this.httpConfiguration != null) {
if
("basic".equalsIgnoreCase(this.httpConfiguration.getAuthMethod())
||
"bearer".equalsIgnoreCase(this.httpConfiguration.getAuthMethod())) {
@@ -327,7 +337,10 @@ public class HttpComponent extends HttpCommonComponent
implements RestProducerFa
new DefaultAuthenticationHttpClientConfigurer(
this.httpConfiguration.getAuthUsername(),
this.httpConfiguration.getAuthPassword(),
this.httpConfiguration.getAuthDomain(),
- this.httpConfiguration.getAuthHost(),
this.httpConfiguration.getAuthBearerToken(),
+
authScopeScheme(this.httpConfiguration.getAuthHost(), targetUri),
+
authScopeHost(this.httpConfiguration.getAuthHost(), targetUri),
+
authScopePort(this.httpConfiguration.getAuthHost(), targetUri),
+ this.httpConfiguration.getAuthBearerToken(),
credentialsProvider));
}
}
@@ -335,6 +348,35 @@ public class HttpComponent extends HttpCommonComponent
implements RestProducerFa
return configurer;
}
+ /**
+ * The host the credentials are scoped to.
+ * <p>
+ * {@code authHost} is optional and is unset in the common basic-auth
configuration, which made the scope
+ * {@code new AuthScope(null, -1)} - matching any host, any port, any
scheme. HttpClient then offers the credentials
+ * to whichever host issues a 401 challenge, so with {@code
followRedirects=true} a redirect chosen by the remote
+ * server could collect them. Fall back to the authority the endpoint
actually addresses.
+ */
+ private static String authScopeHost(String authHost, URI targetUri) {
+ if (authHost != null) {
+ return authHost;
+ }
+ return targetUri != null ? targetUri.getHost() : null;
+ }
+
+ private static String authScopeScheme(String authHost, URI targetUri) {
+ return authHost == null && targetUri != null ? targetUri.getScheme() :
null;
+ }
+
+ private static Integer authScopePort(String authHost, URI targetUri) {
+ if (authHost != null || targetUri == null) {
+ return null;
+ }
+ if (targetUri.getPort() >= 0) {
+ return targetUri.getPort();
+ }
+ return "https".equalsIgnoreCase(targetUri.getScheme()) ? 443 : 80;
+ }
+
private HttpClientConfigurer configureHttpProxy(
Map<String, Object> parameters, HttpClientConfigurer configurer,
boolean secure,
HttpCredentialsHelper credentialsProvider) {
@@ -450,8 +492,14 @@ public class HttpComponent extends HttpCommonComponent
implements RestProducerFa
// uri part should be without protocol as that was how this component
was originally created
uri =
org.apache.camel.component.http.HttpUtil.removeHttpOrHttpsProtocol(uri);
- // create the configurer to use for this endpoint
- HttpClientConfigurer configurer =
createHttpClientConfigurer(parameters, secure);
+ // Keep dispatching through the existing protected method so
subclasses overriding it continue to be invoked.
+ HttpClientConfigurer configurer;
+ parameters.put(TARGET_URI_PARAMETER, uriHttpUriAddress);
+ try {
+ configurer = createHttpClientConfigurer(parameters, secure);
+ } finally {
+ parameters.remove(TARGET_URI_PARAMETER);
+ }
URI endpointUri = URISupport.createRemainingURI(uriHttpUriAddress,
httpClientParameters);
endpointUri = URISupport.createRemainingURI(
diff --git
a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpCredentialsHelper.java
b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpCredentialsHelper.java
index 6185b6e9da8e..8d9032af31d9 100644
---
a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpCredentialsHelper.java
+++
b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpCredentialsHelper.java
@@ -39,9 +39,17 @@ public final class HttpCredentialsHelper {
public CredentialsProvider getCredentialsProvider(
String host, Integer port, Credentials credentials) {
+ return getCredentialsProvider(null, host, port, credentials);
+ }
+
+ CredentialsProvider getCredentialsProvider(
+ String scheme, String host, Integer port, Credentials credentials)
{
this.credentialsProvider.setCredentials(new AuthScope(
+ scheme,
host,
- Objects.requireNonNullElse(port, -1)), credentials);
+ Objects.requireNonNullElse(port, -1),
+ null,
+ null), credentials);
return credentialsProvider;
}
diff --git
a/components/camel-http/src/main/java/org/apache/camel/component/http/OAuth2ClientConfigurer.java
b/components/camel-http/src/main/java/org/apache/camel/component/http/OAuth2ClientConfigurer.java
index 8edb187e02f2..c98d980a3ac6 100644
---
a/components/camel-http/src/main/java/org/apache/camel/component/http/OAuth2ClientConfigurer.java
+++
b/components/camel-http/src/main/java/org/apache/camel/component/http/OAuth2ClientConfigurer.java
@@ -41,9 +41,13 @@ import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.protocol.HttpContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class OAuth2ClientConfigurer extends ServiceSupport implements
HttpClientConfigurer {
+ private static final Logger LOG =
LoggerFactory.getLogger(OAuth2ClientConfigurer.class);
+
private final String clientId;
private final String clientSecret;
private final String tokenEndpoint;
@@ -54,12 +58,27 @@ public class OAuth2ClientConfigurer extends ServiceSupport
implements HttpClient
private final static ConcurrentMap<OAuth2URIAndCredentials, TokenCache>
tokenCache = new ConcurrentHashMap<>();
private final boolean useBodyAuthentication;
private final String resourceIndicator;
+ private final URI targetUri;
private HttpClient httpClient;
public OAuth2ClientConfigurer(String clientId, String clientSecret, String
tokenEndpoint, String resourceIndicator,
String scope, boolean cacheTokens,
long cachedTokensDefaultExpirySeconds, long
cachedTokensExpirationMarginSeconds,
boolean useBodyAuthentication) {
+ this(clientId, clientSecret, tokenEndpoint, resourceIndicator, scope,
cacheTokens,
+ cachedTokensDefaultExpirySeconds,
cachedTokensExpirationMarginSeconds, useBodyAuthentication, null);
+ }
+
+ /**
+ * @param targetUri the URI the endpoint addresses. The bearer token is
only attached to requests for the same
+ * authority, so that a redirect chosen by the remote
server cannot collect it. Null keeps the
+ * previous behaviour of attaching it to whatever
authority the request names.
+ */
+ OAuth2ClientConfigurer(String clientId, String clientSecret, String
tokenEndpoint, String resourceIndicator,
+ String scope, boolean cacheTokens,
+ long cachedTokensDefaultExpirySeconds, long
cachedTokensExpirationMarginSeconds,
+ boolean useBodyAuthentication, URI targetUri) {
+ this.targetUri = targetUri;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenEndpoint = tokenEndpoint;
@@ -78,6 +97,14 @@ public class OAuth2ClientConfigurer extends ServiceSupport
implements HttpClient
clientBuilder.addRequestInterceptorFirst((HttpRequest request,
EntityDetails entity, HttpContext context) -> {
URI requestUri = getUriFromRequest(request);
+ if (!isTargetAuthority(requestUri)) {
+ // HttpClient runs protocol-level request interceptors inside
ProtocolExec, which sits below
+ // RedirectExec, so this runs again for every redirect hop.
Without this check the bearer token is
+ // re-attached to whichever authority the Location header
named.
+ LOG.debug("Not attaching the OAuth2 bearer token to {}, which
is not the endpoint's authority {}",
+ requestUri, targetUri);
+ return;
+ }
OAuth2URIAndCredentials uriAndCredentials = new
OAuth2URIAndCredentials(
requestUri, clientId, clientSecret, tokenEndpoint, scope,
resourceIndicator);
if (cacheTokens) {
@@ -103,6 +130,32 @@ public class OAuth2ClientConfigurer extends ServiceSupport
implements HttpClient
});
}
+ private boolean isTargetAuthority(URI requestUri) {
+ if (targetUri == null) {
+ return true;
+ }
+ if (targetUri.getScheme() == null || targetUri.getHost() == null
+ || requestUri == null || requestUri.getScheme() == null ||
requestUri.getHost() == null) {
+ return false;
+ }
+ return targetUri.getScheme().equalsIgnoreCase(requestUri.getScheme())
+ && targetUri.getHost().equalsIgnoreCase(requestUri.getHost())
+ && effectivePort(targetUri) == effectivePort(requestUri);
+ }
+
+ private static int effectivePort(URI uri) {
+ if (uri.getPort() >= 0) {
+ return uri.getPort();
+ }
+ if ("http".equalsIgnoreCase(uri.getScheme())) {
+ return 80;
+ }
+ if ("https".equalsIgnoreCase(uri.getScheme())) {
+ return 443;
+ }
+ return -1;
+ }
+
private JsonObject getAccessTokenResponse(HttpClient httpClient) throws
IOException {
String bodyStr = "grant_type=client_credentials";
if (scope != null) {
diff --git
a/components/camel-http/src/test/java/org/apache/camel/component/http/HttpClientConfigurerOverrideTest.java
b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpClientConfigurerOverrideTest.java
new file mode 100644
index 000000000000..074cd08cbba9
--- /dev/null
+++
b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpClientConfigurerOverrideTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.http;
+
+import java.util.Map;
+
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class HttpClientConfigurerOverrideTest extends CamelTestSupport {
+
+ @Test
+ public void existingTwoArgumentOverrideIsStillInvoked() {
+ TrackingHttpComponent component = new TrackingHttpComponent();
+ context.addComponent("http-tracking", component);
+
+
assertThat(context.getEndpoint("http-tracking://localhost:8080")).isNotNull();
+ assertThat(component.invoked).isTrue();
+ }
+
+ private static final class TrackingHttpComponent extends HttpComponent {
+
+ private boolean invoked;
+
+ @Override
+ protected HttpClientConfigurer createHttpClientConfigurer(Map<String,
Object> parameters, boolean secure)
+ throws Exception {
+ invoked = true;
+ return super.createHttpClientConfigurer(parameters, secure);
+ }
+ }
+}
diff --git
a/components/camel-http/src/test/java/org/apache/camel/component/http/HttpOAuth2RedirectTokenLeakTest.java
b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpOAuth2RedirectTokenLeakTest.java
new file mode 100644
index 000000000000..13b6b72c5a5e
--- /dev/null
+++
b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpOAuth2RedirectTokenLeakTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.http;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.component.http.handler.OAuth2TokenRequestHandler;
+import org.apache.camel.util.IOHelper;
+import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
+import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * HttpClient runs protocol-level request interceptors inside {@code
ProtocolExec}, which sits below
+ * {@code RedirectExec} in the exec chain, so the OAuth2 interceptor runs once
per redirect hop. Without a check it
+ * re-attaches the bearer token to whichever host the {@code Location} header
named - a host chosen by the remote
+ * server, not by the route.
+ * <p>
+ * The tests cover both a different host and a different port because
credentials are scoped to an authority, not just a
+ * host name.
+ */
+public class HttpOAuth2RedirectTokenLeakTest extends BaseHttpTest {
+
+ private static final String FAKE_TOKEN = "xxx.yyy.zzz";
+ private static final String CLIENT_ID = "test-client";
+ private static final String CLIENT_SECRET = "test-secret";
+
+ private final AtomicReference<String> authorizationSeenAfterRedirect = new
AtomicReference<>();
+
+ private HttpServer localServer;
+ private HttpServer differentHostRedirectTarget;
+ private HttpServer differentPortRedirectTarget;
+
+ @Override
+ public void setupResources() throws Exception {
+ differentHostRedirectTarget = createRedirectTarget("127.0.0.1");
+ differentHostRedirectTarget.start();
+ differentPortRedirectTarget = createRedirectTarget("localhost");
+ differentPortRedirectTarget.start();
+
+ localServer = ServerBootstrap.bootstrap()
+
.setCanonicalHostName("localhost").setHttpProcessor(getBasicHttpProcessor())
+
.setConnectionReuseStrategy(getConnectionReuseStrategy()).setResponseFactory(getHttpResponseFactory())
+ .setSslContext(getSSLContext())
+ .register("/token", new OAuth2TokenRequestHandler(FAKE_TOKEN,
CLIENT_ID, CLIENT_SECRET))
+ .register("/redirect-to-different-host", (request, response,
context) -> {
+ response.setHeader("Location",
+ "http://127.0.0.1:" +
differentHostRedirectTarget.getLocalPort() + "/elsewhere");
+ response.setCode(302);
+ })
+ .register("/redirect-to-different-port", (request, response,
context) -> {
+ response.setHeader("Location",
+ "http://localhost:" +
differentPortRedirectTarget.getLocalPort() + "/elsewhere");
+ response.setCode(302);
+ })
+ .register("/challenge-on-different-host", (request, response,
context) -> {
+ response.setHeader("Location",
+ "http://127.0.0.1:" +
differentHostRedirectTarget.getLocalPort() + "/challenge");
+ response.setCode(302);
+ })
+ .register("/challenge-on-different-port", (request, response,
context) -> {
+ response.setHeader("Location",
+ "http://localhost:" +
differentPortRedirectTarget.getLocalPort() + "/challenge");
+ response.setCode(302);
+ })
+ .create();
+
+ localServer.start();
+ }
+
+ private HttpServer createRedirectTarget(String canonicalHostName) {
+ return ServerBootstrap.bootstrap()
+
.setCanonicalHostName(canonicalHostName).setHttpProcessor(getBasicHttpProcessor())
+
.setConnectionReuseStrategy(getConnectionReuseStrategy()).setResponseFactory(getHttpResponseFactory())
+ .register("/elsewhere", (request, response, context) -> {
+ authorizationSeenAfterRedirect.set(
+ request.containsHeader("Authorization")
+ ?
request.getFirstHeader("Authorization").getValue() : null);
+ response.setCode(200);
+ response.setEntity(new StringEntity("Bye World"));
+ })
+ .register("/challenge", (request, response, context) -> {
+ authorizationSeenAfterRedirect.set(
+ request.containsHeader("Authorization")
+ ?
request.getFirstHeader("Authorization").getValue() : null);
+ response.setHeader("WWW-Authenticate", "Basic
realm=\"elsewhere\"");
+ response.setCode(401);
+ })
+ .create();
+ }
+
+ @Override
+ public void cleanupResources() throws Exception {
+ IOHelper.close(localServer, differentHostRedirectTarget,
differentPortRedirectTarget);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { true, false })
+ public void theBearerTokenIsNotSentToARedirectTarget(boolean
differentHost) {
+ HttpComponent http = context.getComponent("http", HttpComponent.class);
+ http.setFollowRedirects(true);
+
+ String tokenEndpoint = "http://localhost:" +
localServer.getLocalPort() + "/token";
+ String redirectPath = differentHost ? "/redirect-to-different-host" :
"/redirect-to-different-port";
+ String uri = "http://localhost:" + localServer.getLocalPort() +
redirectPath + "?oauth2ClientId=" + CLIENT_ID
+ + "&oauth2ClientSecret=" + CLIENT_SECRET +
"&oauth2TokenEndpoint=" + tokenEndpoint;
+
+ String body = fluentTemplate.to(uri).request(String.class);
+
+ assertThat(body).as("the redirect should still have been
followed").isEqualTo("Bye World");
+ assertThat(authorizationSeenAfterRedirect.get())
+ .as("the bearer token must not be re-attached to the authority
the Location header named").isNull();
+ }
+
+ /**
+ * The basic-auth half of the same problem: authHost is optional and unset
in the common configuration, which made
+ * the credentials scope {@code new AuthScope(null, -1)} - any host, any
port, any scheme. HttpClient then offers
+ * the credentials to whichever host issues a 401 challenge, including one
reached by following a redirect the
+ * remote server chose.
+ */
+ @ParameterizedTest
+ @ValueSource(booleans = { true, false })
+ public void basicCredentialsAreNotOfferedToARedirectTarget(boolean
differentHost) {
+ HttpComponent http = context.getComponent("http", HttpComponent.class);
+ http.setFollowRedirects(true);
+
+ String challengePath = differentHost ? "/challenge-on-different-host"
: "/challenge-on-different-port";
+ String uri = "http://localhost:" + localServer.getLocalPort() +
challengePath
+ + "?throwExceptionOnFailure=false"
+ + "&authUsername=scott&authPassword=tiger";
+
+ fluentTemplate.to(uri).request(String.class);
+
+ assertThat(authorizationSeenAfterRedirect.get())
+ .as("basic credentials must not be offered to the authority
the Location header named").isNull();
+ }
+}
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 3fcc5b76c9b6..afb004c9e82b 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
@@ -564,6 +564,27 @@ is truncated rather than recreated and would otherwise
keep its original permiss
Deployments where another account legitimately reads these files — a sidecar
or a backup agent running as
a different user — need to run as the owner, or use a group-aware key store
instead.
+=== camel-http
+
+Credentials are no longer sent to an authority the endpoint was not configured
with. Two paths reached that
+outcome once `followRedirects=true`, since a redirect target is chosen by the
remote server rather than
+by the route:
+
+* The OAuth2 interceptor is registered with `addRequestInterceptorFirst`, and
HttpClient runs
+protocol-level request interceptors inside `ProtocolExec`, which sits below
`RedirectExec` — so it ran
+again for every redirect hop and re-attached `Authorization: Bearer <token>`
to whatever authority the
+`Location` header named. The token is now attached only for the endpoint's own
scheme, host and effective port.
+* `authHost` is optional and unset in the common basic-auth configuration,
which made the credentials
+scope `new AuthScope(null, -1)` — any host, any port, any scheme — so
HttpClient offered the credentials
+to whichever host issued a `401` challenge. The scope now falls back to the
endpoint's host when
+`authHost` is not set and is restricted to the endpoint's scheme and effective
port.
+
+Routes that relied on credentials following a redirect to a different
authority must set `authHost`
+explicitly, which continues to take precedence and preserves the previous
any-port behaviour.
+
+`HttpComponent.createHttpClientConfigurer(Map, boolean)` remains the protected
customization point and is still invoked
+when an endpoint is created, so existing subclasses continue to behave as
before.
+
=== camel-jetty
`enableCORS=true` added `new CrossOriginFilter()` with no init parameters, so
Jetty's own defaults