Skip to content

Commit

Permalink
Add CORS support for Private Network Access
Browse files Browse the repository at this point in the history
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 spring-projectsgh-28546
  • Loading branch information
sdeleuze committed Jan 5, 2024
1 parent 19a87e9 commit 540c052
Show file tree
Hide file tree
Showing 18 changed files with 328 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@
*/
String allowCredentials() default "";

/**
* Whether private network access is supported. Please, see
* {@link CorsConfiguration#setAllowPrivateNetwork(Boolean)} for details.
* <p>By default this is not set (i.e. private network access is not supported).
* @since 6.1.3
*/
String allowPrivateNetwork() default "";

/**
* The maximum age (in seconds) of the cache duration for preflight responses.
* <p>This property controls the value of the {@code Access-Control-Max-Age}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ public class CorsConfiguration {
@Nullable
private Boolean allowCredentials;

@Nullable
private Boolean allowPrivateNetwork;

@Nullable
private Long maxAge;

Expand All @@ -114,6 +117,7 @@ public CorsConfiguration(CorsConfiguration other) {
this.allowedHeaders = other.allowedHeaders;
this.exposedHeaders = other.exposedHeaders;
this.allowCredentials = other.allowCredentials;
this.allowPrivateNetwork = other.allowPrivateNetwork;
this.maxAge = other.maxAge;
}

Expand All @@ -133,9 +137,10 @@ public CorsConfiguration(CorsConfiguration other) {
* {@code Access-Control-Allow-Origin} response header is set either to the
* matched domain value or to {@code "*"}. Keep in mind however that the
* CORS spec does not allow {@code "*"} when {@link #setAllowCredentials
* allowCredentials} is set to {@code true} and as of 5.3 that combination
* is rejected in favor of using {@link #setAllowedOriginPatterns
* allowedOriginPatterns} instead.
* allowCredentials} is set to {@code true}, and does not recommend {@code "*"}
* when {@link #setAllowPrivateNetwork allowPrivateNetwork} is set to {@code true}.
* As a consequence, those combinations are rejected in favor of using
* {@link #setAllowedOriginPatterns allowedOriginPatterns} instead.
* <p>By default this is not set which means that no origins are allowed.
* However, an instance of this class is often initialized further, e.g. for
* {@code @CrossOrigin}, via {@link #applyPermitDefaultValues()}.
Expand Down Expand Up @@ -199,11 +204,13 @@ else if (this.allowedOrigins == DEFAULT_PERMIT_ALL && CollectionUtils.isEmpty(th
* note that such placeholders must be resolved externally.
* </ul>
* <p>In contrast to {@link #setAllowedOrigins(List) allowedOrigins} which
* only supports "*" and cannot be used with {@code allowCredentials}, when
* an allowedOriginPattern is matched, the {@code Access-Control-Allow-Origin}
* response header is set to the matched origin and not to {@code "*"} nor
* to the pattern. Therefore, allowedOriginPatterns can be used in combination
* with {@link #setAllowCredentials} set to {@code true}.
* only supports "*" and cannot be used with {@code allowCredentials} or
* {@code allowPrivateNetwork}, when an {@code allowedOriginPattern} is matched,
* the {@code Access-Control-Allow-Origin} response header is set to the
* matched origin and not to {@code "*"} nor to the pattern.
* Therefore, {@code allowedOriginPatterns} can be used in combination with
* {@link #setAllowCredentials} and {@link #setAllowPrivateNetwork} set to
* {@code true}
* <p>By default this is not set.
* @since 5.3
*/
Expand Down Expand Up @@ -461,6 +468,32 @@ public Boolean getAllowCredentials() {
return this.allowCredentials;
}

/**
* Whether private network access is supported for user-agents restricting such access by default.
* <p>Private network requests are requests whose target server's IP address is more private than
* that from which the request initiator was fetched. For example, a request from a public website
* (https://example.com) to a private website (https://router.local), or a request from a private website to localhost.
* <p>Setting this property has an impact on how {@link #setAllowedOrigins(List)
* origins} and {@link #setAllowedOriginPatterns(List) originPatterns} are processed,
* see related API documentation for more details.
* <p>By default this is not set (i.e. private network access is not supported).
* @since 6.1.3
* @see <a href="https://wicg.github.io/private-network-access/">Private network access specifications</a>
*/
public void setAllowPrivateNetwork(@Nullable Boolean allowPrivateNetwork) {
this.allowPrivateNetwork = allowPrivateNetwork;
}

/**
* Return the configured {@code allowPrivateNetwork} flag, or {@code null} if none.
* @since 6.1.3
* @see #setAllowPrivateNetwork(Boolean)
*/
@Nullable
public Boolean getAllowPrivateNetwork() {
return this.allowPrivateNetwork;
}

