Skip to content

Commit f92da40

Browse files
committed
GROOVY-12274: HttpBuilder: drop default headers on a redirect to another origin
With followRedirects and no confinement, redirect following was delegated to the JDK client, which re-issued the request to the Location target carrying the headers configured on the builder. Those headers are applied to every request, so a token or key among them was sent to whatever origin a redirect named. The platform cannot be relied on for this. Measured against a cross-host redirect, on a chain that ends at a server which records what it received: JDK 17.0.20 JDK 21.0.6 JDK 23.0.2 (2026-07) (2025-01) (2025-01) Authorization stripped forwarded forwarded Cookie stripped forwarded forwarded Proxy-Authorization stripped stripped stripped X-Api-Key forwarded forwarded forwarded Two things follow. Whether the well known credential headers are protected depends on the update level of the JDK in use, which an application cannot choose. And a header the platform does not recognise is forwarded on every JDK, while the builder's headers may hold anything the caller put there, so a policy naming header names would repeat the same mistake at one remove. Follow redirects here in every case rather than only under confinement, and drop the caller's headers for good once a hop leaves the origin the request started from. Same-origin redirects are unaffected, as is confinement, which already rejected a cross-origin hop outright. A chain which returns to the original origin does not get the headers back, since by then they have been seen by another server. Note this drops all of the caller's headers rather than a chosen few: which of them carry credentials is not something this class can know.
1 parent 389ab8c commit f92da40

2 files changed

Lines changed: 219 additions & 21 deletions

File tree

subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,21 @@ final class HttpBuilder {
5454
if (config.connectTimeout != null) {
5555
clientBuilder.connectTimeout(config.connectTimeout)
5656
}
57-
// When confinement is active we must see every 3xx ourselves so the base-URI
58-
// gate can be applied to each hop; the JDK client would otherwise follow
59-
// redirects internally, escaping confinement. Only the unconfined case
60-
// delegates redirect-following to the JDK.
61-
followRedirectsManually = config.confineToBaseUri && config.followRedirects
62-
if (config.followRedirects && !followRedirectsManually) {
63-
clientBuilder.followRedirects(HttpClient.Redirect.NORMAL)
64-
}
57+
// Every 3xx is seen here rather than followed inside the JDK client, so that each hop
58+
// can be gated: a confined hop against the base URI, and any hop against the origin the
59+
// caller's headers were set for.
60+
followRedirectsManually = config.followRedirects
6561
if (config.clientConfigurer != null) {
6662
Closure<?> code = (Closure<?>) config.clientConfigurer.clone()
6763
code.resolveStrategy = Closure.DELEGATE_FIRST
6864
code.delegate = clientBuilder
6965
code.call(clientBuilder)
7066
}
67+
// Redirect policy is owned by the followRedirects flag above. A redirect policy set on
68+
// the raw builder (via clientConfig) would make the JDK client follow hops internally,
69+
// where no hop can be gated and the caller's headers travel to whatever origin a
70+
// Location names — so any such setting is overridden, after the configurer has run.
71+
clientBuilder.followRedirects(HttpClient.Redirect.NEVER)
7172
client = clientBuilder.build()
7273
baseUri = config.baseUri
7374
defaultHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(config.headers))
@@ -217,7 +218,8 @@ final class HttpBuilder {
217218
if (followRedirectsManually) {
218219
future = future.thenCompose { HttpResponse<String> response ->
219220
followRedirectsAsync(method, httpRequest.uri(), response, headers,
220-
requestSpec.body, requestSpec.timeout, requestSpec.bodyHandler, 0)
221+
requestSpec.body, requestSpec.timeout, requestSpec.bodyHandler, 0,
222+
httpRequest.uri(), false)
221223
}
222224
}
223225
return future.thenApply { HttpResponse<String> response -> new HttpResult(response) }
@@ -339,10 +341,10 @@ final class HttpBuilder {
339341
}
340342

