Skip to content

Commit 8d51fc0

Browse files
committed
Add CORS support for Private Network Access
This commit adds CORS support for Private Network Access by adding an Access-Control-Allow-Private-Network response header when the preflight request is sent with an Access-Control-Request-Private-Network header and that Private Network Access has been enabled in the CORS configuration. See https://developer.chrome.com/blog/private-network-access-preflight/ for more details. Closes gh-31975 (cherry picked from commit 318d460)
1 parent bad0101 commit 8d51fc0

File tree

18 files changed

+328
-15
lines changed

18 files changed

+328
-15
lines changed

spring-web/src/main/java/org/springframework/web/bind/annotation/CrossOrigin.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@
116116
*/
117117
String allowCredentials() default "";
118118

119+
/**
120+
* Whether private network access is supported. Please, see
121+
* {@link CorsConfiguration#setAllowPrivateNetwork(Boolean)} for details.
122+
* <p>By default this is not set (i.e. private network access is not supported).
123+
* @since 6.1.3
124+
*/
125+
String allowPrivateNetwork() default "";
126+
119127
/**
120128
* The maximum age (in seconds) of the cache duration for preflight responses.
121129
* <p>This property controls the value of the {@code Access-Control-Max-Age}

spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ public class CorsConfiguration {
9090
@Nullable
9191
private Boolean allowCredentials;
9292

93+
@Nullable
94+
private Boolean allowPrivateNetwork;
95+
9396
@Nullable
9497
private Long maxAge;
9598

@@ -114,6 +117,7 @@ public CorsConfiguration(CorsConfiguration other) {
114117
this.allowedHeaders = other.allowedHeaders;
115118
this.exposedHeaders = other.exposedHeaders;
116119
this.allowCredentials = other.allowCredentials;
120+
this.allowPrivateNetwork = other.allowPrivateNetwork;
117121
this.maxAge = other.maxAge;
118122
}
119123

@@ -133,9 +137,10 @@ public CorsConfiguration(CorsConfiguration other) {
133137
* {@code Access-Control-Allow-Origin} response header is set either to the
134138
* matched domain value or to {@code "*"}. Keep in mind however that the
135139
* CORS spec does not allow {@code "*"} when {@link #setAllowCredentials
136-
* allowCredentials} is set to {@code true} and as of 5.3 that combination
137-
* is rejected in favor of using {@link #setAllowedOriginPatterns
138-
* allowedOriginPatterns} instead.
140+
* allowCredentials} is set to {@code true}, and does not recommend {@code "*"}
141+
* when {@link #setAllowPrivateNetwork allowPrivateNetwork} is set to {@code true}.
142+
* As a consequence, those combinations are rejected in favor of using
143+
* {@link #setAllowedOriginPatterns allowedOriginPatterns} instead.
139144
* <p>By default this is not set which means that no origins are allowed.
140145
* However, an instance of this class is often initialized further, e.g. for
141146
* {@code @CrossOrigin}, via {@link #applyPermitDefaultValues()}.
@@ -199,11 +204,13 @@ else if (this.allowedOrigins == DEFAULT_PERMIT_ALL && CollectionUtils.isEmpty(th
199204
* note that such placeholders must be resolved externally.
200205
* </ul>
201206
* <p>In contrast to {@link #setAllowedOrigins(List) allowedOrigins} which
202-
* only supports "*" and cannot be used with {@code allowCredentials}, when
203-
* an allowedOriginPattern is matched, the {@code Access-Control-Allow-Origin}
204-
* response header is set to the matched origin and not to {@code "*"} nor
205-
* to the pattern. Therefore, allowedOriginPatterns can be used in combination
206-
* with {@link #setAllowCredentials} set to {@code true}.
207+
* only supports "*" and cannot be used with {@code allowCredentials} or
208+
* {@code allowPrivateNetwork}, when an {@code allowedOriginPattern} is matched,
209+
* the {@code Access-Control-Allow-Origin} response header is set to the
210+
* matched origin and not to {@code "*"} nor to the pattern.
211+
* Therefore, {@code allowedOriginPatterns} can be used in combination with
212+
* {@link #setAllowCredentials} and {@link #setAllowPrivateNetwork} set to
213+
* {@code true}
207214
* <p>By default this is not set.
208215
* @since 5.3
209216
*/
@@ -461,6 +468,33 @@ public Boolean getAllowCredentials() {
461468
return this.allowCredentials;
462469
}
463470

