Skip to content

Commit 5eed339

Browse files
committed
Patch Security Advisory: GHSA-fmxf-pm6p-7xgm
1 parent ae557ad commit 5eed339

17 files changed

Lines changed: 277 additions & 18 deletions

File tree

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.14.5</version>
8+
<version>2.15.0</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.14.5</version>
5+
<version>2.15.0</version>
66
</parent>
77
<modelVersion>4.0.0</modelVersion>
88
<artifactId>async-http-client</artifactId>

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ public boolean exitAfterHandlingRedirect(Channel channel,
103103
boolean schemeDowngrade = request.getUri().isSecured() && !newUri.isSecured();
104104
boolean stripAuth = !sameBase || schemeDowngrade || stripAuthorizationOnRedirect;
105105

106-
if (stripAuth && (request.getRealm() != null || request.getHeaders().contains(AUTHORIZATION))) {
106+
if (stripAuth && (request.getRealm() != null
107+
|| request.getHeaders().contains(AUTHORIZATION)
108+
|| request.getHeaders().contains(COOKIE))) {
107109
LOGGER.debug("Stripping credentials on redirect to {}", newUri);
108110
}
109111

@@ -199,7 +201,13 @@ private HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keep
199201
headers.remove(CONTENT_TYPE);
200202
}
201203

202-
if (stripAuthorization || (realm != null && realm.getScheme() == AuthScheme.NTLM)) {
204+
if (stripAuthorization) {
205+
// Cookie is dropped only on the security boundary; the URI-scoped CookieStore re-adds
206+
// any cookies that legitimately match the new target after this method returns.
207+
headers.remove(AUTHORIZATION)
208+
.remove(PROXY_AUTHORIZATION)
209+
.remove(COOKIE);
210+
} else if (realm != null && realm.getScheme() == AuthScheme.NTLM) {
203211
headers.remove(AUTHORIZATION)
204212
.remove(PROXY_AUTHORIZATION);
205213
}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public class HttpsDowngradeRedirectTest {
4242
private static int httpPort;
4343
private static int httpsPort;
4444
private static final AtomicReference<String> authOnHttpTarget = new AtomicReference<>();
45+
private static final AtomicReference<String> cookieOnHttpTarget = new AtomicReference<>();
4546

4647
@BeforeClass(alwaysRun = true)
4748
public static void setUp() throws Exception {
@@ -58,6 +59,7 @@ public void handle(String target, Request baseRequest, HttpServletRequest reques
5859
response.setHeader("Location", "http://localhost:" + httpPort + "/target");
5960
} else if ("/target".equals(target)) {
6061
authOnHttpTarget.set(request.getHeader("Authorization"));
62+
cookieOnHttpTarget.set(request.getHeader("Cookie"));
6163
response.setStatus(200);
6264
}
6365
baseRequest.setHandled(true);
@@ -95,4 +97,26 @@ public void httpsToHttpDowngradeStripsAuthorization() throws Exception {
9597
"Authorization header must be stripped when redirect downgrades HTTPS to HTTP");
9698
}
9799
}
100+
101+
/**
102+
* HTTPS-to-HTTP downgrade also strips the Cookie header. Regression test for GHSA-fmxf-pm6p-7xgm.
103+
*/
104+
@Test
105+
public void httpsToHttpDowngradeStripsCookie() throws Exception {
106+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
107+
.setFollowRedirect(true)
108+
.setUseInsecureTrustManager(true)
109+
.build();
110+
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
111+
cookieOnHttpTarget.set(null);
112+
113+
client.prepareGet("https://localhost:" + httpsPort + "/redirect")
114+
.setHeader("Cookie", "session=secret-session")
115+
.execute()
116+
.get(10, TimeUnit.SECONDS);
117+
118+
assertNull(cookieOnHttpTarget.get(),
119+
"Cookie header must be stripped when redirect downgrades HTTPS to HTTP");
120+
}
121+
}
98122
}

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

Lines changed: 228 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
package org.asynchttpclient;
1717

1818
import com.sun.net.httpserver.HttpServer;
19+
import io.netty.handler.codec.http.cookie.DefaultCookie;
20+
import org.asynchttpclient.cookie.ThreadSafeCookieStore;
21+
import org.asynchttpclient.uri.Uri;
1922
import org.testng.annotations.AfterClass;
2023
import org.testng.annotations.BeforeClass;
2124
import org.testng.annotations.Test;
@@ -29,11 +32,14 @@
2932

3033
import static org.asynchttpclient.Dsl.basicAuthRealm;
3134
import static org.testng.Assert.assertEquals;
35+
import static org.testng.Assert.assertNotNull;
3236
import static org.testng.Assert.assertNull;
37+
import static org.testng.Assert.assertTrue;
3338