341343
private HttpRequest buildStreamRequest(final String method, final Object uri, final Closure<?> spec) {
342-
// Note: streaming does not auto-follow redirects under confinement. Because
343-
// the body is an unbuffered publisher, a 3xx is returned to the caller as-is
344-
// rather than followed. This is safe (no bypass) but not transparent; callers
345-
// who need confined streaming redirects should resolve the Location themselves.
344+
// Note: streaming never auto-follows redirects. Because the response body is an
345+
// unbuffered publisher, a 3xx is returned to the caller as-is rather than followed;
346+
// callers who need streaming redirects should resolve the Location themselves. This
347+
// also means no hop can escape the gates above, at the cost of transparency.
346348
RequestSpec requestSpec = evalSpec(spec)
347349
URI resolvedUri = resolveUri(uri, requestSpec.queryParameters)
348350
return buildHopRequest(method, resolvedUri, combinedHeaders(requestSpec), requestSpec.body, requestSpec.timeout)
@@ -393,27 +395,32 @@ final class HttpBuilder {
393395
}
394396

395397
/**
396-
* Synchronously follows redirects while confinement is active, applying
397-
* {@link #enforceConfinement} to every hop. Because a confined hop must share
398-
* the base URI's origin, a redirect to another host is rejected outright — so
399-
* sensitive headers can never leak across origins here.
398+
* Synchronously follows redirects, applying {@link #enforceConfinement} to every hop while
399+
* confinement is active, and shedding the caller's headers on any hop that leaves the
400+
* origin the request started from.
400401
*/
401402
private HttpResponse<String> followRedirects(String method, URI currentUri, HttpResponse<String> response,
402403
Map<String, String> headers, Object body,
403404
Duration timeout, HttpResponse.BodyHandler<String> bodyHandler) {
405+
URI origin = currentUri
406+
boolean leftOrigin = false
404407
int redirects = 0
405408
while (true) {
406409
URI target = redirectTarget(currentUri, response)
407410
if (target == null) {
408411
return response
409412
}
410413
if (++redirects > MAX_REDIRECTS) {
411-
throw new RuntimeException("Too many redirects (> " + MAX_REDIRECTS + ") for request confined to " + baseUri)
414+
throw new RuntimeException("Too many redirects (> " + MAX_REDIRECTS + ") for request to " + origin)
412415
}
413416
String nextMethod = redirectMethod(method, response.statusCode())
414417
boolean sameMethod = nextMethod == method
415418
Object nextBody = sameMethod ? body : null
416419
Map<String, String> nextHeaders = sameMethod ? headers : withoutBodyHeaders(headers)
420+
leftOrigin = leftOrigin || !sameOrigin(origin, target)
421+
if (leftOrigin) {
422+
nextHeaders = [:]
423+
}
417424
HttpRequest httpRequest = buildHopRequest(nextMethod, target, nextHeaders, nextBody, timeout)
418425
response = send(nextMethod, httpRequest, bodyHandler)
419426
currentUri = target
@@ -431,24 +438,29 @@ final class HttpBuilder {
431438
private CompletableFuture<HttpResponse<String>> followRedirectsAsync(
432439
String method, URI currentUri, HttpResponse<String> response,
433440
Map<String, String> headers, Object body, Duration timeout,
434-
HttpResponse.BodyHandler<String> bodyHandler, int redirects) {
441+
HttpResponse.BodyHandler<String> bodyHandler, int redirects, URI origin, boolean leftOrigin) {
435442
URI target = redirectTarget(currentUri, response)
436443
if (target == null) {
437444
return CompletableFuture.completedFuture(response)
438445
}
439446
if (redirects + 1 > MAX_REDIRECTS) {
440447
CompletableFuture<HttpResponse<String>> failed = new CompletableFuture<>()
441448
failed.completeExceptionally(
442-
new RuntimeException("Too many redirects (> " + MAX_REDIRECTS + ") for request confined to " + baseUri))
449+
new RuntimeException("Too many redirects (> " + MAX_REDIRECTS + ") for request to " + origin))
443450
return failed
444451
}
445452
String nextMethod = redirectMethod(method, response.statusCode())
446453
boolean sameMethod = nextMethod == method
447454
Object nextBody = sameMethod ? body : null
448455
Map<String, String> nextHeaders = sameMethod ? headers : withoutBodyHeaders(headers)
456+
boolean nextLeftOrigin = leftOrigin || !sameOrigin(origin, target)
457+
if (nextLeftOrigin) {
458+
nextHeaders = [:]
459+
}
449460
HttpRequest httpRequest = buildHopRequest(nextMethod, target, nextHeaders, nextBody, timeout)
450461
return client.sendAsync(httpRequest, bodyHandler).thenCompose { HttpResponse<String> next ->
451-
followRedirectsAsync(nextMethod, target, next, nextHeaders, nextBody, timeout, bodyHandler, redirects + 1)
462+
followRedirectsAsync(nextMethod, target, next, nextHeaders, nextBody, timeout, bodyHandler,
463+
redirects + 1, origin, nextLeftOrigin)
452464
}
453465
}
454466

@@ -784,6 +796,10 @@ final class HttpBuilder {
784796
/**
785797
* Provides direct access to the underlying {@code HttpClient.Builder}
786798
* for advanced configuration (authenticator, SSL context, proxy, cookie handler, etc.).
799+
* <p>
800+
* Redirect policy is the exception: it is owned by {@link #followRedirects} so that
801+
* every hop can be gated against confinement and against the origin the caller's
802+
* headers were set for. A redirect policy set on the raw builder here is overridden.
787803
*
788804
* @param configurer a closure taking an {@code HttpClient.Builder}
789805
*/
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package groovy.http
20+
21+
import com.sun.net.httpserver.HttpExchange
22+
import com.sun.net.httpserver.HttpServer
23+
24+
import java.net.http.HttpClient
25+
import java.util.concurrent.ConcurrentHashMap
26+
import java.util.concurrent.atomic.AtomicInteger
27+
import org.junit.jupiter.api.AfterAll
28+
import org.junit.jupiter.api.BeforeAll
29+
import org.junit.jupiter.api.BeforeEach
30+
import org.junit.jupiter.api.Test
31+
32+
/**
33+
* GROOVY-12274: headers the caller configured for one origin must not be carried to another by
34+
* following a redirect. The JDK client cannot be relied on for this. It protects only the header
35+
* names it knows about, so a credential in any other header is forwarded regardless; and whether
36+
* it protects even those depends on the update level of the JDK in use.
37+
*/
38+
class HttpBuilderRedirectHeaderTest {
39+
40+
static HttpServer origin
41+
static HttpServer elsewhere
42+
static int originPort
43+
static int elsewherePort
44+
/** Headers seen by whichever endpoint served the final hop; written on server threads. */
45+
static final Map<String, String> received = new ConcurrentHashMap<>()
46+
/** Counts final-hop arrivals, so a negative assertion cannot pass by the hop never happening. */
47+
static final AtomicInteger arrivals = new AtomicInteger()
48+
49+
@BeforeAll
50+
static void setUpClass() {
51+
origin = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0)
52+
originPort = origin.address.port
53+
elsewhere = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0)
54+
elsewherePort = elsewhere.address.port
55+
56+
// The redirect target is passed as the query so one handler serves every case.
57+
origin.createContext('/redirect') { HttpExchange exchange ->
58+
exchange.responseHeaders.add('Location', exchange.requestURI.query)
59+
exchange.sendResponseHeaders(302, -1)
60+
exchange.close()
61+
}
62+
[origin, elsewhere].each { server ->
63+
server.createContext('/target') { HttpExchange exchange ->
64+
arrivals.incrementAndGet()
65+
record(exchange)
66+
byte[] body = 'ok'.bytes
67+
exchange.sendResponseHeaders(200, body.length)
68+
exchange.responseBody.withStream { it.write(body) }
69+
}
70+
}
71+
// Second hop for the return-to-origin case.
72+
elsewhere.createContext('/bounce') { HttpExchange exchange ->
73+
exchange.responseHeaders.add('Location', URLDecoder.decode(exchange.requestURI.query, 'UTF-8'))
74+
exchange.sendResponseHeaders(302, -1)
75+
exchange.close()
76+
}
77+
origin.start()
78+
elsewhere.start()
79+
}
80+
81+
@AfterAll
82+
static void tearDownClass() {
83+
origin?.stop(0)
84+
elsewhere?.stop(0)
85+
}
86+
87+
@BeforeEach
88+
void setUp() {
89+
received.clear()
90+
arrivals.set(0)
91+
}
92+
93+
private static void record(HttpExchange exchange) {
94+
['Authorization', 'Cookie', 'X-Api-Key', 'Accept'].each { name ->
95+
def values = exchange.requestHeaders.get(name)
96+
if (values) received.put(name, values.first())
97+
}
98+
}
99+
100+
private static HttpBuilder builderWithHeaders() {
101+
HttpBuilder.http {
102+
baseUri "http://127.0.0.1:${originPort}"
103+
followRedirects true
104+
headers([
105+
'Authorization': 'Bearer SECRET-TOKEN',
106+
'Cookie' : 'session=SECRET-COOKIE',
107+
'X-Api-Key' : 'SECRET-KEY',
108+
'Accept' : 'text/plain',
109+
])
110+
}
111+
}
112+
113+
@Test
114+
void testHeadersAreNotCarriedToAnotherOrigin() {
115+
builderWithHeaders().get("/redirect?http://127.0.0.1:${elsewherePort}/target")
116+
117+
assert arrivals.get() == 1, 'the redirect was not followed, so the test proves nothing'
118+
assert received.isEmpty(),
119+
"a redirect to another origin received ${received.keySet()}"
120+
}
121+
122+
@Test
123+
void testCustomHeaderIsNotCarriedEither() {
124+
// The header the JDK never protects, on any update level: the whole reason this is not
125+
// left to the platform.
126+
builderWithHeaders().get("/redirect?http://127.0.0.1:${elsewherePort}/target")
127+
128+
assert arrivals.get() == 1, 'the redirect was not followed, so the test proves nothing'
129+
assert received['X-Api-Key'] == null
130+
}
131+
132+
@Test
133+
void testHeadersSurviveARedirectWithinTheSameOrigin() {
134+
builderWithHeaders().get("/redirect?http://127.0.0.1:${originPort}/target")
135+
136+
assert received['Authorization'] == 'Bearer SECRET-TOKEN'
137+
assert received['X-Api-Key'] == 'SECRET-KEY'
138+
assert received['Accept'] == 'text/plain'
139+
}
140+
141+
@Test
142+
void testHeadersAreNotCarriedToAnotherOriginAsynchronously() {
143+
builderWithHeaders()
144+
.requestAsync('GET', "/redirect?http://127.0.0.1:${elsewherePort}/target")
145+
.join()
146+
147+
assert arrivals.get() == 1, 'the redirect was not followed, so the test proves nothing'
148+
assert received.isEmpty(),
149+
"an asynchronous redirect to another origin received ${received.keySet()}"
150+
}
151+
152+
@Test
153+
void testClientConfigCannotHandRedirectsBackToTheJdkClient() {
154+
// A redirect policy set on the raw builder would make the JDK client follow hops
155+
// internally, bypassing the header shedding; it is overridden, so the manual loop
156+
// still follows the redirect and the other origin still sees no caller headers.
157+
def builder = HttpBuilder.http {
158+
baseUri "http://127.0.0.1:${originPort}"
159+
followRedirects true
160+
// X-Api-Key is the discriminating probe: a JDK-followed hop forwards it on every
161+
// JDK version, so this test fails without the override no matter the platform
162+
headers(['Authorization': 'Bearer SECRET-TOKEN', 'X-Api-Key': 'SECRET-KEY'])
163+
clientConfig { it.followRedirects(HttpClient.Redirect.NORMAL) }
164+
}
165+
builder.get("/redirect?http://127.0.0.1:${elsewherePort}/target")
166+
167+
assert arrivals.get() == 1, 'the redirect was not followed, so the test proves nothing'
168+
assert received.isEmpty(),
169+
"a redirect to another origin received ${received.keySet()}"
170+
}
171+
172+
@Test
173+
void testHeadersAreNotRestoredByReturningToTheOrigin() {
174+
// Once a chain has left the origin the caller's headers are gone for good; a hop back
175+
// does not bring them out again.
176+
String back = URLEncoder.encode("http://127.0.0.1:${originPort}/target", 'UTF-8')
177+
builderWithHeaders().get("/redirect?http://127.0.0.1:${elsewherePort}/bounce?${back}")
178+
179+
assert arrivals.get() == 1, 'the chain did not reach the origin again, so the test proves nothing'
180+
assert received['Authorization'] == null
181+
}
182+
}

0 commit comments

Comments
 (0)