471+
/**
472+
* Whether private network access is supported for user-agents restricting such access by default.
473+
* <p>Private network requests are requests whose target server's IP address is more private than
474+
* that from which the request initiator was fetched. For example, a request from a public website
475+
* (https://example.com) to a private website (https://router.local), or a request from a private
476+
* website to localhost.
477+
* <p>Setting this property has an impact on how {@link #setAllowedOrigins(List)
478+
* origins} and {@link #setAllowedOriginPatterns(List) originPatterns} are processed,
479+
* see related API documentation for more details.
480+
* <p>By default this is not set (i.e. private network access is not supported).
481+
* @since 6.1.3
482+
* @see <a href="https://wicg.github.io/private-network-access/">Private network access specifications</a>
483+
*/
484+
public void setAllowPrivateNetwork(@Nullable Boolean allowPrivateNetwork) {
485+
this.allowPrivateNetwork = allowPrivateNetwork;
486+
}
487+
488+
/**
489+
* Return the configured {@code allowPrivateNetwork} flag, or {@code null} if none.
490+
* @since 6.1.3
491+
* @see #setAllowPrivateNetwork(Boolean)
492+
*/
493+
@Nullable
494+
public Boolean getAllowPrivateNetwork() {
495+
return this.allowPrivateNetwork;
496+
}
497+
464498
/**
465499
* Configure how long, as a duration, the response from a pre-flight request
466500
* can be cached by clients.
@@ -543,6 +577,25 @@ public void validateAllowCredentials() {
543577
}
544578
}
545579

580+
/**
581+
* Validate that when {@link #setAllowPrivateNetwork allowPrivateNetwork} is {@code true},
582+
* {@link #setAllowedOrigins allowedOrigins} does not contain the special
583+
* value {@code "*"} since this is insecure.
584+
* @throws IllegalArgumentException if the validation fails
585+
* @since 6.1.3
586+
*/
587+
public void validateAllowPrivateNetwork() {
588+
if (this.allowPrivateNetwork == Boolean.TRUE &&
589+
this.allowedOrigins != null && this.allowedOrigins.contains(ALL)) {
590+
591+
throw new IllegalArgumentException(
592+
"When allowPrivateNetwork is true, allowedOrigins cannot contain the special value \"*\" " +
593+
"as it is not recommended from a security perspective. " +
594+
"To allow private network access to a set of origins, list them explicitly " +
595+
"or consider using \"allowedOriginPatterns\" instead.");
596+
}
597+
}
598+
546599
/**
547600
* Combine the non-null properties of the supplied
548601
* {@code CorsConfiguration} with this one.
@@ -577,6 +630,10 @@ public CorsConfiguration combine(@Nullable CorsConfiguration other) {
577630
if (allowCredentials != null) {
578631
config.setAllowCredentials(allowCredentials);
579632
}
633+
Boolean allowPrivateNetwork = other.getAllowPrivateNetwork();
634+
if (allowPrivateNetwork != null) {
635+
config.setAllowPrivateNetwork(allowPrivateNetwork);
636+
}
580637
Long maxAge = other.getMaxAge();
581638
if (maxAge != null) {
582639
config.setMaxAge(maxAge);
@@ -640,6 +697,7 @@ public String checkOrigin(@Nullable String origin) {
640697
if (!ObjectUtils.isEmpty(this.allowedOrigins)) {
641698
if (this.allowedOrigins.contains(ALL)) {
642699
validateAllowCredentials();
700+
validateAllowPrivateNetwork();
643701
return ALL;
644702
}
645703
for (String allowedOrigin : this.allowedOrigins) {

spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ public class DefaultCorsProcessor implements CorsProcessor {
5454

5555
private static final Log logger = LogFactory.getLog(DefaultCorsProcessor.class);
5656

57+
/**
58+
* The {@code Access-Control-Request-Private-Network} request header field name.
59+
* @see <a href="https://wicg.github.io/private-network-access/">Private Network Access specification</a>
60+
*/
61+
static final String ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK = "Access-Control-Request-Private-Network";
62+
63+
/**
64+
* The {@code Access-Control-Allow-Private-Network} response header field name.
65+
* @see <a href="https://wicg.github.io/private-network-access/">Private Network Access specification</a>
66+
*/
67+
static final String ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK = "Access-Control-Allow-Private-Network";
68+
5769

5870
@Override
5971
@SuppressWarnings("resource")
@@ -155,6 +167,11 @@ protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse r
155167
responseHeaders.setAccessControlAllowCredentials(true);
156168
}
157169