/**
* Configure how long, as a duration, the response from a pre-flight request
* can be cached by clients.
Expand Down Expand Up @@ -543,6 +576,26 @@ public void validateAllowCredentials() {
}
}

/**
* Validate that when {@link #setAllowCredentials allowCredentials} is {@code true},
* {@link #setAllowedOrigins allowedOrigins} does not contain the special
* value {@code "*"} since in that case the "Access-Control-Allow-Origin"
* cannot be set to {@code "*"}.
* @throws IllegalArgumentException if the validation fails
* @since 6.1.3
*/
public void validateAllowPrivateNetwork() {
if (this.allowPrivateNetwork == Boolean.TRUE &&
this.allowedOrigins != null && this.allowedOrigins.contains(ALL)) {

throw new IllegalArgumentException(
"When allowPrivateNetwork is true, allowedOrigins cannot contain the special value \"*\" " +
"as it is not recommended from a security perspective. " +
"To allow private network access to a set of origins, list them explicitly " +
"or consider using \"allowedOriginPatterns\" instead.");
}
}

/**
* Combine the non-null properties of the supplied
* {@code CorsConfiguration} with this one.
Expand Down Expand Up @@ -577,6 +630,10 @@ public CorsConfiguration combine(@Nullable CorsConfiguration other) {
if (allowCredentials != null) {
config.setAllowCredentials(allowCredentials);
}
Boolean allowPrivateNetwork = other.getAllowPrivateNetwork();
if (allowPrivateNetwork != null) {
config.setAllowPrivateNetwork(allowPrivateNetwork);
}
Long maxAge = other.getMaxAge();
if (maxAge != null) {
config.setMaxAge(maxAge);
Expand Down Expand Up @@ -640,6 +697,7 @@ public String checkOrigin(@Nullable String origin) {
if (!ObjectUtils.isEmpty(this.allowedOrigins)) {
if (this.allowedOrigins.contains(ALL)) {
validateAllowCredentials();
validateAllowPrivateNetwork();
return ALL;
}
for (String allowedOrigin : this.allowedOrigins) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ public class DefaultCorsProcessor implements CorsProcessor {

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

/**
* The {@code Access-Control-Request-Private-Network} request header field name.
* @see <a href="https://wicg.github.io/private-network-access/">Private Network Access specification</a>
*/
static final String ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK = "Access-Control-Request-Private-Network";

/**
* The {@code Access-Control-Allow-Private-Network} response header field name.
* @see <a href="https://wicg.github.io/private-network-access/">Private Network Access specification</a>
*/
static final String ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK = "Access-Control-Allow-Private-Network";


@Override
@SuppressWarnings("resource")
Expand Down Expand Up @@ -155,6 +167,11 @@ protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse r
responseHeaders.setAccessControlAllowCredentials(true);
}

if (Boolean.TRUE.equals(config.getAllowPrivateNetwork()) &&
Boolean.parseBoolean(request.getHeaders().getFirst(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK))) {
responseHeaders.set(ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK, Boolean.toString(true));
}

