Skip to content

Commit 2bed849

Browse files
committed
Release 2.14.5: Security fixes and dependency upgrades
Security: - Backport GHSA-cmxv-58fp-fm3g: strip Authorization and Proxy-Authorization headers on cross-origin, scheme-downgrade, or port-mismatch redirects. - Add stripAuthorizationOnRedirect config flag (default false) for users who need to always strip credentials even on same-origin redirects. - Clear realm and proxyRealm on future when stripping to prevent NettyRequestFactory from regenerating auth headers on redirect. Tests: - New RedirectCredentialSecurityTest for cross-origin redirect scenarios. - New HttpsDowngradeRedirectTest for HTTPS-to-HTTP scheme downgrade. - New StripAuthorizationOnRedirectHttpTest for the new config flag. - New DefaultAsyncHttpClientConfigTest for config default coverage. Dependencies: - netty 4.1.65.Final -> 4.1.121.Final (CVE fixes) - slf4j 1.7.30 -> 1.7.36 - netty-reactive-streams 2.0.4 -> 2.0.17 - rxjava2 2.2.10 -> 2.2.21 - logback 1.2.3 -> 1.2.13 - testng 7.1.0 -> 7.5.1 (last Java 8 compatible) - commons-io 2.6 -> 2.21.0 - commons-fileupload 1.4 -> 1.6.0 - hamcrest-core -> hamcrest 2.2 - jetty pinned at 9.4.18.v20190429 (9.4.58 changes 401 socket behavior) - tomcat pinned at 9.0.31 (9.0.117 changes WebDAV response format) CI: - Add release.yml workflow for Maven Central publishing. - Update maven.yml to trigger on 2.14.5 branch with Corretto JDK 8. Test fixes: - InputStreamTest.available() now honors InputStream contract by returning 0 after EOF (Netty 4.1.65+ correctly rejects always-1). - CookieStoreTest replaces Guava Sets.newHashSet with HashSet (TestNG 7.5+ no longer pulls transitive Guava). - TestUtils uses SslContextFactory.Server (base class deprecated).
1 parent 6afba08 commit 2bed849

27 files changed

Lines changed: 863 additions & 50 deletions

File tree

.github/workflows/maven.yml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ name: Test PR
55

66
on:
77
pull_request:
8-
branches: [ master ]
8+
branches: [ master, 2.14.5 ]
9+
push:
10+
branches: [ 2.14.5 ]
911

1012
jobs:
1113
build:
12-
runs-on: ubuntu-20.04
14+
runs-on: ubuntu-latest
1315
steps:
14-
- uses: actions/checkout@v2
15-
- name: Set up JDK 1.8
16-
uses: actions/setup-java@v1
16+
- uses: actions/checkout@v6
17+
- name: Set up JDK 8
18+
uses: actions/setup-java@v5
1719
with:
18-
java-version: 1.8
20+
java-version: 8
21+
distribution: 'corretto'
1922
- name: Build and test with Maven
20-
run: mvn test -Ptest-output
23+
run: mvn test -ntp -B -Ptest-output

.github/workflows/release.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: Release 2.x
2+
3+
on:
4+
push:
5+
branches:
6+
- 2.14.5
7+
8+
workflow_dispatch:
9+
inputs:
10+
snapshot:
11+
description: 'Deploy SNAPSHOT'
12+
type: boolean
13+
default: false
14+
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
20+
deploy:
21+
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v6
25+
26+
- uses: actions/setup-java@v5
27+
with:
28+
distribution: 'corretto'
29+
java-version: '8'
30+
31+
- name: Validate SNAPSHOT version
32+
if: inputs.snapshot == true
33+
run: |
34+
VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
35+
if [[ "$VERSION" != *-SNAPSHOT ]]; then
36+
echo "::error::Version $VERSION is not a SNAPSHOT version"
37+
exit 1
38+
fi
39+
40+
- name: Remove old Maven Settings
41+
run: rm -f /home/runner/.m2/settings.xml
42+
43+
- name: Maven Settings
44+
uses: s4u/maven-settings-action@v4.0.0
45+
with:
46+
servers: |
47+
[{
48+
"id": "sonatype-nexus-staging",
49+
"username": "${{ secrets.OSSRH_USERNAME }}",
50+
"password": "${{ secrets.OSSRH_PASSWORD }}"
51+
},
52+
{
53+
"id": "ossrh",
54+
"username": "${{ secrets.OSSRH_USERNAME }}",
55+
"password": "${{ secrets.OSSRH_PASSWORD }}"
56+
}]
57+
58+
- name: Import GPG
59+
uses: crazy-max/ghaction-import-gpg@v7.0.0
60+
with:
61+
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
62+
passphrase: ${{ secrets.GPG_PASSPHRASE }}
63+
64+
- name: Deploy
65+
env:
66+
GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }}
67+
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
68+
run: mvn -B -ntp deploy -DskipTests -Dgpg.keyname=${GPG_KEY_NAME} -Dgpg.passphrase=${GPG_PASSPHRASE}