170+
if (Boolean.TRUE.equals(config.getAllowPrivateNetwork()) &&
171+
Boolean.parseBoolean(request.getHeaders().getFirst(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK))) {
172+
responseHeaders.set(ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK, Boolean.toString(true));
173+
}
174+
158175
if (preFlightRequest && config.getMaxAge() != null) {
159176
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
160177
}

spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ public class DefaultCorsProcessor implements CorsProcessor {
5252
private static final List<String> VARY_HEADERS = List.of(
5353
HttpHeaders.ORIGIN, HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
5454

55+
/**
56+
* The {@code Access-Control-Request-Private-Network} request header field name.
57+
* @see <a href="https://wicg.github.io/private-network-access/">Private Network Access specification</a>
58+
*/
59+
static final String ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK = "Access-Control-Request-Private-Network";
60+
61+
/**
62+
* The {@code Access-Control-Allow-Private-Network} response header field name.
63+
* @see <a href="https://wicg.github.io/private-network-access/">Private Network Access specification</a>
64+
*/
65+
static final String ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK = "Access-Control-Allow-Private-Network";
66+
5567

5668
@Override
5769
public boolean process(@Nullable CorsConfiguration config, ServerWebExchange exchange) {
@@ -153,6 +165,11 @@ protected boolean handleInternal(ServerWebExchange exchange,
153165
responseHeaders.setAccessControlAllowCredentials(true);
154166
}
155167

168+
if (Boolean.TRUE.equals(config.getAllowPrivateNetwork()) &&
169+
Boolean.parseBoolean(request.getHeaders().getFirst(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK))) {
170+
responseHeaders.set(ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK, Boolean.toString(true));
171+
}
172+
156173
if (preFlightRequest && config.getMaxAge() != null) {
157174
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
158175
}

spring-web/src/test/java/org/springframework/web/cors/CorsConfigurationTests.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -50,6 +50,8 @@ void setNullValues() {
5050
assertThat(config.getExposedHeaders()).isNull();
5151
config.setAllowCredentials(null);
5252
assertThat(config.getAllowCredentials()).isNull();
53+
config.setAllowPrivateNetwork(null);
54+
assertThat(config.getAllowPrivateNetwork()).isNull();
5355
config.setMaxAge((Long) null);
5456
assertThat(config.getMaxAge()).isNull();
5557
}
@@ -63,6 +65,7 @@ void setValues() {
6365
config.addAllowedMethod("*");
6466
config.addExposedHeader("*");
6567
config.setAllowCredentials(true);
68+
config.setAllowPrivateNetwork(true);
6669
config.setMaxAge(123L);
6770

6871
assertThat(config.getAllowedOrigins()).containsExactly("*");
@@ -71,6 +74,7 @@ void setValues() {
7174
assertThat(config.getAllowedMethods()).containsExactly("*");
7275
assertThat(config.getExposedHeaders()).containsExactly("*");
7376
assertThat(config.getAllowCredentials()).isTrue();
77+
assertThat(config.getAllowPrivateNetwork()).isTrue();
7478
assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(123));
7579
}
7680

@@ -93,6 +97,7 @@ void combineWithNullProperties() {
9397
config.addAllowedMethod(HttpMethod.GET.name());
9498
config.setMaxAge(123L);
9599
config.setAllowCredentials(true);
100+
config.setAllowPrivateNetwork(true);
96101

97102
CorsConfiguration other = new CorsConfiguration();
98103
config = config.combine(other);
@@ -105,6 +110,7 @@ void combineWithNullProperties() {
105110
assertThat(config.getAllowedMethods()).containsExactly(HttpMethod.GET.name());
106111
assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(123));
107112
assertThat(config.getAllowCredentials()).isTrue();
113+
assertThat(config.getAllowPrivateNetwork()).isTrue();
108114
}
109115

110116
@Test // SPR-15772
@@ -258,6 +264,7 @@ void combine() {
258264
config.addAllowedMethod(HttpMethod.GET.name());
259265
config.setMaxAge(123L);
260266
config.setAllowCredentials(true);
267+
config.setAllowPrivateNetwork(true);
261268

262269
CorsConfiguration other = new CorsConfiguration();
263270
other.addAllowedOrigin("https://domain2.com");
@@ -267,6 +274,7 @@ void combine() {
267274
other.addAllowedMethod(HttpMethod.PUT.name());
268275
other.setMaxAge(456L);
269276
other.setAllowCredentials(false);
277+
other.setAllowPrivateNetwork(false);
270278

271279
config = config.combine(other);
272280
assertThat(config).isNotNull();
@@ -277,6 +285,7 @@ void combine() {
277285
assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(456));
278286
assertThat(config).isNotNull();
279287
assertThat(config.getAllowCredentials()).isFalse();
288+
assertThat(config.getAllowPrivateNetwork()).isFalse();
280289
assertThat(config.getAllowedOriginPatterns()).containsExactly("http://*.domain1.com", "http://*.domain2.com");
281290
}
282291

spring-web/src/test/java/org/springframework/web/cors/DefaultCorsProcessorTests.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,32 @@ public void preflightRequestCredentialsWithWildcardOrigin() throws Exception {
351351
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
352352
}
353353

354+
@Test
355+
public void preflightRequestPrivateNetworkWithWildcardOrigin() throws Exception {
356+
this.request.setMethod(HttpMethod.OPTIONS.name());
357+
this.request.addHeader(HttpHeaders.ORIGIN, "https://domain2.com");
358+
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
359+
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Header1");
360+
this.request.addHeader(DefaultCorsProcessor.ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK, "true");
361+
this.conf.setAllowedOrigins(Arrays.asList("https://domain1.com", "*", "http://domain3.example"));
362+
this.conf.addAllowedHeader("Header1");
363+
this.conf.setAllowPrivateNetwork(true);
364+
365+
assertThatIllegalArgumentException().isThrownBy(() ->
366+
this.processor.processRequest(this.conf, this.request, this.response));
367+
368+
this.conf.setAllowedOrigins(null);
369+
this.conf.addAllowedOriginPattern("*");
370+
371+
this.processor.processRequest(this.conf, this.request, this.response);
372+
assertThat(this.response.containsHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
373+
assertThat(this.response.containsHeader(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isTrue();
374+
assertThat(this.response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo("https://domain2.com");
375+
assertThat(this.response.getHeaders(HttpHeaders.VARY)).contains(HttpHeaders.ORIGIN,
376+
HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
377+
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
378+
}
379+
354380
@Test
355381
public void preflightRequestAllowedHeaders() throws Exception {
356382
this.request.setMethod(HttpMethod.OPTIONS.name());
@@ -434,4 +460,49 @@ public void preventDuplicatedVaryHeaders() throws Exception {
434460
HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
435461
}
436462

463+
@Test
464+
public void preflightRequestWithoutAccessControlRequestPrivateNetwork() throws Exception {
465+
this.request.setMethod(HttpMethod.OPTIONS.name());
466+
this.request.addHeader(HttpHeaders.ORIGIN, "https://domain2.com");
467+
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
468+
this.conf.addAllowedHeader("*");
469+
this.conf.addAllowedOrigin("https://domain2.com");
470+
471+
this.processor.processRequest(this.conf, this.request, this.response);
472+
assertThat(this.response.containsHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
473+
assertThat(this.response.containsHeader(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isFalse();
474+
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
475+
}
476+
477+
@Test
478+
public void preflightRequestWithAccessControlRequestPrivateNetworkNotAllowed() throws Exception {
479+
this.request.setMethod(HttpMethod.OPTIONS.name());
480+
this.request.addHeader(HttpHeaders.ORIGIN, "https://domain2.com");
481+
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
482+
this.request.addHeader(DefaultCorsProcessor.ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK, "true");
483+
this.conf.addAllowedHeader("*");
484+
this.conf.addAllowedOrigin("https://domain2.com");
485+
486+
this.processor.processRequest(this.conf, this.request, this.response);
487+
assertThat(this.response.containsHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
488+
assertThat(this.response.containsHeader(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isFalse();
489+
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
490+
}
491+
492+
@Test
493+
public void preflightRequestWithAccessControlRequestPrivateNetworkAllowed() throws Exception {
494+
this.request.setMethod(HttpMethod.OPTIONS.name());
495+
this.request.addHeader(HttpHeaders.ORIGIN, "https://domain2.com");
496+
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
497+
this.request.addHeader(DefaultCorsProcessor.ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK, "true");
498+
this.conf.addAllowedHeader("*");
499+
this.conf.addAllowedOrigin("https://domain2.com");
500+
this.conf.setAllowPrivateNetwork(true);
501+
502+
this.processor.processRequest(this.conf, this.request, this.response);
503+
assertThat(this.response.containsHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
504+
assertThat(this.response.containsHeader(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isTrue();
505+
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
506+
}
507+
437508
}

0 commit comments

Comments
 (0)