Commit 84d3a34c authored by Andy Wilkinson's avatar Andy Wilkinson

Add CORS support to the actuator’s endpoints

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 (57a51ed2) 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
parent b4662292
......@@ -103,7 +103,8 @@ import org.springframework.web.servlet.DispatcherServlet;
@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<EndpointHandlerMappingCustomizer> mappingCustomizers;
......@@ -130,7 +134,7 @@ public class EndpointWebMvcAutoConfiguration implements ApplicationContextAware,
@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) {
......
/*
* 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<String> allowedOrigins = new ArrayList<String>();
/**
* List of methods to allow.
*/
private List<String> allowedMethods = new ArrayList<String>();
/**
* List of headers to allow in a request
*/
private List<String> allowedHeaders = new ArrayList<String>();
/**
* List of headers to include in a response.
*/
private List<String> exposedHeaders = new ArrayList<String>();
/**
* 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<String> getAllowedOrigins() {
return this.allowedOrigins;
}
public void setAllowedOrigins(List<String> allowedOrigins) {
this.allowedOrigins = allowedOrigins;
}
public List<String> getAllowedMethods() {
return this.allowedMethods;
}
public void setAllowedMethods(List<String> allowedMethods) {
this.allowedMethods = allowedMethods;
}
public List<String> getAllowedHeaders() {
return this.allowedHeaders;
}
public void setAllowedHeaders(List<String> allowedHeaders) {
this.allowedHeaders = allowedHeaders;
}
public List<String> getExposedHeaders() {
return this.exposedHeaders;
}
public void setExposedHeaders(List<String> 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;
}
}
......@@ -28,6 +28,7 @@ import org.springframework.context.ApplicationContext;
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 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandl
* @author Phillip Webb
* @author Christian Dupuis
* @author Dave Syer
* @author Andy Wilkinson
*/
public class EndpointHandlerMapping extends RequestMappingHandlerMapping implements
ApplicationContextAware {
private final Set<MvcEndpoint> 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<? extends MvcEndpoint> 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<? extends MvcEndpoint> endpoints,
CorsConfiguration corsConfiguration) {
this.endpoints = new HashSet<MvcEndpoint>(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 @@ public class EndpointHandlerMapping extends RequestMappingHandlerMapping impleme
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 class EndpointHandlerMapping extends RequestMappingHandlerMapping impleme
return new HashSet<MvcEndpoint>(this.endpoints);
}
@Override
protected CorsConfiguration initCorsConfiguration(Object handler, Method method,
RequestMappingInfo mappingInfo) {
return this.corsConfiguration;
}
}
......@@ -71,7 +71,6 @@ public class JolokiaMvcEndpoint implements MvcEndpoint, InitializingBean,
this.path = "/jolokia";
this.controller.setServletClass(AgentServlet.class);
this.controller.setServletName("jolokia");
this.controller.setSupportedMethods("GET", "POST", "HEAD", "OPTIONS");
}
@Override
......
......@@ -32,7 +32,6 @@ import org.springframework.boot.test.SpringApplicationConfiguration;
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.hamcrest.Matchers.containsString;
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 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
*
* @author Christian Dupuis
* @author Dave Syer
* @author Andy Wilkinson
*/
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = { Config.class })
......@@ -103,15 +99,6 @@ public class JolokiaMvcEndpointTests {
.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
......
/*
* 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());
}
}
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment