From 8d51fc044484dc105da9da9d7c5a4631516567b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Thu, 4 Jan 2024 21:38:59 +0100 Subject: [PATCH] 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 318d4602564fb287b448ea310cf6c146f89cc47a) --- .../web/bind/annotation/CrossOrigin.java | 8 ++ .../web/cors/CorsConfiguration.java | 74 ++++++++++++++++-- .../web/cors/DefaultCorsProcessor.java | 17 ++++ .../cors/reactive/DefaultCorsProcessor.java | 17 ++++ .../web/cors/CorsConfigurationTests.java | 11 ++- .../web/cors/DefaultCorsProcessorTests.java | 71 +++++++++++++++++ .../reactive/DefaultCorsProcessorTests.java | 78 +++++++++++++++++++ .../web/reactive/config/CorsRegistration.java | 11 +++ .../handler/AbstractHandlerMapping.java | 1 + .../method/AbstractHandlerMethodMapping.java | 1 + .../RequestMappingHandlerMapping.java | 12 +++ .../reactive/config/CorsRegistryTests.java | 8 +- .../config/CorsBeanDefinitionParser.java | 1 + .../config/annotation/CorsRegistration.java | 11 +++ .../handler/AbstractHandlerMapping.java | 1 + .../handler/AbstractHandlerMethodMapping.java | 1 + .../RequestMappingHandlerMapping.java | 12 +++ .../config/annotation/CorsRegistryTests.java | 8 +- 18 files changed, 328 insertions(+), 15 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/CrossOrigin.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/CrossOrigin.java index 5f28c1580c1b..8f5a1e7f421b 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/CrossOrigin.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/CrossOrigin.java @@ -116,6 +116,14 @@ */ String allowCredentials() default ""; + /** + * Whether private network access is supported. Please, see + * {@link CorsConfiguration#setAllowPrivateNetwork(Boolean)} for details. + *

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. *

This property controls the value of the {@code Access-Control-Max-Age} diff --git a/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java b/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java index 88ffe1bc96bd..4f09598cca0b 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java +++ b/spring-web/src/main/java/org/springframework/web/cors/CorsConfiguration.java @@ -90,6 +90,9 @@ public class CorsConfiguration { @Nullable private Boolean allowCredentials; + @Nullable + private Boolean allowPrivateNetwork; + @Nullable private Long maxAge; @@ -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; } @@ -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. *

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()}. @@ -199,11 +204,13 @@ else if (this.allowedOrigins == DEFAULT_PERMIT_ALL && CollectionUtils.isEmpty(th * note that such placeholders must be resolved externally. * *

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} *

By default this is not set. * @since 5.3 */ @@ -461,6 +468,33 @@ public Boolean getAllowCredentials() { return this.allowCredentials; } + /** + * Whether private network access is supported for user-agents restricting such access by default. + *

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. + *

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. + *

By default this is not set (i.e. private network access is not supported). + * @since 6.1.3 + * @see Private network access specifications + */ + 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. @@ -543,6 +577,25 @@ public void validateAllowCredentials() { } } + /** + * Validate that when {@link #setAllowPrivateNetwork allowPrivateNetwork} is {@code true}, + * {@link #setAllowedOrigins allowedOrigins} does not contain the special + * value {@code "*"} since this is insecure. + * @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. @@ -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); @@ -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) { diff --git a/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java b/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java index 177d28b5a63d..c134d806df9b 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java +++ b/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java @@ -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 Private Network Access specification + */ + 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 Private Network Access specification + */ + static final String ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK = "Access-Control-Allow-Private-Network"; + @Override @SuppressWarnings("resource") @@ -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()); } diff --git a/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java b/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java index 8f6b16f1f7b4..a259efbb8e74 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java +++ b/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java @@ -52,6 +52,18 @@ public class DefaultCorsProcessor implements CorsProcessor { private static final List 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 Private Network Access specification + */ + 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 Private Network Access specification + */ + static final String ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK = "Access-Control-Allow-Private-Network"; + @Override public boolean process(@Nullable CorsConfiguration config, ServerWebExchange exchange) { @@ -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()); } diff --git a/spring-web/src/test/java/org/springframework/web/cors/CorsConfigurationTests.java b/spring-web/src/test/java/org/springframework/web/cors/CorsConfigurationTests.java index 567240558273..01c925488fa8 100644 --- a/spring-web/src/test/java/org/springframework/web/cors/CorsConfigurationTests.java +++ b/spring-web/src/test/java/org/springframework/web/cors/CorsConfigurationTests.java @@ -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. @@ -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(); } @@ -63,6 +65,7 @@ void setValues() { config.addAllowedMethod("*"); config.addExposedHeader("*"); config.setAllowCredentials(true); + config.setAllowPrivateNetwork(true); config.setMaxAge(123L); assertThat(config.getAllowedOrigins()).containsExactly("*"); @@ -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)); } @@ -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); @@ -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 @@ -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"); @@ -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(); @@ -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"); } diff --git a/spring-web/src/test/java/org/springframework/web/cors/DefaultCorsProcessorTests.java b/spring-web/src/test/java/org/springframework/web/cors/DefaultCorsProcessorTests.java index 735504aa204a..2bd442867cfb 100644 --- a/spring-web/src/test/java/org/springframework/web/cors/DefaultCorsProcessorTests.java +++ b/spring-web/src/test/java/org/springframework/web/cors/DefaultCorsProcessorTests.java @@ -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()); @@ -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); + } + } diff --git a/spring-web/src/test/java/org/springframework/web/cors/reactive/DefaultCorsProcessorTests.java b/spring-web/src/test/java/org/springframework/web/cors/reactive/DefaultCorsProcessorTests.java index eb7a97ecab0f..87ee906f86e6 100644 --- a/spring-web/src/test/java/org/springframework/web/cors/reactive/DefaultCorsProcessorTests.java +++ b/spring-web/src/test/java/org/springframework/web/cors/reactive/DefaultCorsProcessorTests.java @@ -364,6 +364,33 @@ public void preflightRequestCredentialsWithWildcardOrigin() { assertThat((Object) response.getStatusCode()).isNull(); } + @Test + public void preflightRequestPrivateNetworkWithWildcardOrigin() { + ServerWebExchange exchange = MockServerWebExchange.from(preFlightRequest() + .header(ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(ACCESS_CONTROL_REQUEST_HEADERS, "Header1") + .header(DefaultCorsProcessor.ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK, "true")); + + this.conf.addAllowedOrigin("https://domain1.com"); + this.conf.addAllowedOrigin("*"); + this.conf.addAllowedOrigin("http://domain3.example"); + this.conf.addAllowedHeader("Header1"); + this.conf.setAllowPrivateNetwork(true); + assertThatIllegalArgumentException().isThrownBy(() -> this.processor.process(this.conf, exchange)); + + this.conf.setAllowedOrigins(null); + this.conf.addAllowedOriginPattern("*"); + 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.getHeaders().getFirst(ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo("https://domain2.com"); + assertThat(response.getHeaders().get(VARY)).contains(ORIGIN, + ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS); + assertThat(response.getStatusCode()).isNull(); + } + @Test public void preflightRequestAllowedHeaders() { ServerWebExchange exchange = MockServerWebExchange.from(preFlightRequest() @@ -460,6 +487,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)); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/CorsRegistration.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/CorsRegistration.java index 383505c4c7fa..32c15d439914 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/CorsRegistration.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/CorsRegistration.java @@ -131,6 +131,17 @@ public CorsRegistration allowCredentials(boolean allowCredentials) { return this; } + /** + * Whether private network access is supported. + *

Please, see {@link CorsConfiguration#setAllowPrivateNetwork(Boolean)} for details. + *

By default this is not set (i.e. private network access is not supported). + * @since 6.1.3 + */ + 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. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java index 391695382b4f..de09dba809c9 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java @@ -196,6 +196,7 @@ public Mono getHandler(ServerWebExchange exchange) { config = (config != null ? config.combine(handlerConfig) : handlerConfig); if (config != null) { config.validateAllowCredentials(); + config.validateAllowPrivateNetwork(); } if (!this.corsProcessor.process(config, exchange) || CorsUtils.isPreFlightRequest(request)) { return NO_OP_HANDLER; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java index 448557c77001..237b36c7d2d2 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java @@ -532,6 +532,7 @@ public void register(T mapping, Object handler, Method method) { CorsConfiguration corsConfig = initCorsConfiguration(handler, method, mapping); if (corsConfig != null) { corsConfig.validateAllowCredentials(); + corsConfig.validateAllowPrivateNetwork(); this.corsLookup.put(handlerMethod, corsConfig); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java index d05339ef8e5c..02882352a816 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java @@ -343,6 +343,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()); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/config/CorsRegistryTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/config/CorsRegistryTests.java index f03254e7bff3..355a3b319de3 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/config/CorsRegistryTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/config/CorsRegistryTests.java @@ -51,8 +51,8 @@ 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 configs = this.registry.getCorsConfigurations(); assertThat(configs).hasSize(1); CorsConfiguration config = configs.get("/foo"); @@ -60,7 +60,8 @@ public void customizedMapping() { 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)); } @@ -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)); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/CorsBeanDefinitionParser.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/CorsBeanDefinitionParser.java index 20997963fe6f..62e4b553f592 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/CorsBeanDefinitionParser.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/CorsBeanDefinitionParser.java @@ -84,6 +84,7 @@ public BeanDefinition parse(Element element, ParserContext parserContext) { } config.applyPermitDefaultValues(); config.validateAllowCredentials(); + config.validateAllowPrivateNetwork(); corsConfigurations.put(mapping.getAttribute("path"), config); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/CorsRegistration.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/CorsRegistration.java index e1a25396c7cf..cd37917349a2 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/CorsRegistration.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/CorsRegistration.java @@ -132,6 +132,17 @@ public CorsRegistration allowCredentials(boolean allowCredentials) { return this; } + /** + * Whether private network access is supported. + *

By default this is not set (i.e. private network access is not supported). + * @since 6.1.3 + * @see Private network access specifications + */ + 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. diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java index e0a56b82a7b8..12d1886f8131 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java @@ -536,6 +536,7 @@ else if (logger.isDebugEnabled() && !DispatcherType.ASYNC.equals(request.getDisp } if (config != null) { config.validateAllowCredentials(); + config.validateAllowPrivateNetwork(); } executionChain = getCorsHandlerExecutionChain(request, executionChain, config); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java index 85d61f2ce6aa..f9f5c68e429c 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java @@ -646,6 +646,7 @@ public void register(T mapping, Object handler, Method method) { CorsConfiguration corsConfig = initCorsConfiguration(handler, method, mapping); if (corsConfig != null) { corsConfig.validateAllowCredentials(); + corsConfig.validateAllowPrivateNetwork(); this.corsLookup.put(handlerMethod, corsConfig); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java index d30fcc6a3ad3..43a13d34670b 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java @@ -522,6 +522,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()); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/CorsRegistryTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/CorsRegistryTests.java index 9b267eb814a7..42c5d7fe1e17 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/CorsRegistryTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/CorsRegistryTests.java @@ -56,8 +56,8 @@ 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 configs = this.registry.getCorsConfigurations(); assertThat(configs).hasSize(1); CorsConfiguration config = configs.get("/foo"); @@ -65,7 +65,8 @@ public void customizedMapping() { 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)); } @@ -95,6 +96,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)); } }