Skip to content

Commit 70cab4b

Browse files
committed
Avoid forwarding sensitive headers on cross-origin redirects
1 parent f1dffff commit 70cab4b

6 files changed

Lines changed: 445 additions & 38 deletions

File tree

http-client-core/src/main/java/io/micronaut/http/client/HttpClientConfiguration.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.micronaut.core.annotation.NextMajorVersion;
2121
import io.micronaut.core.annotation.NonNull;
2222
import io.micronaut.core.annotation.Nullable;
23+
import io.micronaut.http.HttpHeaders;
2324
import io.micronaut.core.convert.format.ReadableBytes;
2425
import io.micronaut.core.util.ArgumentUtils;
2526
import io.micronaut.core.util.Toggleable;
@@ -40,11 +41,13 @@
4041
import java.time.temporal.ChronoUnit;
4142
import java.util.Arrays;
4243
import java.util.Collections;
44+
import java.util.LinkedHashSet;
4345
import java.util.List;
4446
import java.util.Map;
4547
import java.util.Objects;
4648
import java.util.Optional;
4749
import java.util.OptionalInt;
50+
import java.util.Set;
4851
import java.util.concurrent.ThreadFactory;
4952

5053
/**
@@ -181,6 +184,23 @@ public abstract class HttpClientConfiguration {
181184

182185
private boolean followRedirects = DEFAULT_FOLLOW_REDIRECTS;
183186

187+
private Set<String> redirectAlwaysFilteredHeaders = new LinkedHashSet<>(Set.of(
188+
HttpHeaders.HOST,
189+
HttpHeaders.CONNECTION
190+
));
191+
192+
private Set<String> redirectAdditionalNonPreserveBodyFilteredHeaders = new LinkedHashSet<>(Set.of(
193+
HttpHeaders.CONTENT_LENGTH,
194+
HttpHeaders.CONTENT_TYPE,
195+
HttpHeaders.TRANSFER_ENCODING
196+
));
197+
198+
private Set<String> redirectCrossOriginFilteredHeaders = new LinkedHashSet<>(Set.of(
199+
HttpHeaders.AUTHORIZATION,
200+
HttpHeaders.PROXY_AUTHORIZATION,
201+
HttpHeaders.COOKIE
202+
));
203+
184204
private int maxRedirects = DEFAULT_MAX_REDIRECTS;
185205

186206
private boolean exceptionOnErrorStatus = DEFAULT_EXCEPTION_ON_ERROR_STATUS;
@@ -244,6 +264,9 @@ public HttpClientConfiguration(HttpClientConfiguration copy) {
244264
this.exceptionOnErrorStatus = copy.exceptionOnErrorStatus;
245265
this.eventLoopGroup = copy.eventLoopGroup;
246266
this.followRedirects = copy.followRedirects;
267+
this.redirectAlwaysFilteredHeaders = copy.redirectAlwaysFilteredHeaders;
268+
this.redirectAdditionalNonPreserveBodyFilteredHeaders = copy.redirectAdditionalNonPreserveBodyFilteredHeaders;
269+
this.redirectCrossOriginFilteredHeaders = copy.redirectCrossOriginFilteredHeaders;
247270
this.maxRedirects = copy.maxRedirects;
248271
this.logLevel = copy.logLevel;
249272
this.loggerName = copy.loggerName;
@@ -431,6 +454,58 @@ public void setFollowRedirects(boolean followRedirects) {
431454
this.followRedirects = followRedirects;
432455
}
433456

457+
/**
458+
* @return Header names that are always filtered for redirects.
459+
* By default this includes {@code Host} and {@code Connection}.
460+
*/
461+
public Set<String> getRedirectAlwaysFilteredHeaders() {
462+
return Collections.unmodifiableSet(redirectAlwaysFilteredHeaders);
463+
}
464+
465+
/**
466+
* Sets the header names that are always filtered for redirects.
467+
*
468+
* @param redirectAlwaysFilteredHeaders The always-filtered redirect header list
469+
*/
470+
public void setRedirectAlwaysFilteredHeaders(Set<String> redirectAlwaysFilteredHeaders) {
471+
this.redirectAlwaysFilteredHeaders = redirectAlwaysFilteredHeaders == null ? new LinkedHashSet<>() : new LinkedHashSet<>(redirectAlwaysFilteredHeaders);
472+
}
473+
474+
/**
475+
* @return Additional header names filtered for redirects that do not preserve the request body.
476+
* These headers are filtered in addition to {@link #getRedirectAlwaysFilteredHeaders()}.
477+
*/
478+
public Set<String> getRedirectAdditionalNonPreserveBodyFilteredHeaders() {
479+
return Collections.unmodifiableSet(redirectAdditionalNonPreserveBodyFilteredHeaders);
480+
}
481+
482+
/**
483+
* Sets the additional header names filtered for redirects that do not preserve the request body.
484+
* These headers are filtered in addition to {@link #getRedirectAlwaysFilteredHeaders()}.
485+
*
486+
* @param redirectAdditionalNonPreserveBodyFilteredHeaders The additional non-preserve-body redirect header filter list
487+
*/
488+
public void setRedirectAdditionalNonPreserveBodyFilteredHeaders(Set<String> redirectAdditionalNonPreserveBodyFilteredHeaders) {
489+
this.redirectAdditionalNonPreserveBodyFilteredHeaders = redirectAdditionalNonPreserveBodyFilteredHeaders == null ? new LinkedHashSet<>() : new LinkedHashSet<>(redirectAdditionalNonPreserveBodyFilteredHeaders);
490+
}
491+
492+
/**
493+
* @return Header names that are additionally filtered for cross-origin redirects.
494+
* By default this includes {@code Authorization}, {@code Proxy-Authorization}, and {@code Cookie}.
495+
*/
496+
public Set<String> getRedirectCrossOriginFilteredHeaders() {
497+
return Collections.unmodifiableSet(redirectCrossOriginFilteredHeaders);
498+
}
499+
500+
/**
501+
* Sets the header names that are additionally filtered for cross-origin redirects.
502+
*
503+
* @param redirectCrossOriginFilteredHeaders The cross-origin redirect header filter list
504+
*/
505+
public void setRedirectCrossOriginFilteredHeaders(Set<String> redirectCrossOriginFilteredHeaders) {
506+
this.redirectCrossOriginFilteredHeaders = redirectCrossOriginFilteredHeaders == null ? new LinkedHashSet<>() : new LinkedHashSet<>(redirectCrossOriginFilteredHeaders);
507+
}
508+
434509
/**
435510
* The maximum number of redirects to follow.
436511
*
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*
2+
* Copyright 2017-2026 original authors
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+
* https://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 io.micronaut.http.client.tck.tests;
17+
18+
import io.micronaut.context.annotation.Requires;
19+
import io.micronaut.http.HttpHeaders;
20+
import io.micronaut.http.HttpRequest;
21+
import io.micronaut.http.HttpResponse;
22+
import io.micronaut.http.HttpStatus;
23+
import io.micronaut.http.MediaType;
24+
import io.micronaut.http.annotation.Consumes;
25+
import io.micronaut.http.annotation.Controller;
26+
import io.micronaut.http.annotation.Get;
27+
import io.micronaut.http.annotation.Post;
28+
import io.micronaut.http.annotation.QueryValue;
29+
import io.micronaut.http.tck.ServerUnderTest;
30+
import io.micronaut.http.tck.ServerUnderTestProviderUtils;
31+
import org.junit.jupiter.params.ParameterizedTest;
32+
import org.junit.jupiter.params.provider.ValueSource;
33+
34+
import java.io.IOException;
35+
import java.net.URI;
36+
import java.util.Collections;
37+
import java.util.LinkedHashMap;
38+
import java.util.Map;
39+
import java.util.TreeMap;
40+
41+
import static io.micronaut.http.tck.TestScenario.asserts;
42+
import static org.junit.jupiter.api.Assertions.assertEquals;
43+
44+
class RedirectHeaderCopyTest {
45+
private static final String SPEC_NAME = "RedirectHeaderCopyTest";
46+
private static final String BLOCKING_CLIENT_PROPERTY = "use.blocking.client";
47+
48+
@ParameterizedTest(name = "blocking={0}")
49+
@ValueSource(booleans = {true, false})
50+
void copiedHeadersForDirectRequest(boolean blocking) throws IOException {
51+
assertHeaders(blocking,
52+
"/redirect/echo-headers",
53+
Map.of(
54+
HttpHeaders.AUTHORIZATION, "Bearer direct",
55+
HttpHeaders.PROXY_AUTHORIZATION, "Basic proxy-direct",
56+
HttpHeaders.COOKIE, "one=direct",
57+
HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_TYPE.toString(),
58+
"X-UnknownHeader", "direct"
59+
),
60+
Map.of(
61+
HttpHeaders.AUTHORIZATION, "Bearer direct",
62+
HttpHeaders.PROXY_AUTHORIZATION, "Basic proxy-direct",
63+
HttpHeaders.COOKIE, "one=direct",
64+
HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_TYPE.toString(),
65+
"X-UnknownHeader", "direct"
66+
));
67+
}
68+
69+
@ParameterizedTest(name = "blocking={0}")
70+
@ValueSource(booleans = {true, false})
71+
void copiedHeadersForSameOriginRedirect(boolean blocking) throws IOException {
72+
assertHeaders(blocking,
73+
"/same-origin-redirect/redirect-to-echo",
74+
Map.of(
75+
HttpHeaders.AUTHORIZATION, "Bearer same-origin",
76+
HttpHeaders.PROXY_AUTHORIZATION, "Basic proxy-same-origin",
77+
HttpHeaders.COOKIE, "one=same-origin",
78+
HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_TYPE.toString(),
79+
"X-UnknownHeader", "same-origin"
80+
),
81+
Map.of(
82+
HttpHeaders.AUTHORIZATION, "Bearer same-origin",
83+
HttpHeaders.PROXY_AUTHORIZATION, "Basic proxy-same-origin",
84+
HttpHeaders.COOKIE, "one=same-origin",
85+
"X-UnknownHeader", "same-origin"
86+
));
87+
}
88+
89+
@ParameterizedTest(name = "blocking={0}")
90+
@ValueSource(booleans = {true, false})
91+
@SuppressWarnings("java:S3655")
92+
void copiedHeadersForCrossOriginRedirect(boolean blocking) throws IOException {
93+
try (ServerUnderTest otherServer = ServerUnderTestProviderUtils.getServerUnderTestProvider().getServer(SPEC_NAME, Collections.singletonMap("redirect.server", "true"))) {
94+
assertHeaders(blocking,
95+
"/cross-origin-redirect/redirect-to-echo?redirect-server-port=" + otherServer.getPort().orElseThrow(),
96+
Map.of(
97+
HttpHeaders.AUTHORIZATION, "Bearer cross-origin",
98+
HttpHeaders.PROXY_AUTHORIZATION, "Basic proxy-cross-origin",
99+
HttpHeaders.COOKIE, "one=cross-origin",
100+
"X-UnknownHeader", "cross-origin"
101+
),
102+
Map.of(
103+
"X-UnknownHeader", "cross-origin"
104+
));
105+
}
106+
}
107+
108+
@ParameterizedTest(name = "blocking={0}")
109+
@ValueSource(booleans = {true, false})
110+
@SuppressWarnings("java:S3655")
111+
void headersAreRetainedForSameOrigin307Redirect(boolean blocking) throws IOException {
112+
assertPostHeaders(blocking,
113+
"/same-origin-redirect/redirect307-to-echo",
114+
Map.of(
115+
HttpHeaders.AUTHORIZATION, "Bearer same-origin-307",
116+
HttpHeaders.PROXY_AUTHORIZATION, "Basic proxy-same-origin-307",
117+
HttpHeaders.COOKIE, "one=same-origin-307",
118+
HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_TYPE.toString(),
119+
"X-UnknownHeader", "same-origin-307"
120+
),
121+
Map.of(
122+
HttpHeaders.AUTHORIZATION, "Bearer same-origin-307",
123+
HttpHeaders.PROXY_AUTHORIZATION, "Basic proxy-same-origin-307",
124+
HttpHeaders.COOKIE, "one=same-origin-307",
125+
HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_TYPE.toString(),
126+
"content-length", "4",
127+
"X-UnknownHeader", "same-origin-307",
128+
"X-Method", "POST"
129+
));
130+
}
131+
132+
@ParameterizedTest(name = "blocking={0}")
133+
@ValueSource(booleans = {true, false})
134+
@SuppressWarnings("java:S3655")
135+
void headersAreRetainedForCrossOrigin307Redirect(boolean blocking) throws IOException {
136+
try (ServerUnderTest otherServer = ServerUnderTestProviderUtils.getServerUnderTestProvider().getServer(SPEC_NAME, Collections.singletonMap("redirect.server", "true"))) {
137+
assertPostHeaders(blocking,
138+
"/cross-origin-redirect/redirect307-to-echo?redirect-server-port=" + otherServer.getPort().orElseThrow(),
139+
Map.of(
140+
HttpHeaders.AUTHORIZATION, "Bearer cross-origin-307",
141+
HttpHeaders.PROXY_AUTHORIZATION, "Basic proxy-cross-origin-307",
142+
HttpHeaders.COOKIE, "one=cross-origin-307",
143+
HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_TYPE.toString(),
144+
"X-UnknownHeader", "cross-origin-307"
145+
),
146+
Map.of(
147+
HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_TYPE.toString(),
148+
"content-length", "4",
149+
"X-UnknownHeader", "cross-origin-307",
150+
"X-Method", "POST"
151+
));
152+
}
153+
}
154+
155+
private void assertHeaders(boolean blocking, String uri, Map<String, String> requestHeaders, Map<String, String> expectedHeaders) throws IOException {
156+
assertHeaders(blocking, uri, requestHeaders, expectedHeaders, null);
157+
}
158+
159+
private void assertHeaders(boolean blocking, String uri, Map<String, String> requestHeaders, Map<String, String> expectedHeaders, Integer redirectPort) throws IOException {
160+
var request = HttpRequest.GET(uri);
161+
requestHeaders.forEach(request::header);
162+
Map<String, Object> properties = redirectPort == null ? Map.of(BLOCKING_CLIENT_PROPERTY, blocking) : Map.of(
163+
BLOCKING_CLIENT_PROPERTY, blocking,
164+
"redirect.server.port", Integer.toString(redirectPort)
165+
);
166+
asserts(SPEC_NAME,
167+
properties,
168+
request,
169+
(server, requestUnderTest) -> {
170+
HttpResponse<Map> response = server.exchange(requestUnderTest, Map.class);
171+
assertEquals(HttpStatus.OK, response.getStatus());
172+
assertEquals(normalizeHeaders(expectedHeaders), normalizeHeaders(response.body()));
173+
}
174+
);
175+
}
176+
177+
private void assertPostHeaders(boolean blocking, String uri, Map<String, String> requestHeaders, Map<String, String> expectedHeaders) throws IOException {
178+
var request = HttpRequest.POST(uri, "body");
179+
requestHeaders.forEach(request::header);
180+
asserts(SPEC_NAME,
181+
Map.of(BLOCKING_CLIENT_PROPERTY, blocking),
182+
request,
183+
(server, requestUnderTest) -> {
184+
HttpResponse<Map> response = server.exchange(requestUnderTest, Map.class);
185+
assertEquals(HttpStatus.OK, response.getStatus());
186+
assertEquals(normalizeHeaders(expectedHeaders), normalizeHeaders(response.body()));
187+
}
188+
);
189+
}
190+
191+
private Map<String, String> normalizeHeaders(Map<?, ?> actualHeaders) {
192+
TreeMap<String, String> normalized = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
193+
actualHeaders.forEach((key, value) -> normalized.put(String.valueOf(key), String.valueOf(value)));
194+
return normalized;
195+
}
196+
197+
@Requires(property = "spec.name", value = SPEC_NAME)
198+
@Controller("/redirect")
199+
@SuppressWarnings("checkstyle:MissingJavadocType")
200+
static class HeaderEchoController {
201+
202+
@Get("/echo-headers")
203+
Map<String, String> echoHeaders(HttpRequest<?> request) {
204+
Map<String, String> headers = new LinkedHashMap<>();
205+
request.getHeaders().forEach((name, values) -> {
206+
if (!HttpHeaders.CONNECTION.equalsIgnoreCase(name)
207+
&& !HttpHeaders.HOST.equalsIgnoreCase(name)
208+
&& !"Upgrade".equalsIgnoreCase(name)
209+
&& !"HTTP2-Settings".equalsIgnoreCase(name)
210+
&& !HttpHeaders.USER_AGENT.equalsIgnoreCase(name)) {
211+
headers.put(name, values.get(0));
212+
}
213+
});
214+
return headers;
215+
}
216+
217+
@Post("/echo-post-headers")
218+
@Consumes(MediaType.ALL)
219+
Map<String, String> echoPostHeaders(HttpRequest<?> request) {
220+
Map<String, String> headers = echoHeaders(request);
221+
headers.put("X-Method", request.getMethodName());
222+
return headers;
223+
}
224+
}
225+
226+
@Requires(property = "spec.name", value = SPEC_NAME)
227+
@Controller("/same-origin-redirect")
228+
@SuppressWarnings("checkstyle:MissingJavadocType")
229+
static class SameOriginRedirectController {
230+
231+
@Get("/redirect-to-echo")
232+
HttpResponse<?> redirectToEcho() {
233+
return HttpResponse.redirect(URI.create("/redirect/echo-headers"));
234+
}
235+
236+
@Post("/redirect307-to-echo")
237+
@Consumes(MediaType.ALL)
238+
HttpResponse<?> redirect307ToEcho() {
239+
return HttpResponse.status(HttpStatus.TEMPORARY_REDIRECT).headers(headers -> headers.location(URI.create("/redirect/echo-post-headers")));
240+
}
241+
}
242+
243+
@Requires(property = "spec.name", value = SPEC_NAME)
244+
@Controller("/cross-origin-redirect")
245+
@SuppressWarnings("checkstyle:MissingJavadocType")
246+
static class CrossOriginRedirectController {
247+
248+
@Get("/redirect-to-echo")
249+
HttpResponse<?> redirectToEcho(@QueryValue("redirect-server-port") String redirectServerPort) {
250+
return HttpResponse.redirect(URI.create("http://localhost:" + redirectServerPort + "/redirect/echo-headers"));
251+
}
252+
253+
@Post("/redirect307-to-echo")
254+
@Consumes(MediaType.ALL)
255+
HttpResponse<?> redirect307ToEcho(@QueryValue("redirect-server-port") String redirectServerPort) {
256+
return HttpResponse.status(HttpStatus.TEMPORARY_REDIRECT)
257+
.headers(headers -> headers.location(URI.create("http://localhost:" + redirectServerPort + "/redirect/echo-post-headers")));
258+
}
259+
}
260+
}

0 commit comments

Comments
 (0)