From 84d3a34c49bbbd1228189e78606b2efd7f57e276 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 14 May 2015 11:39:41 +0100 Subject: [PATCH] =?UTF-8?q?Add=20CORS=20support=20to=20the=20actuator?= =?UTF-8?q?=E2=80=99s=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds CORS support to the Actuator’s MVC endpoints. CORS support is disabled by default and is only enabled once the endpoints.cors.allowed-origins property has been set. The new properties to control the endpoints’ CORS configuration are: endpoints.cors.allow-credentials endpoints.cors.allowed-origins endpoints.cors.allowed-methods endpoints.cors.allowed-headers endpoints.cors.exposed-headers The changes to enable Jolokia-specific CORS support (57a51ed) have been reverted as part of this commit. This provides a consistent approach to CORS configuration across all endpoints, rather than Jolokia using its own configuration. See gh-1987 Closes gh-2936 --- .../EndpointWebMvcAutoConfiguration.java | 8 +- .../MvcEndpointCorsProperties.java | 138 +++++++++++++ .../endpoint/mvc/EndpointHandlerMapping.java | 28 ++- .../endpoint/mvc/JolokiaMvcEndpoint.java | 1 - .../endpoint/mvc/JolokiaMvcEndpointTests.java | 13 -- .../mvc/MvcEndpointCorsIntegrationTests.java | 193 ++++++++++++++++++ .../appendix-application-properties.adoc | 7 + 7 files changed, 369 insertions(+), 19 deletions(-) create mode 100644 spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/MvcEndpointCorsProperties.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/MvcEndpointCorsIntegrationTests.java diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfiguration.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfiguration.java index 3859371cbce3..16c3ffd0a62d 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfiguration.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfiguration.java @@ -103,7 +103,8 @@ @AutoConfigureAfter({ PropertyPlaceholderAutoConfiguration.class, EmbeddedServletContainerAutoConfiguration.class, WebMvcAutoConfiguration.class, ManagementServerPropertiesAutoConfiguration.class }) -@EnableConfigurationProperties(HealthMvcEndpointProperties.class) +@EnableConfigurationProperties({ HealthMvcEndpointProperties.class, + MvcEndpointCorsProperties.class }) public class EndpointWebMvcAutoConfiguration implements ApplicationContextAware, SmartInitializingSingleton { @@ -117,6 +118,9 @@ public class EndpointWebMvcAutoConfiguration implements ApplicationContextAware, @Autowired private ManagementServerProperties managementServerProperties; + @Autowired + private MvcEndpointCorsProperties corsMvcEndpointProperties; + @Autowired(required = false) private List mappingCustomizers; @@ -130,7 +134,7 @@ public void setApplicationContext(ApplicationContext applicationContext) @ConditionalOnMissingBean public EndpointHandlerMapping endpointHandlerMapping() { EndpointHandlerMapping mapping = new EndpointHandlerMapping(mvcEndpoints() - .getEndpoints()); + .getEndpoints(), this.corsMvcEndpointProperties.toCorsConfiguration()); boolean disabled = ManagementServerPort.get(this.applicationContext) != ManagementServerPort.SAME; mapping.setDisabled(disabled); if (!disabled) { diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/MvcEndpointCorsProperties.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/MvcEndpointCorsProperties.java new file mode 100644 index 000000000000..4bf9be7a342d --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/MvcEndpointCorsProperties.java @@ -0,0 +1,138 @@ +/* + * Copyright 2012-2015 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.autoconfigure; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.CollectionUtils; +import org.springframework.web.cors.CorsConfiguration; + +/** + * Configuration properties for MVC endpoints' CORS support. + * + * @author Andy Wilkinson + * @since 1.3.0 + */ +@ConfigurationProperties(prefix = "endpoints.cors") +public class MvcEndpointCorsProperties { + + /** + * List of origins to allow. + */ + private List allowedOrigins = new ArrayList(); + + /** + * List of methods to allow. + */ + private List allowedMethods = new ArrayList(); + + /** + * List of headers to allow in a request + */ + private List allowedHeaders = new ArrayList(); + + /** + * List of headers to include in a response. + */ + private List exposedHeaders = new ArrayList(); + + /** + * Whether credentials are supported + */ + private Boolean allowCredentials; + + /** + * How long, in seconds, the response from a pre-flight request can be cached by + * clients. + */ + private Long maxAge = 1800L; + + public List getAllowedOrigins() { + return this.allowedOrigins; + } + + public void setAllowedOrigins(List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + public List getAllowedMethods() { + return this.allowedMethods; + } + + public void setAllowedMethods(List allowedMethods) { + this.allowedMethods = allowedMethods; + } + + public List getAllowedHeaders() { + return this.allowedHeaders; + } + + public void setAllowedHeaders(List allowedHeaders) { + this.allowedHeaders = allowedHeaders; + } + + public List getExposedHeaders() { + return this.exposedHeaders; + } + + public void setExposedHeaders(List exposedHeaders) { + this.exposedHeaders = exposedHeaders; + } + + public Boolean getAllowCredentials() { + return this.allowCredentials; + } + + public void setAllowCredentials(Boolean allowCredentials) { + this.allowCredentials = allowCredentials; + } + + public Long getMaxAge() { + return this.maxAge; + } + + public void setMaxAge(Long maxAge) { + this.maxAge = maxAge; + } + + CorsConfiguration toCorsConfiguration() { + if (CollectionUtils.isEmpty(this.allowedOrigins)) { + return null; + } + CorsConfiguration corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowedOrigins(this.allowedOrigins); + if (!CollectionUtils.isEmpty(this.allowedHeaders)) { + corsConfiguration.setAllowedHeaders(this.allowedHeaders); + } + if (!CollectionUtils.isEmpty(this.allowedMethods)) { + corsConfiguration.setAllowedMethods(this.allowedMethods); + } + if (!CollectionUtils.isEmpty(this.exposedHeaders)) { + corsConfiguration.setExposedHeaders(this.exposedHeaders); + } + if (this.maxAge != null) { + corsConfiguration.setMaxAge(this.maxAge); + } + if (this.allowCredentials != null) { + corsConfiguration.setAllowCredentials(true); + } + return corsConfiguration; + } + +} diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/EndpointHandlerMapping.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/EndpointHandlerMapping.java index 382daf7a9af3..fb71c09b5561 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/EndpointHandlerMapping.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/EndpointHandlerMapping.java @@ -28,6 +28,7 @@ import org.springframework.context.ApplicationContextAware; import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; @@ -47,24 +48,40 @@ * @author Phillip Webb * @author Christian Dupuis * @author Dave Syer - * @author Andy Wilkinson */ public class EndpointHandlerMapping extends RequestMappingHandlerMapping implements ApplicationContextAware { private final Set endpoints; + private final CorsConfiguration corsConfiguration; + private String prefix = ""; private boolean disabled = false; /** * Create a new {@link EndpointHandlerMapping} instance. All {@link Endpoint}s will be - * detected from the {@link ApplicationContext}. + * detected from the {@link ApplicationContext}. The endpoints will not accept CORS + * requests. * @param endpoints the endpoints */ public EndpointHandlerMapping(Collection endpoints) { + this(endpoints, null); + } + + /** + * Create a new {@link EndpointHandlerMapping} instance. All {@link Endpoint}s will be + * detected from the {@link ApplicationContext}. The endpoints will accepts CORS + * requests based on the given {@code corsConfiguration}. + * @param endpoints the endpoints + * @param corsConfiguration the CORS configuration for the endpoints + * @since 1.3.0 + */ + public EndpointHandlerMapping(Collection endpoints, + CorsConfiguration corsConfiguration) { this.endpoints = new HashSet(endpoints); + this.corsConfiguration = corsConfiguration; // By default the static resource handler mapping is LOWEST_PRECEDENCE - 1 // and the RequestMappingHandlerMapping is 0 (we ideally want to be before both) setOrder(-100); @@ -96,7 +113,7 @@ protected void registerHandlerMethod(Object handler, Method method, return; } String[] patterns = getPatterns(handler, mapping); - super.registerMapping(withNewPatterns(mapping, patterns), handler, method); + super.registerHandlerMethod(handler, method, withNewPatterns(mapping, patterns)); } private String[] getPatterns(Object handler, RequestMappingInfo mapping) { @@ -180,4 +197,9 @@ public Set getEndpoints() { return new HashSet(this.endpoints); } + @Override + protected CorsConfiguration initCorsConfiguration(Object handler, Method method, + RequestMappingInfo mappingInfo) { + return this.corsConfiguration; + } } diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/JolokiaMvcEndpoint.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/JolokiaMvcEndpoint.java index d95b3a4371c0..71152270bfc1 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/JolokiaMvcEndpoint.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/JolokiaMvcEndpoint.java @@ -71,7 +71,6 @@ public JolokiaMvcEndpoint() { this.path = "/jolokia"; this.controller.setServletClass(AgentServlet.class); this.controller.setServletName("jolokia"); - this.controller.setSupportedMethods("GET", "POST", "HEAD", "OPTIONS"); } @Override diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/JolokiaMvcEndpointTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/JolokiaMvcEndpointTests.java index d805f4d52c9b..9b02bb663c17 100644 --- a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/JolokiaMvcEndpointTests.java +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/JolokiaMvcEndpointTests.java @@ -32,7 +32,6 @@ import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.http.HttpHeaders; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; @@ -44,9 +43,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -54,7 +51,6 @@ * * @author Christian Dupuis * @author Dave Syer - * @author Andy Wilkinson */ @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = { Config.class }) @@ -103,15 +99,6 @@ public void list() throws Exception { .andExpect(content().string(containsString("NonHeapMemoryUsage"))); } - @Test - public void corsOptionsRequest() throws Exception { - this.mvc.perform( - options("/jolokia/read/java.lang:type=Memory").header(HttpHeaders.ORIGIN, - "example.com").header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, - "GET")).andExpect( - header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "example.com")); - } - @Configuration @EnableConfigurationProperties @EnableWebMvc diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/MvcEndpointCorsIntegrationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/MvcEndpointCorsIntegrationTests.java new file mode 100644 index 000000000000..ca068175d3f6 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/MvcEndpointCorsIntegrationTests.java @@ -0,0 +1,193 @@ +/* + * Copyright 2012-2015 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.endpoint.mvc; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.boot.actuate.autoconfigure.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.EndpointWebMvcAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.JolokiaAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.ManagementServerPropertiesAutoConfiguration; +import org.springframework.boot.autoconfigure.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.autoconfigure.web.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration; +import org.springframework.boot.test.EnvironmentTestUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for the actuator endpoints' CORS support + * + * @author Andy Wilkinson + */ +public class MvcEndpointCorsIntegrationTests { + + private AnnotationConfigWebApplicationContext context; + + @Before + public void createContext() { + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setServletContext(new MockServletContext()); + this.context.register(HttpMessageConvertersAutoConfiguration.class, + EndpointAutoConfiguration.class, EndpointWebMvcAutoConfiguration.class, + ManagementServerPropertiesAutoConfiguration.class, + PropertyPlaceholderAutoConfiguration.class, + JolokiaAutoConfiguration.class, WebMvcAutoConfiguration.class); + } + + @Test + public void corsIsDisabledByDefault() throws Exception { + createMockMvc().perform( + options("/beans").header("Origin", "foo.example.com").header( + HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")).andExpect( + header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + } + + @Test + public void settingAllowedOriginsEnablesCors() throws Exception { + EnvironmentTestUtils.addEnvironment(this.context, + "endpoints.cors.allowed-origins:foo.example.com"); + createMockMvc().perform( + options("/beans").header("Origin", "bar.example.com").header( + HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")).andExpect( + status().isForbidden()); + performAcceptedCorsRequest(); + } + + @Test + public void maxAgeDefaultsTo30Minutes() throws Exception { + EnvironmentTestUtils.addEnvironment(this.context, + "endpoints.cors.allowed-origins:foo.example.com"); + createMockMvc().perform( + options("/beans").header("Origin", "bar.example.com").header( + HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")).andExpect( + status().isForbidden()); + performAcceptedCorsRequest().andExpect( + header().string(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "1800")); + } + + @Test + public void maxAgeCanBeConfigured() throws Exception { + EnvironmentTestUtils.addEnvironment(this.context, + "endpoints.cors.allowed-origins:foo.example.com", + "endpoints.cors.max-age: 2400"); + performAcceptedCorsRequest().andExpect( + header().string(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "2400")); + } + + @Test + public void requestsWithDisallowedHeadersAreRejected() throws Exception { + EnvironmentTestUtils.addEnvironment(this.context, + "endpoints.cors.allowed-origins:foo.example.com"); + createMockMvc().perform( + options("/beans").header("Origin", "foo.example.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Alpha")) + .andExpect(status().isForbidden()); + } + + @Test + public void allowedHeadersCanBeConfigured() throws Exception { + EnvironmentTestUtils.addEnvironment(this.context, + "endpoints.cors.allowed-origins:foo.example.com", + "endpoints.cors.allowed-headers:Alpha,Bravo"); + createMockMvc() + .perform( + options("/beans") + .header("Origin", "foo.example.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, + "Alpha")) + .andExpect(status().isOk()) + .andExpect( + header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "Alpha")); + } + + @Test + public void requestsWithDisallowedMethodsAreRejected() throws Exception { + EnvironmentTestUtils.addEnvironment(this.context, + "endpoints.cors.allowed-origins:foo.example.com"); + createMockMvc().perform( + options("/health").header(HttpHeaders.ORIGIN, "foo.example.com").header( + HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "HEAD")).andExpect( + status().isForbidden()); + } + + @Test + public void allowedMethodsCanBeConfigured() throws Exception { + EnvironmentTestUtils.addEnvironment(this.context, + "endpoints.cors.allowed-origins:foo.example.com", + "endpoints.cors.allowed-methods:GET,HEAD"); + createMockMvc() + .perform( + options("/health") + .header(HttpHeaders.ORIGIN, "foo.example.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "HEAD")) + .andExpect(status().isOk()) + .andExpect( + header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, + "GET,HEAD")); + } + + @Test + public void credentialsCanBeAllowed() throws Exception { + EnvironmentTestUtils.addEnvironment(this.context, + "endpoints.cors.allowed-origins:foo.example.com", + "endpoints.cors.allow-credentials:true"); + performAcceptedCorsRequest().andExpect( + header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")); + } + + @Test + public void jolokiaEndpointUsesGlobalCorsConfiguration() throws Exception { + EnvironmentTestUtils.addEnvironment(this.context, + "endpoints.cors.allowed-origins:foo.example.com"); + createMockMvc().perform( + options("/jolokia").header("Origin", "bar.example.com").header( + HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")).andExpect( + status().isForbidden()); + performAcceptedCorsRequest("/jolokia"); + } + + private MockMvc createMockMvc() { + this.context.refresh(); + return MockMvcBuilders.webAppContextSetup(this.context).build(); + } + + private ResultActions performAcceptedCorsRequest() throws Exception { + return performAcceptedCorsRequest("/beans"); + } + + private ResultActions performAcceptedCorsRequest(String url) throws Exception { + return createMockMvc() + .perform( + options(url).header(HttpHeaders.ORIGIN, "foo.example.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")) + .andExpect( + header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, + "foo.example.com")).andExpect(status().isOk()); + } + +} diff --git a/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc b/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc index fc1f86f1dbc4..f50e23949811 100644 --- a/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc +++ b/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc @@ -551,6 +551,13 @@ content into your application; rather pick only the properties that you need. endpoints.trace.sensitive=true endpoints.trace.enabled=true + # ENDPOINTS CORS CONFIGURATION ({sc-spring-boot-actuator}/autoconfigure/MvcEndpointCorsProperties.{sc-ext}[MvcEndpointCorsProperties]) + endpoints.cors.allow-credentials= # whether user credentials are support. When not set, credentials are not supported. + endpoints.cors.allowed-origins= # comma-separated list of origins to allow. * allows all origins. When not set, CORS support is disabled. + endpoints.cors.allowed-methods= # comma-separated list of methods to allow. * allows all methods. When not set, defaults to GET. + endpoints.cors.allowed-headers= # comma-separated list of headers to allow in a request. * allows all headers. + endpoints.cors.exposed-headers= # comma-separated list of headers to include in a response. + # HEALTH INDICATORS (previously health.*) management.health.db.enabled=true management.health.elasticsearch.enabled=true