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 59f7c0b
Show file tree
Hide file tree
Showing 13 changed files with 227 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@
*/
String allowCredentials() default "";

/**
* Whether private network access is supported.
* <p>By default this is not set (i.e. private network access is not supported).
* @see <a href="https://wicg.github.io/private-network-access/">Private network access specifications</a>
*/
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 Down Expand Up @@ -461,6 +465,24 @@ public Boolean getAllowCredentials() {
return this.allowCredentials;
}

/**
* Whether private network access is supported.
* <p>By default this is not set (i.e. private network access is not supported).
* @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.
* @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 @@ -577,6 +599,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
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 @@ -434,4 +434,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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,57 @@ public void preventDuplicatedVaryHeaders() {
ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS);
}

@Test
public void preflightRequestWithoutAccessControlRequestPrivateNetwork() {
ServerWebExchange exchange = MockServerWebExchange.from(preFlightRequest()
.header(ACCESS_CONTROL_REQUEST_METHOD, "GET"));

this.conf.addAllowedHeader("*");
this.conf.addAllowedOrigin("https://domain2.com");

this.processor.process(this.conf, exchange);

ServerHttpResponse response = exchange.getResponse();
assertThat(response.getHeaders().containsKey(ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
assertThat(response.getHeaders().containsKey(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isFalse();
assertThat(response.getStatusCode()).isNull();
}

@Test
public void preflightRequestWithAccessControlRequestPrivateNetworkNotAllowed() {
ServerWebExchange exchange = MockServerWebExchange.from(preFlightRequest()
.header(ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(DefaultCorsProcessor.ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK, "true"));

this.conf.addAllowedHeader("*");
this.conf.addAllowedOrigin("https://domain2.com");

this.processor.process(this.conf, exchange);

ServerHttpResponse response = exchange.getResponse();
assertThat(response.getHeaders().containsKey(ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
assertThat(response.getHeaders().containsKey(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isFalse();
assertThat(response.getStatusCode()).isNull();
}

@Test
public void preflightRequestWithAccessControlRequestPrivateNetworkAllowed() {
ServerWebExchange exchange = MockServerWebExchange.from(preFlightRequest()
.header(ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(DefaultCorsProcessor.ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK, "true"));

this.conf.addAllowedHeader("*");
this.conf.addAllowedOrigin("https://domain2.com");
this.conf.setAllowPrivateNetwork(true);

this.processor.process(this.conf, exchange);

ServerHttpResponse response = exchange.getResponse();
assertThat(response.getHeaders().containsKey(ACCESS_CONTROL_ALLOW_ORIGIN)).isTrue();
assertThat(response.getHeaders().containsKey(DefaultCorsProcessor.ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK)).isTrue();
assertThat(response.getStatusCode()).isNull();
}


private ServerWebExchange actualRequest() {
return MockServerWebExchange.from(corsRequest(HttpMethod.GET));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ public CorsRegistration allowCredentials(boolean allowCredentials) {
return this;
}

/**
* Whether private network access is supported.
* <p>By default this is not set (i.e. private network access is not supported).
* @see <a href="https://wicg.github.io/private-network-access/">Private network access specifications</a>
*/
public CorsRegistration allowPrivateNetwork(boolean allowPrivateNetwork) {
this.config.setAllowPrivateNetwork(allowPrivateNetwork);
return this;
}

/**
* Configure how long in seconds the response from a pre-flight request
* can be cached by clients.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,18 @@ else if (!allowCredentials.isEmpty()) {
"or an empty string (\"\"): current value is [" + allowCredentials + "]");
}

String allowPrivateNetwork = resolveCorsAnnotationValue(annotation.allowPrivateNetwork());
if ("true".equalsIgnoreCase(allowPrivateNetwork)) {
config.setAllowPrivateNetwork(true);
}
else if ("false".equalsIgnoreCase(allowPrivateNetwork)) {
config.setAllowPrivateNetwork(false);
}
else if (!allowPrivateNetwork.isEmpty()) {
throw new IllegalStateException("@CrossOrigin's allowPrivateNetwork value must be \"true\", \"false\", " +
"or an empty string (\"\"): current value is [" + allowPrivateNetwork + "]");
}

if (annotation.maxAge() >= 0) {
config.setMaxAge(annotation.maxAge());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,17 @@ public void multipleMappings() {
@Test
public void customizedMapping() {
this.registry.addMapping("/foo").allowedOrigins("https://domain2.com", "https://domain2.com")
.allowedMethods("DELETE").allowCredentials(false).allowedHeaders("header1", "header2")
.exposedHeaders("header3", "header4").maxAge(3600);
.allowedMethods("DELETE").allowCredentials(true).allowPrivateNetwork(true)
.allowedHeaders("header1", "header2").exposedHeaders("header3", "header4").maxAge(3600);
Map<String, CorsConfiguration> configs = this.registry.getCorsConfigurations();
assertThat(configs).hasSize(1);
CorsConfiguration config = configs.get("/foo");
assertThat(config.getAllowedOrigins()).isEqualTo(Arrays.asList("https://domain2.com", "https://domain2.com"));
assertThat(config.getAllowedMethods()).isEqualTo(Collections.singletonList("DELETE"));
assertThat(config.getAllowedHeaders()).isEqualTo(Arrays.asList("header1", "header2"));
assertThat(config.getExposedHeaders()).isEqualTo(Arrays.asList("header3", "header4"));
assertThat(config.getAllowCredentials()).isFalse();
assertThat(config.getAllowCredentials()).isTrue();
assertThat(config.getAllowPrivateNetwork()).isTrue();
assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(3600));
}

Expand Down Expand Up @@ -90,6 +91,7 @@ void combine() {
assertThat(config.getAllowedHeaders()).isEqualTo(Collections.singletonList("*"));
assertThat(config.getExposedHeaders()).isEmpty();
assertThat(config.getAllowCredentials()).isNull();
assertThat(config.getAllowPrivateNetwork()).isNull();
assertThat(config.getMaxAge()).isEqualTo(Long.valueOf(1800));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ public CorsRegistration allowCredentials(boolean allowCredentials) {
return this;
}

/**
* Whether private network access is supported.
* <p>By default this is not set (i.e. private network access is not supported).
* @see <a href="https://wicg.github.io/private-network-access/">Private network access specifications</a>
*/
public CorsRegistration allowPrivateNetwork(boolean allowPrivateNetwork) {
this.config.setAllowPrivateNetwork(allowPrivateNetwork);
return this;
}

/**
* Configure how long in seconds the response from a pre-flight request
* can be cached by clients.
Expand Down

0 comments on commit 59f7c0b

Please sign in to comment.