@@ -964,6 +964,77 @@ class RedirectStepTest {
964964 assertTrue(target.contains(" x=%26" ), " encoded query value %26 must be preserved: $target " )
965965 }
966966
967+ @Test
968+ fun `stripping userinfo preserves an IPv6 literal host with brackets, port, path, and query` () {
969+ // An IPv6 literal authority carries its host inside square brackets in the URI
970+ // (URI.getHost() returns "[2001:db8::1]"), and the userinfo-stripping rebuild appends
971+ // that bracketed host verbatim. Clearing the userinfo must leave the IPv6 host, its
972+ // port, the path, and the query byte-exact — the brackets in particular must survive.
973+ val fake =
974+ FakeHttpClient ()
975+ .enqueue {
976+ status(302 ).header(
977+ " Location" ,
978+ " https://user:pass@[2001:db8::1]:8443/v2/resource?q=1" ,
979+ )
980+ }.enqueue { status(200 ) }
981+
982+ val pipeline =
983+ HttpPipelineBuilder (fake)
984+ .append(DefaultRedirectStep ())
985+ .build()
986+
987+ val response = pipeline.send(getRequest(" https://api.example.com/v1" ))
988+
989+ // Pin that the redirect was actually followed exactly once, so a regression that skips
990+ // the reissue fails on these assertions rather than an IndexOutOfBoundsException below.
991+ assertEquals(200 , response.status.code)
992+ assertEquals(2 , fake.callCount)
993+
994+ val reissued = fake.requests[1 ]
995+ assertNull(reissued.url.userInfo, " userinfo must be stripped from an IPv6 Location" )
996+ // The bracketed IPv6 literal host and port are preserved exactly.
997+ assertEquals(" [2001:db8::1]" , reissued.url.host, " IPv6 literal host (with brackets) must be preserved" )
998+ assertEquals(8443 , reissued.url.port, " port must be preserved" )
999+ assertEquals(" /v2/resource" , reissued.url.path, " path must be preserved" )
1000+ assertEquals(" q=1" , reissued.url.query, " query must be preserved" )
1001+ // The reissued target is byte-exact apart from the dropped userinfo.
1002+ assertEquals(" https://[2001:db8::1]:8443/v2/resource?q=1" , reissued.url.toString())
1003+ }
1004+
1005+ @Test
1006+ fun `IPv6 literal host without userinfo passes through with brackets preserved` () {
1007+ // The early-return branch (no userinfo to strip) hands the resolved IPv6 URI through
1008+ // unchanged via toURL(); confirm the bracketed host, port, path, and query survive on
1009+ // that non-rebuilding path too.
1010+ val fake =
1011+ FakeHttpClient ()
1012+ .enqueue {
1013+ status(302 ).header(
1014+ " Location" ,
1015+ " https://[2001:db8::1]:8443/v2/resource?q=1" ,
1016+ )
1017+ }.enqueue { status(200 ) }
1018+
1019+ val pipeline =
1020+ HttpPipelineBuilder (fake)
1021+ .append(DefaultRedirectStep ())
1022+ .build()
1023+
1024+ val response = pipeline.send(getRequest(" https://api.example.com/v1" ))
1025+
1026+ assertEquals(200 , response.status.code)
1027+ assertEquals(2 , fake.callCount)
1028+
1029+ val reissued = fake.requests[1 ]
1030+ assertNull(reissued.url.userInfo, " no userinfo was present" )
1031+ assertEquals(" [2001:db8::1]" , reissued.url.host, " IPv6 literal host (with brackets) must be preserved" )
1032+ assertEquals(8443 , reissued.url.port, " port must be preserved" )
1033+ assertEquals(" /v2/resource" , reissued.url.path, " path must be preserved" )
1034+ assertEquals(" q=1" , reissued.url.query, " query must be preserved" )
1035+ assertEquals(" https://[2001:db8::1]:8443/v2/resource?q=1" , reissued.url.toString())
1036+ }
1037+
9671038 // ----------------- Other non-3xx status codes don't trigger redirect -----------------
9681039
9691040 @Test
0 commit comments