Skip to content

Commit ef77b40

Browse files
committed
Keep DefaultServerHttpRequestBuilder-mutated headers case-insensitive
This change avoids the trap of creating a copy of `HttpHeaders` using a case-sensitive `MultiValueMap` by mistake. Since mutability is always desirable, we make a mutable copy by using `addAll` on an empty `HttpHeaders`. We can't simply rely on HttpHeaders' map-based constructor to detect read-only header in this particular case, because the container's original headers representation might in some cases be read-only. Closes gh-33666
1 parent 59ef5e1 commit ef77b40

File tree

2 files changed

+123
-3
lines changed

2 files changed

+123
-3
lines changed

spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
import org.springframework.http.HttpMethod;
3131
import org.springframework.lang.Nullable;
3232
import org.springframework.util.Assert;
33-
import org.springframework.util.LinkedMultiValueMap;
3433
import org.springframework.util.MultiValueMap;
3534
import org.springframework.util.StringUtils;
3635

@@ -71,8 +70,12 @@ public DefaultServerHttpRequestBuilder(ServerHttpRequest original) {
7170
Assert.notNull(original, "ServerHttpRequest is required");
7271

7372
this.uri = original.getURI();
74-
// original headers can be immutable, so create a copy
75-
this.headers = new HttpHeaders(new LinkedMultiValueMap<>(original.getHeaders()));
73+
// Some containers (including Jetty and Netty4) can have an immutable
74+
// representation of headers. Since mutability is always desirable here,
75+
// we always create a mutable case-insensitive copy of the original
76+
// headers by using the basic constructor and addAll.
77+
this.headers = new HttpHeaders();
78+
this.headers.addAll(original.getHeaders());
7679
this.httpMethod = original.getMethod();
7780
this.contextPath = original.getPath().contextPath().value();
7881
this.remoteAddress = original.getRemoteAddress();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright 2002-2024 the original author or 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+
17+
package org.springframework.http.server.reactive;
18+
19+
import java.util.Locale;
20+
import java.util.stream.Stream;
21+
22+
import io.netty.handler.codec.http.DefaultHttpHeaders;
23+
import io.netty.handler.codec.http.ReadOnlyHttpHeaders;
24+
import io.undertow.util.HeaderMap;
25+
import org.apache.tomcat.util.http.MimeHeaders;
26+
import org.eclipse.jetty.http.HttpFields;
27+
import org.junit.jupiter.params.ParameterizedTest;
28+
import org.junit.jupiter.params.provider.Arguments;
29+
import org.junit.jupiter.params.provider.MethodSource;
30+
import org.mockito.BDDMockito;
31+
32+
import org.springframework.http.HttpHeaders;
33+
import org.springframework.http.support.JettyHeadersAdapter;
34+
import org.springframework.http.support.Netty4HeadersAdapter;
35+
import org.springframework.http.support.Netty5HeadersAdapter;
36+
import org.springframework.util.CollectionUtils;
37+
import org.springframework.util.LinkedCaseInsensitiveMap;
38+
import org.springframework.util.MultiValueMap;
39+
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
40+
41+
import static org.assertj.core.api.Assertions.assertThat;
42+
import static org.assertj.core.api.Assertions.assertThatRuntimeException;
43+
import static org.junit.jupiter.params.provider.Arguments.argumentSet;
44+
import static org.mockito.BDDMockito.when;
45+
46+
class DefaultServerHttpRequestBuilderTests {
47+
48+
@ParameterizedTest
49+
@MethodSource("headers")
50+
void containerImmutableHeadersAreCopied(MultiValueMap<String, String> headerMap, boolean isMutableMap) {
51+
HttpHeaders originalHeaders = new HttpHeaders(headerMap);
52+
ServerHttpRequest mockRequest = createMockRequest(originalHeaders);
53+
final DefaultServerHttpRequestBuilder builder = new DefaultServerHttpRequestBuilder(mockRequest);
54+
55+
//perform mutations on the map adapter of the container's headers if possible
56+
if (isMutableMap) {
57+
headerMap.set("CaseInsensitive", "original");
58+
assertThat(originalHeaders.getFirst("caseinsensitive"))
59+
.as("original mutated")
60+
.isEqualTo("original");
61+
}
62+
else {
63+
assertThatRuntimeException().isThrownBy(() -> headerMap.set("CaseInsensitive", "original"));
64+
assertThat(originalHeaders.getFirst("caseinsensitive"))
65+
.as("original not mutable")
66+
.isEqualTo("unmodified");
67+
}
68+
69+
// Mutating the headers in the build. Note directly mutating via
70+
// .build().getHeaders() isn't applicable since/ headers are made
71+
// read-only by build()
72+
ServerHttpRequest req = builder
73+
.header("CaseInsensitive", "modified")
74+
.header("Additional", "header")
75+
.build();
76+
77+
assertThat(req.getHeaders().getFirst("CaseInsensitive"))
78+
.as("copy mutated")
79+
.isEqualTo("modified");
80+
assertThat(req.getHeaders().getFirst("caseinsensitive"))
81+
.as("copy case-insensitive")
82+
.isEqualTo("modified");
83+
assertThat(req.getHeaders().getFirst("additional"))
84+
.as("copy has additional header")
85+
.isEqualTo("header");
86+
}
87+
88+
private ServerHttpRequest createMockRequest(HttpHeaders originalHeaders) {
89+
//we can't use only use a MockServerHttpRequest because it uses a ReadOnlyHttpHeaders internally
90+
ServerHttpRequest mock = BDDMockito.spy(MockServerHttpRequest.get("/example").build());
91+
when(mock.getHeaders()).thenReturn(originalHeaders);
92+
93+
return mock;
94+
}
95+
96+
static Arguments initHeader(String description, MultiValueMap<String, String> headerMap) {
97+
headerMap.add("CaseInsensitive", "unmodified");
98+
return argumentSet(description, headerMap, true);
99+
}
100+
101+
static Stream<Arguments> headers() {
102+
return Stream.of(
103+
initHeader("Map", CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH))),
104+
initHeader("Netty", new Netty4HeadersAdapter(new DefaultHttpHeaders())),
105+
initHeader("Netty5", new Netty5HeadersAdapter(io.netty5.handler.codec.http.headers.HttpHeaders.newHeaders())),
106+
initHeader("Tomcat", new TomcatHeadersAdapter(new MimeHeaders())),
107+
initHeader("Undertow", new UndertowHeadersAdapter(new HeaderMap())),
108+
initHeader("Jetty", new JettyHeadersAdapter(HttpFields.build())),
109+
//immutable versions of some headers
110+
argumentSet("Netty immutable", new Netty4HeadersAdapter(new ReadOnlyHttpHeaders(false,
111+
"CaseInsensitive", "unmodified")), false),
112+
argumentSet("Jetty immutable", new JettyHeadersAdapter(HttpFields.build()
113+
.add("CaseInsensitive", "unmodified").asImmutable()), false)
114+
);
115+
}
116+
117+
}

0 commit comments

Comments
 (0)