bom/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<parent>
66
<groupId>org.asynchttpclient</groupId>
77
<artifactId>async-http-client-project</artifactId>
8-
<version>2.12.4</version>
8+
<version>2.14.5</version>
99
</parent>
1010

1111
<artifactId>async-http-client-bom</artifactId>

client/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<parent>
33
<groupId>org.asynchttpclient</groupId>
44
<artifactId>async-http-client-project</artifactId>
5-
<version>2.12.4</version>
5+
<version>2.14.5</version>
66
</parent>
77
<modelVersion>4.0.0</modelVersion>
88
<artifactId>async-http-client</artifactId>

client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,15 @@ public interface AsyncHttpClientConfig {
347347

348348
int getIoThreadsCount();
349349

350+
/**
351+
* Indicates whether the Authorization header should be stripped during redirects to a different domain.
352+
*
353+
* @return true if the Authorization header should be stripped, false otherwise.
354+
*/
355+
default boolean isStripAuthorizationOnRedirect() {
356+
return false;
357+
}
358+
350359
enum ResponseBodyPartFactory {
351360

352361
EAGER {

client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig {
6363
private final boolean keepEncodingHeader;
6464
private final ProxyServerSelector proxyServerSelector;
6565
private final boolean validateResponseHeaders;
66+
private final boolean stripAuthorizationOnRedirect;
6667

6768
// websockets
6869
private final boolean aggregateWebSocketFrameFragments;
@@ -153,6 +154,7 @@ private DefaultAsyncHttpClientConfig(// http
153154
boolean validateResponseHeaders,
154155
boolean aggregateWebSocketFrameFragments,
155156
boolean enablewebSocketCompression,
157+
boolean stripAuthorizationOnRedirect,
156158

157159
// timeouts
158160
int connectTimeout,
@@ -239,6 +241,7 @@ private DefaultAsyncHttpClientConfig(// http
239241
this.keepEncodingHeader = keepEncodingHeader;
240242
this.proxyServerSelector = proxyServerSelector;
241243
this.validateResponseHeaders = validateResponseHeaders;
244+
this.stripAuthorizationOnRedirect = stripAuthorizationOnRedirect;
242245

243246
// websocket
244247
this.aggregateWebSocketFrameFragments = aggregateWebSocketFrameFragments;
@@ -483,6 +486,11 @@ public boolean isValidateResponseHeaders() {
483486
return validateResponseHeaders;
484487
}
485488

489+
@Override
490+
public boolean isStripAuthorizationOnRedirect() {
491+
return stripAuthorizationOnRedirect;
492+
}
493+
486494
// ssl
487495
@Override
488496
public boolean isUseOpenSsl() {
@@ -713,6 +721,7 @@ public static class Builder {
713721
private boolean useProxySelector = defaultUseProxySelector();
714722
private boolean useProxyProperties = defaultUseProxyProperties();
715723
private boolean validateResponseHeaders = defaultValidateResponseHeaders();
724+
private boolean stripAuthorizationOnRedirect = false; // default value
716725

717726
// websocket
718727
private boolean aggregateWebSocketFrameFragments = defaultAggregateWebSocketFrameFragments();
@@ -801,6 +810,7 @@ public Builder(AsyncHttpClientConfig config) {
801810
disableZeroCopy = config.isDisableZeroCopy();
802811
keepEncodingHeader = config.isKeepEncodingHeader();
803812
proxyServerSelector = config.getProxyServerSelector();
813+
stripAuthorizationOnRedirect = config.isStripAuthorizationOnRedirect();
804814

805815
// websocket
806816
aggregateWebSocketFrameFragments = config.isAggregateWebSocketFrameFragments();
@@ -935,6 +945,11 @@ public Builder setProxyServerSelector(ProxyServerSelector proxyServerSelector) {
935945
return this;
936946
}
937947

948+
public Builder setStripAuthorizationOnRedirect(boolean value) {
949+
this.stripAuthorizationOnRedirect = value;
950+
return this;
951+
}
952+
938953
public Builder setValidateResponseHeaders(boolean validateResponseHeaders) {
939954
this.validateResponseHeaders = validateResponseHeaders;
940955
return this;
@@ -1314,6 +1329,7 @@ public DefaultAsyncHttpClientConfig build() {
13141329
validateResponseHeaders,
13151330
aggregateWebSocketFrameFragments,
13161331
enablewebSocketCompression,
1332+
stripAuthorizationOnRedirect,
13171333
connectTimeout,
13181334
requestTimeout,
13191335
readTimeout,

client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,13 @@ public class Redirect30xInterceptor {
6262
private final AsyncHttpClientConfig config;
6363
private final NettyRequestSender requestSender;
6464
private final MaxRedirectException maxRedirectException;
65+
private final boolean stripAuthorizationOnRedirect;
6566

6667
Redirect30xInterceptor(ChannelManager channelManager, AsyncHttpClientConfig config, NettyRequestSender requestSender) {
6768
this.channelManager = channelManager;
6869
this.config = config;
6970
this.requestSender = requestSender;
71+
this.stripAuthorizationOnRedirect = config.isStripAuthorizationOnRedirect();
7072
maxRedirectException = unknownStackTrace(new MaxRedirectException("Maximum redirect reached: " + config.getMaxRedirects()), Redirect30xInterceptor.class,
7173
"exitAfterHandlingRedirect");
7274
}
@@ -92,15 +94,35 @@ public boolean exitAfterHandlingRedirect(Channel channel,
9294
&& !originalMethod.equals(OPTIONS) && !originalMethod.equals(HEAD) && (statusCode == MOVED_PERMANENTLY_301 || statusCode == SEE_OTHER_303 || (statusCode == FOUND_302 && !config.isStrict302Handling()));
9395
boolean keepBody = statusCode == TEMPORARY_REDIRECT_307 || statusCode == PERMANENT_REDIRECT_308 || (statusCode == FOUND_302 && config.isStrict302Handling());
9496

97+
HttpHeaders responseHeaders = response.headers();
98+
String location = responseHeaders.get(LOCATION);
99+
Uri newUri = Uri.create(future.getUri(), location);
100+
LOGGER.debug("Redirecting to {}", newUri);
101+
102+
boolean sameBase = request.getUri().isSameBase(newUri);
103+
boolean schemeDowngrade = request.getUri().isSecured() && !newUri.isSecured();
104+
boolean stripAuth = !sameBase || schemeDowngrade || stripAuthorizationOnRedirect;
105+
106+
if (stripAuth && (request.getRealm() != null || request.getHeaders().contains(AUTHORIZATION))) {
107+
LOGGER.debug("Stripping credentials on redirect to {}", newUri);
108+
}
109+
95110
final RequestBuilder requestBuilder = new RequestBuilder(switchToGet ? GET : originalMethod)
96111
.setChannelPoolPartitioning(request.getChannelPoolPartitioning())
97112
.setFollowRedirect(true)
98113
.setLocalAddress(request.getLocalAddress())
99114
.setNameResolver(request.getNameResolver())
100115
.setProxyServer(request.getProxyServer())
101-
.setRealm(request.getRealm())
116+
.setRealm(stripAuth ? null : request.getRealm())
102117
.setRequestTimeout(request.getRequestTimeout());
103118

119+
if (stripAuth) {
120+
// Clear both realms on the future so NettyRequestFactory cannot regenerate
121+
// Authorization or Proxy-Authorization headers on the redirected request.
122+
future.setRealm(null);
123+
future.setProxyRealm(null);
124+
}
125+
104126
if (keepBody) {
105127
requestBuilder.setCharset(request.getCharset());
106128
if (isNonEmpty(request.getFormParams()))
@@ -118,18 +140,13 @@ else if (isNonEmpty(request.getBodyParts())) {
118140
}
119141
}
120142

121-
requestBuilder.setHeaders(propagatedHeaders(request, realm, keepBody));
143+
requestBuilder.setHeaders(propagatedHeaders(request, realm, keepBody, stripAuth));
122144

123145
// in case of a redirect from HTTP to HTTPS, future
124146
// attributes might change
125147
final boolean initialConnectionKeepAlive = future.isKeepAlive();
126148
final Object initialPartitionKey = future.getPartitionKey();
127149

128-
HttpHeaders responseHeaders = response.headers();
129-
String location = responseHeaders.get(LOCATION);
130-
Uri newUri = Uri.create(future.getUri(), location);
131-
LOGGER.debug("Redirecting to {}", newUri);
132-
133150
CookieStore cookieStore = config.getCookieStore();
134151
if (cookieStore != null) {
135152
// Update request's cookies assuming that cookie store is already updated by Interceptors
@@ -140,8 +157,6 @@ else if (isNonEmpty(request.getBodyParts())) {
140157
}
141158
}
142159

143-
boolean sameBase = request.getUri().isSameBase(newUri);
144-
145160
if (sameBase) {
146161
// we can only assume the virtual host is still valid if the baseUrl is the same
147162
requestBuilder.setVirtualHost(request.getVirtualHost());
@@ -174,7 +189,7 @@ else if (isNonEmpty(request.getBodyParts())) {
174189
return false;
175190
}
176191

177-
private HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody) {
192+
private HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody, boolean stripAuthorization) {
178193

179194
HttpHeaders headers = request.getHeaders()
180195
.remove(HOST)
@@ -184,7 +199,7 @@ private HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keep
184199
headers.remove(CONTENT_TYPE);
185200
}
186201

187-
if (realm != null && realm.getScheme() == AuthScheme.NTLM) {
202+
if (stripAuthorization || (realm != null && realm.getScheme() == AuthScheme.NTLM)) {
188203
headers.remove(AUTHORIZATION)
189204
.remove(PROXY_AUTHORIZATION);
190205
}

client/src/test/java/org/asynchttpclient/CookieStoreTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@
2828
import org.testng.annotations.BeforeClass;
2929
import org.testng.annotations.Test;
3030

31+
import java.util.Arrays;
3132
import java.util.Collection;
33+
import java.util.HashSet;
3234
import java.util.List;
3335
import java.util.stream.Collectors;
3436

3537
import static org.testng.Assert.assertTrue;
3638

37-
import com.google.common.collect.Sets;
38-
3939
public class CookieStoreTest {
4040

4141
private final Logger logger = LoggerFactory.getLogger(getClass());
@@ -359,7 +359,7 @@ private void shouldCleanExpiredCookieFromUnderlyingDataStructure() throws Except
359359
store.evictExpired();
360360
assertTrue(store.getUnderlying().size() == 2);
361361
Collection<String> unexpiredCookieNames = store.getAll().stream().map(Cookie::name).collect(Collectors.toList());
362-
assertTrue(unexpiredCookieNames.containsAll(Sets.newHashSet("UNEXPIRED_BAR", "UNEXPIRED_FOOBAR")));
362+
assertTrue(unexpiredCookieNames.containsAll(new HashSet<>(Arrays.asList("UNEXPIRED_BAR", "UNEXPIRED_FOOBAR"))));
363363
}
364364

365365
private static Cookie getCookie(String key, String value, int maxAge) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright (c) 2015-2026 AsyncHttpClient Project. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.asynchttpclient;
17+
18+
import org.testng.annotations.Test;
19+
20+
import static org.testng.Assert.assertFalse;
21+
import static org.testng.Assert.assertTrue;
22+
23+
public class DefaultAsyncHttpClientConfigTest {
24+
25+
@Test
26+
public void testStripAuthorizationOnRedirect_DefaultIsFalse() {
27+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder().build();
28+
assertFalse(config.isStripAuthorizationOnRedirect(), "Default should be false");
29+
}
30+
31+
@Test
32+
public void testStripAuthorizationOnRedirect_SetTrue() {
33+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
34+
.setStripAuthorizationOnRedirect(true)
35+
.build();
36+
assertTrue(config.isStripAuthorizationOnRedirect(), "Should be true when set");
37+
}
38+
39+
@Test
40+
public void testStripAuthorizationOnRedirect_SetFalse() {
41+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
42+
.setStripAuthorizationOnRedirect(false)
43+
.build();
44+
assertFalse(config.isStripAuthorizationOnRedirect(), "Should be false when set to false");
45+
}
46+
}

0 commit comments

Comments
 (0)