if (preFlightRequest && config.getMaxAge() != null) {
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ public class DefaultCorsProcessor implements CorsProcessor {
private static final List<String> VARY_HEADERS = List.of(
HttpHeaders.ORIGIN, HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);

/**
* The {@code Access-Control-Request-Private-Network} request header field name.
* @see <a href="https://wicg.github.io/private-network-access/">Private Network Access specification</a>
*/
static final String ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK = "Access-Control-Request-Private-Network";

/**
* The {@code Access-Control-Allow-Private-Network} response header field name.
* @see <a href="https://wicg.github.io/private-network-access/">Private Network Access specification</a>
*/
static final String ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK = "Access-Control-Allow-Private-Network";


@Override
public boolean process(@Nullable CorsConfiguration config, ServerWebExchange exchange) {
Expand Down Expand Up @@ -153,6 +165,11 @@ protected boolean handleInternal(ServerWebExchange exchange,
responseHeaders.setAccessControlAllowCredentials(true);
}

if (Boolean.TRUE.equals(config.getAllowPrivateNetwork()) &&
Boolean.parseBoolean(request.getHeaders().getFirst(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK))) {
responseHeaders.set(ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK, Boolean.toString(true));
}

if (preFlightRequest && config.getMaxAge() != null) {
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -50,6 +50,8 @@ void setNullValues() {
assertThat(config.getExposedHeaders()).isNull();
config.setAllowCredentials(null);
assertThat(config.getAllowCredentials()).isNull();
config.setAllowPrivateNetwork(null);
assertThat(config.getAllowPrivateNetwork()).isNull();
config.setMaxAge((Long) null);
assertThat(config.getMaxAge()).isNull();
}
Expand All @@ -63,6 +65,7 @@ void setValues() {
config.addAllowedMethod("*");
config.addExposedHeader("*");
config.setAllowCredentials(true);
config.setAllowPrivateNetwork(true);
config.setMaxAge(123L);

assertThat(config.getAllowedOrigins()).containsExactly("*");
Expand All @@ -71,6 +74,7 @@ void setValues() {
assertThat(config.getAllowedMethods()).containsExactly("*");
assertThat(config.getExposedHeaders()).containsExactly("*");
assertThat(config.getAllowCredentials()).isTrue();
assertThat(config.getAllowPrivateNetwork()).isTrue();
assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(123));
}

Expand All @@ -93,6 +97,7 @@ void combineWithNullProperties() {
config.addAllowedMethod(HttpMethod.GET.name());
config.setMaxAge(123L);
config.setAllowCredentials(true);
config.setAllowPrivateNetwork(true);

CorsConfiguration other = new CorsConfiguration();
config = config.combine(other);
Expand All @@ -105,6 +110,7 @@ void combineWithNullProperties() {
assertThat(config.getAllowedMethods()).containsExactly(HttpMethod.GET.name());
assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(123));
assertThat(config.getAllowCredentials()).isTrue();
assertThat(config.getAllowPrivateNetwork()).isTrue();
}

@Test // SPR-15772
Expand Down Expand Up @@ -258,6 +264,7 @@ void combine() {
config.addAllowedMethod(HttpMethod.GET.name());
config.setMaxAge(123L);
config.setAllowCredentials(true);
config.setAllowPrivateNetwork(true);

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

config = config.combine(other);
assertThat(config).isNotNull();
Expand All @@ -277,6 +285,7 @@ void combine() {
assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(456));
assertThat(config).isNotNull();
assertThat(config.getAllowCredentials()).isFalse();
assertThat(config.getAllowPrivateNetwork()).isFalse();
assertThat(config.getAllowedOriginPatterns()).containsExactly("http://*.domain1.com", "http://*.domain2.com");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,32 @@ public void preflightRequestCredentialsWithWildcardOrigin() throws Exception {
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
}

@Test
public void preflightRequestPrivateNetworkWithWildcardOrigin() throws Exception {
this.request.setMethod(HttpMethod.OPTIONS.name());
this.request.addHeader(HttpHeaders.ORIGIN, "https://domain2.com");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Header1");
this.request.addHeader(DefaultCorsProcessor.ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK, "true");
this.conf.setAllowedOrigins(Arrays.asList("https://domain1.com", "*", "http://domain3.example"));
this.conf.addAllowedHeader("Header1");
this.conf.setAllowPrivateNetwork(true);

assertThatIllegalArgumentException().isThrownBy(() ->
this.processor.processRequest(this.conf, this.request, this.response));

this.conf.setAllowedOrigins(null);
this.conf.addAllowedOriginPattern("*");

this.processor.processRequest(this.conf, this.request, this.response);
assertThat(this.response.containsHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
assertThat(this.response.containsHeader(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isTrue();
assertThat(this.response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo("https://domain2.com");
assertThat(this.response.getHeaders(HttpHeaders.VARY)).contains(HttpHeaders.ORIGIN,
HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
}

@Test
public void preflightRequestAllowedHeaders() throws Exception {
this.request.setMethod(HttpMethod.OPTIONS.name());
Expand Down Expand Up @@ -434,4 +460,49 @@ public void preventDuplicatedVaryHeaders() throws Exception {
HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
}

@Test
public void preflightRequestWithoutAccessControlRequestPrivateNetwork() throws Exception {
this.request.setMethod(HttpMethod.OPTIONS.name());
this.request.addHeader(HttpHeaders.ORIGIN, "https://domain2.com");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
this.conf.addAllowedHeader("*");
this.conf.addAllowedOrigin("https://domain2.com");

this.processor.processRequest(this.conf, this.request, this.response);
assertThat(this.response.containsHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
assertThat(this.response.containsHeader(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isFalse();
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
}

@Test
public void preflightRequestWithAccessControlRequestPrivateNetworkNotAllowed() throws Exception {
this.request.setMethod(HttpMethod.OPTIONS.name());
this.request.addHeader(HttpHeaders.ORIGIN, "https://domain2.com");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
this.request.addHeader(DefaultCorsProcessor.ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK, "true");
this.conf.addAllowedHeader("*");
this.conf.addAllowedOrigin("https://domain2.com");

this.processor.processRequest(this.conf, this.request, this.response);
assertThat(this.response.containsHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
assertThat(this.response.containsHeader(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isFalse();
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
}

@Test
public void preflightRequestWithAccessControlRequestPrivateNetworkAllowed() throws Exception {
this.request.setMethod(HttpMethod.OPTIONS.name());
this.request.addHeader(HttpHeaders.ORIGIN, "https://domain2.com");
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
this.request.addHeader(DefaultCorsProcessor.ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK, "true");
this.conf.addAllowedHeader("*");
this.conf.addAllowedOrigin("https://domain2.com");
this.conf.setAllowPrivateNetwork(true);

this.processor.processRequest(this.conf, this.request, this.response);
assertThat(this.response.containsHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
assertThat(this.response.containsHeader(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isTrue();
assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
}

}

0 comments on commit 540c052

Please sign in to comment.