3439
/**
3540
* Tests for credential stripping on cross-domain redirects and HTTPS-to-HTTP downgrades.
36-
* Verifies that Authorization headers and Realm credentials are not leaked to different origins.
41+
* Verifies that Authorization headers, Cookie headers, and Realm credentials are not leaked
42+
* to different origins.
3743
*/
3844
public class RedirectCredentialSecurityTest {
3945

@@ -53,6 +59,10 @@ public class RedirectCredentialSecurityTest {
5359
private static final AtomicReference<String> bodyOn308Target = new AtomicReference<>();
5460
private static final AtomicReference<String> proxyAuthOnB = new AtomicReference<>();
5561
private static final AtomicReference<String> authOnHttpsDowngradeTarget = new AtomicReference<>();
62+
private static final AtomicReference<String> lastCookieHeaderOnA = new AtomicReference<>();
63+
private static final AtomicReference<String> lastCookieHeaderOnB = new AtomicReference<>();
64+
private static final AtomicReference<String> cookieAtChainStep2 = new AtomicReference<>();
65+
private static final AtomicReference<String> cookieOnBounceBack = new AtomicReference<>();
5666

5767
@BeforeClass
5868
public static void startServers() throws Exception {
@@ -71,20 +81,23 @@ public static void startServers() throws Exception {
7181
// Server A endpoints
7282
serverA.createContext("/redirect-to-b", exchange -> {
7383
lastAuthHeaderOnA.set(exchange.getRequestHeaders().getFirst("Authorization"));
84+
lastCookieHeaderOnA.set(exchange.getRequestHeaders().getFirst("Cookie"));
7485
exchange.getResponseHeaders().add("Location", "http://127.0.0.1:" + portB + "/target");
7586
exchange.sendResponseHeaders(302, -1);
7687
exchange.close();
7788
});
7889

7990
serverA.createContext("/redirect-same-origin", exchange -> {
8091
lastAuthHeaderOnA.set(exchange.getRequestHeaders().getFirst("Authorization"));
92+
lastCookieHeaderOnA.set(exchange.getRequestHeaders().getFirst("Cookie"));
8193
exchange.getResponseHeaders().add("Location", "http://127.0.0.1:" + portA + "/final");
8294
exchange.sendResponseHeaders(302, -1);
8395
exchange.close();
8496
});
8597

8698
serverA.createContext("/final", exchange -> {
8799
lastAuthHeaderOnA.set(exchange.getRequestHeaders().getFirst("Authorization"));
100+
lastCookieHeaderOnA.set(exchange.getRequestHeaders().getFirst("Cookie"));
88101
exchange.sendResponseHeaders(200, 0);
89102
exchange.getResponseBody().close();
90103
exchange.close();
@@ -93,6 +106,7 @@ public static void startServers() throws Exception {
93106
// Server B endpoints
94107
serverB.createContext("/target", exchange -> {
95108
lastAuthHeaderOnB.set(exchange.getRequestHeaders().getFirst("Authorization"));
109+
lastCookieHeaderOnB.set(exchange.getRequestHeaders().getFirst("Cookie"));
96110
exchange.sendResponseHeaders(200, 0);
97111
exchange.getResponseBody().close();
98112
exchange.close();
@@ -107,6 +121,7 @@ public static void startServers() throws Exception {
107121

108122
serverA.createContext("/chain-step2", exchange -> {
109123
authAtChainStep2.set(exchange.getRequestHeaders().getFirst("Authorization"));
124+
cookieAtChainStep2.set(exchange.getRequestHeaders().getFirst("Cookie"));
110125
exchange.getResponseHeaders().add("Location", "http://127.0.0.1:" + portB + "/target");
111126
exchange.sendResponseHeaders(302, -1);
112127
exchange.close();
@@ -127,6 +142,7 @@ public static void startServers() throws Exception {
127142

128143
serverC.createContext("/chain-final", exchange -> {
129144
authOnBounceBack.set(exchange.getRequestHeaders().getFirst("Authorization"));
145+
cookieOnBounceBack.set(exchange.getRequestHeaders().getFirst("Cookie"));
130146
exchange.sendResponseHeaders(200, 0);
131147
exchange.getResponseBody().close();
132148
exchange.close();
@@ -438,4 +454,215 @@ public void redirect308CrossDomainStripsAuthButPreservesBody() throws Exception
438454
"Request body must be preserved on 308 redirect");
439455
}
440456
}
457+
458+
/**
459+
* Cross-domain redirect (different port) must strip a user-supplied Cookie header.
460+
* Regression test for GHSA-fmxf-pm6p-7xgm.
461+
*/
462+
@Test
463+
public void crossDomainRedirectStripsCookieHeader() throws Exception {
464+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
465+
.setFollowRedirect(true)
466+
.build();
467+
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
468+
lastCookieHeaderOnA.set(null);
469+
lastCookieHeaderOnB.set(null);
470+
471+
client.prepareGet("http://127.0.0.1:" + portA + "/redirect-to-b")
472+
.setHeader("Cookie", "session=abc123; csrf=xyz789")
473+
.execute()
474+
.get(5, TimeUnit.SECONDS);
475+
476+
// Cookie should be present on the original request to server A
477+
assertEquals(lastCookieHeaderOnA.get(), "session=abc123; csrf=xyz789",
478+
"Cookie header should be present on original request");
479+
// Cookie must NOT be forwarded to the cross-domain target (server B)
480+
assertNull(lastCookieHeaderOnB.get(),
481+
"Cookie header must be stripped on cross-domain redirect");
482+
}
483+
}
484+
485+
/**
486+
* Same-origin redirect (same host and port) should preserve the Cookie header.
487+
*/
488+
@Test
489+
public void sameOriginRedirectPreservesCookieHeader() throws Exception {
490+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
491+
.setFollowRedirect(true)
492+
.build();
493+
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
494+
lastCookieHeaderOnA.set(null);
495+
496+
client.prepareGet("http://127.0.0.1:" + portA + "/redirect-same-origin")
497+
.setHeader("Cookie", "session=abc123")
498+
.execute()
499+
.get(5, TimeUnit.SECONDS);
500+
501+
assertEquals(lastCookieHeaderOnA.get(), "session=abc123",
502+
"Cookie header should be preserved on same-origin redirect");
503+
}
504+
}
505+
506+
/**
507+
* Cross-domain redirect must strip both Authorization and Cookie when both are set.
508+
* Combined regression that mirrors the original PoC.
509+
*/
510+
@Test
511+
public void crossDomainRedirectStripsBothCookieAndAuthorization() throws Exception {
512+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
513+
.setFollowRedirect(true)
514+
.build();
515+
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
516+
lastAuthHeaderOnA.set(null);
517+
lastAuthHeaderOnB.set(null);
518+
lastCookieHeaderOnA.set(null);
519+
lastCookieHeaderOnB.set(null);
520+
521+
client.prepareGet("http://127.0.0.1:" + portA + "/redirect-to-b")
522+
.setHeader("Authorization", "Bearer token123")
523+
.setHeader("Cookie", "session=abc123; api_key=secret")
524+
.execute()
525+
.get(5, TimeUnit.SECONDS);
526+
527+
assertEquals(lastAuthHeaderOnA.get(), "Bearer token123",
528+
"Authorization header should be present on original request");
529+
assertEquals(lastCookieHeaderOnA.get(), "session=abc123; api_key=secret",
530+
"Cookie header should be present on original request");
531+
assertNull(lastAuthHeaderOnB.get(),
532+
"Authorization header must be stripped on cross-domain redirect");
533+
assertNull(lastCookieHeaderOnB.get(),
534+
"Cookie header must be stripped on cross-domain redirect");
535+
}
536+
}
537+
538+
/**
539+
* Multi-hop: A -> A (same-origin, Cookie preserved) -> B (cross-domain, Cookie stripped).
540+
*/
541+
@Test
542+
public void multiHopChainStripsCookieAtFirstCrossOriginHop() throws Exception {
543+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
544+
.setFollowRedirect(true)
545+
.build();
546+
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
547+
cookieAtChainStep2.set(null);
548+
lastCookieHeaderOnB.set(null);
549+
550+
client.prepareGet("http://127.0.0.1:" + portA + "/chain-same-then-cross")
551+
.setHeader("Cookie", "session=abc123")
552+
.execute()
553+
.get(5, TimeUnit.SECONDS);
554+
555+
// Cookie should survive the same-origin intermediate hop (A -> A)
556+
assertEquals(cookieAtChainStep2.get(), "session=abc123",
557+
"Cookie header should be preserved on same-origin intermediate redirect");
558+
// Cookie must be stripped on the cross-domain hop (A -> B)
559+
assertNull(lastCookieHeaderOnB.get(),
560+
"Cookie header must be stripped on cross-domain hop in redirect chain");
561+
}
562+
}
563+
564+
/**
565+
* Once Cookie is stripped at a cross-domain hop, it must not reappear on subsequent hops.
566+
*/
567+
@Test
568+
public void multiHopCookieStaysStrippedAfterCrossDomain() throws Exception {
569+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
570+
.setFollowRedirect(true)
571+
.build();
572+
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
573+
cookieOnBounceBack.set(null);
574+
575+
client.prepareGet("http://127.0.0.1:" + portA + "/chain-cross-and-back")
576+
.setHeader("Cookie", "session=abc123")
577+
.execute()
578+
.get(5, TimeUnit.SECONDS);
579+
580+
assertNull(cookieOnBounceBack.get(),
581+
"Cookie must not reappear after being stripped at a cross-domain hop");
582+
}
583+
}
584+
585+
/**
586+
* setStripAuthorizationOnRedirect(true) also strips Cookie on same-origin redirects:
587+
* the conditions are coupled, so users opting into strict credential stripping get cookie
588+
* stripping on the same-origin path too.
589+
*/
590+
@Test
591+
public void stripAuthorizationOnRedirectFlagAlsoStripsCookie() throws Exception {
592+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
593+
.setFollowRedirect(true)
594+
.setStripAuthorizationOnRedirect(true)
595+
.build();
596+
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
597+
lastCookieHeaderOnA.set(null);
598+
599+
client.prepareGet("http://127.0.0.1:" + portA + "/redirect-same-origin")
600+
.setHeader("Cookie", "session=abc123")
601+
.execute()
602+
.get(5, TimeUnit.SECONDS);
603+
604+
// With stripAuthorizationOnRedirect=true, even same-origin redirects strip the Cookie
605+
assertNull(lastCookieHeaderOnA.get(),
606+
"stripAuthorizationOnRedirect=true must also strip Cookie on same-origin redirect");
607+
}
608+
}
609+
610+
/**
611+
* Regression: a cookie added to the URI-scoped CookieStore for server B is still delivered
612+
* to server B after a cross-origin redirect from A -> B. Cookie stripping must not break the
613+
* legitimate cookie-store flow (store cookies are added after the strip step in
614+
* Redirect30xInterceptor and are URI-matched).
615+
*/
616+
@Test
617+
public void cookieStoreManagedCookiesUnaffectedByStrip() throws Exception {
618+
ThreadSafeCookieStore store = new ThreadSafeCookieStore();
619+
store.add(Uri.create("http://127.0.0.1:" + portB + "/target"),
620+
new DefaultCookie("store_cookie", "store_value"));
621+
622+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
623+
.setFollowRedirect(true)
624+
.setCookieStore(store)
625+
.build();
626+
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
627+
lastCookieHeaderOnB.set(null);
628+
629+
client.prepareGet("http://127.0.0.1:" + portA + "/redirect-to-b")
630+
.execute()
631+
.get(5, TimeUnit.SECONDS);
632+
633+
// The store cookie scoped to server B's URI should reach server B
634+
assertNotNull(lastCookieHeaderOnB.get(),
635+
"URI-scoped CookieStore cookies should be delivered after cross-domain redirect");
636+
assertTrue(lastCookieHeaderOnB.get().contains("store_cookie=store_value"),
637+
"Expected store cookie in Cookie header, got: " + lastCookieHeaderOnB.get());
638+
}
639+
}
640+
641+
/**
642+
* Same host (127.0.0.1) on a different port is treated as cross-origin: both Cookie and
643+
* Authorization are stripped. Locks in the port-as-part-of-origin behavior so that a future
644+
* change to {@link Uri#isSameBase} cannot accidentally narrow the origin to host-only.
645+
*/
646+
@Test
647+
public void portChangeOnSameHostIsTreatedAsCrossOrigin() throws Exception {
648+
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
649+
.setFollowRedirect(true)
650+
.build();
651+
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
652+
lastAuthHeaderOnB.set(null);
653+
lastCookieHeaderOnB.set(null);
654+
655+
// /redirect-to-b: 127.0.0.1:portA -> 127.0.0.1:portB. Same host, different port.
656+
client.prepareGet("http://127.0.0.1:" + portA + "/redirect-to-b")
657+
.setHeader("Authorization", "Bearer same-host-token")
658+
.setHeader("Cookie", "session=same-host-cookie")
659+
.execute()
660+
.get(5, TimeUnit.SECONDS);
661+
662+
assertNull(lastAuthHeaderOnB.get(),
663+
"Authorization must be stripped when only the port differs (origin includes port)");
664+
assertNull(lastCookieHeaderOnB.get(),
665+
"Cookie must be stripped when only the port differs (origin includes port)");
666+
}
667+
}
441668
}

example/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.14.5</version>
5+
<version>2.15.0</version>
66
</parent>
77
<modelVersion>4.0.0</modelVersion>
88
<artifactId>async-http-client-example</artifactId>

0 commit comments

Comments
 (0)