1616package org .asynchttpclient ;
1717
1818import 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 ;
1922import org .testng .annotations .AfterClass ;
2023import org .testng .annotations .BeforeClass ;
2124import org .testng .annotations .Test ;
2932
3033import static org .asynchttpclient .Dsl .basicAuthRealm ;
3134import static org .testng .Assert .assertEquals ;
35+ import static org .testng .Assert .assertNotNull ;
3236import 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 */
3844public 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}
0 commit comments