Skip to content

Commit

Permalink
Allow /health and /info to authenticate anonymously
Browse files Browse the repository at this point in the history
Then we can optionally find a non-anonymous principal if there
is one. If the user is anonymous then the health result is cached
up to endpoints.health.ttl (default 1000ms) to prevent a DOS attack.

Fixes gh-1353
  • Loading branch information
Dave Syer committed Oct 27, 2014
1 parent 43eda4c commit 24e71e8
Show file tree
Hide file tree
Showing 12 changed files with 191 additions and 38 deletions.
Expand Up @@ -141,11 +141,9 @@ public void init(WebSecurity builder) throws Exception {
// add them back.
List<String> ignored = SpringBootWebSecurityConfiguration
.getIgnored(this.security);
ignored.addAll(Arrays.asList(getEndpointPaths(this.endpointHandlerMapping,
false)));
if (!this.management.getSecurity().isEnabled()) {
ignored.addAll(Arrays.asList(getEndpointPaths(
this.endpointHandlerMapping, true)));
this.endpointHandlerMapping)));
}
if (ignored.contains("none")) {
ignored.remove("none");
Expand Down Expand Up @@ -220,7 +218,7 @@ protected static class ManagementWebSecurityConfigurerAdapter extends
protected void configure(HttpSecurity http) throws Exception {

// secure endpoints
String[] paths = getEndpointPaths(this.endpointHandlerMapping, true);
String[] paths = getEndpointPaths(this.endpointHandlerMapping);
if (paths.length > 0 && this.management.getSecurity().isEnabled()) {
// Always protect them if present
if (this.security.isRequireSsl()) {
Expand All @@ -229,10 +227,12 @@ protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling().authenticationEntryPoint(entryPoint());
paths = this.server.getPathsArray(paths);
http.requestMatchers().antMatchers(paths);
http.authorizeRequests().anyRequest()
.hasRole(this.management.getSecurity().getRole()) //
.and().httpBasic() //
.and().anonymous().disable();
// @formatter:off
http.authorizeRequests()
.antMatchers(this.server.getPathsArray(getEndpointPaths(this.endpointHandlerMapping, false))).access("permitAll()")
.anyRequest().hasRole(this.management.getSecurity().getRole());
// @formatter:on
http.httpBasic();

// No cookies for management endpoints by default
http.csrf().disable();
Expand All @@ -254,6 +254,12 @@ private AuthenticationEntryPoint entryPoint() {

}

private static String[] getEndpointPaths(EndpointHandlerMapping endpointHandlerMapping) {
return StringUtils.mergeStringArrays(
getEndpointPaths(endpointHandlerMapping, false),
getEndpointPaths(endpointHandlerMapping, true));
}

private static String[] getEndpointPaths(
EndpointHandlerMapping endpointHandlerMapping, boolean secure) {
if (endpointHandlerMapping == null) {
Expand All @@ -264,13 +270,11 @@ private static String[] getEndpointPaths(
List<String> paths = new ArrayList<String>(endpoints.size());
for (MvcEndpoint endpoint : endpoints) {
if (endpoint.isSensitive() == secure) {
String path = endpointHandlerMapping.getPrefix() + endpoint.getPath();
String path = endpointHandlerMapping.getPath(endpoint.getPath());
paths.add(path);
if (secure) {
// Add Spring MVC-generated additional paths
paths.add(path + "/");
paths.add(path + ".*");
}
// Add Spring MVC-generated additional paths
paths.add(path + "/");
paths.add(path + ".*");
}
}
return paths.toArray(new String[paths.size()]);
Expand Down
Expand Up @@ -202,6 +202,7 @@ private Map<String, Object> sanitize(Map<String, Object> map) {
* Extension to {@link JacksonAnnotationIntrospector} to suppress CGLIB generated bean
* properties.
*/
@SuppressWarnings("serial")
private static class CglibAnnotationIntrospector extends
JacksonAnnotationIntrospector {

Expand Down
Expand Up @@ -36,6 +36,22 @@ public class HealthEndpoint extends AbstractEndpoint<Health> {

private final HealthIndicator healthIndicator;

private long ttl = 1000;

/**
* Time to live for cached result. If accessed anonymously, we might need to cache the
* result of this endpoint to prevent a DOS attack.
*
* @return time to live in milliseconds (default 1000)
*/
public long getTtl() {
return ttl;
}

public void setTtl(long ttl) {
this.ttl = ttl;
}

/**
* Create a new {@link HealthIndicator} instance.
*/
Expand Down
Expand Up @@ -18,7 +18,9 @@

import java.lang.reflect.Method;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.springframework.boot.actuate.endpoint.Endpoint;
Expand Down Expand Up @@ -50,7 +52,7 @@
public class EndpointHandlerMapping extends RequestMappingHandlerMapping implements
ApplicationContextAware {

private final Set<? extends MvcEndpoint> endpoints;
private final Map<String, MvcEndpoint> endpoints = new HashMap<String, MvcEndpoint>();

private String prefix = "";

Expand All @@ -62,7 +64,10 @@ public class EndpointHandlerMapping extends RequestMappingHandlerMapping impleme
* @param endpoints
*/
public EndpointHandlerMapping(Collection<? extends MvcEndpoint> endpoints) {
this.endpoints = new HashSet<MvcEndpoint>(endpoints);
HashMap<String, MvcEndpoint> map = (HashMap<String, MvcEndpoint>) this.endpoints;
for (MvcEndpoint endpoint : endpoints) {
map.put(endpoint.getPath(), endpoint);
}
// 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);
Expand All @@ -72,7 +77,7 @@ public EndpointHandlerMapping(Collection<? extends MvcEndpoint> endpoints) {
public void afterPropertiesSet() {
super.afterPropertiesSet();
if (!this.disabled) {
for (MvcEndpoint endpoint : this.endpoints) {
for (MvcEndpoint endpoint : this.endpoints.values()) {
detectHandlerMethods(endpoint);
}
}
Expand Down Expand Up @@ -146,6 +151,13 @@ public String getPrefix() {
return this.prefix;
}

/**
* @return the path used in mappings
*/
public String getPath(String endpoint) {
return this.prefix + endpoint;
}

/**
* Sets if this mapping is disabled.
*/
Expand All @@ -164,7 +176,7 @@ public boolean isDisabled() {
* Return the endpoints
*/
public Set<? extends MvcEndpoint> getEndpoints() {
return this.endpoints;
return new HashSet<MvcEndpoint>(this.endpoints.values());
}

}
Expand Up @@ -56,6 +56,7 @@ public void setEnvironment(Environment environment) {
this.environment = environment;
}

@SuppressWarnings("serial")
@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "No such property")
public static class NoSuchPropertyException extends RuntimeException {

Expand Down
Expand Up @@ -16,10 +16,12 @@

package org.springframework.boot.actuate.endpoint.mvc;

import java.security.Principal;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import org.springframework.boot.actuate.endpoint.Endpoint;
import org.springframework.boot.actuate.endpoint.HealthEndpoint;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.Status;
Expand All @@ -33,14 +35,21 @@
* Adapter to expose {@link HealthEndpoint} as an {@link MvcEndpoint}.
*
* @author Christian Dupuis
* @author Dave Syer
* @since 1.1.0
*/
public class HealthMvcEndpoint extends EndpointMvcAdapter {
public class HealthMvcEndpoint implements MvcEndpoint {

private Map<String, HttpStatus> statusMapping = new HashMap<String, HttpStatus>();

private HealthEndpoint delegate;

private long lastAccess = 0;

private Health cached;

public HealthMvcEndpoint(HealthEndpoint delegate) {
super(delegate);
this.delegate = delegate;
setupDefaultStatusMapping();
}

Expand Down Expand Up @@ -91,21 +100,65 @@ public void addStatusMapping(String statusCode, HttpStatus httpStatus) {

@RequestMapping
@ResponseBody
@Override
public Object invoke() {
if (!this.getDelegate().isEnabled()) {
// Shouldn't happen
public Object invoke(Principal principal) {

if (!delegate.isEnabled()) {
// Shouldn't happen because the request mapping should not be registered
return new ResponseEntity<Map<String, String>>(Collections.singletonMap(
"message", "This endpoint is disabled"), HttpStatus.NOT_FOUND);
}

Health health = (Health) getDelegate().invoke();
Health health = getHealth(principal);
Status status = health.getStatus();
if (this.statusMapping.containsKey(status.getCode())) {
return new ResponseEntity<Health>(health, this.statusMapping.get(status
.getCode()));
}

return health;

}

private Health getHealth(Principal principal) {
Health health = useCachedValue(principal) ? cached : (Health) delegate.invoke();
// Not too worried about concurrent access here, the worst that can happen is the
// odd extra call to delegate.invoke()
cached = health;
if (!secure(principal)) {
// If not secure we only expose the status
health = Health.status(health.getStatus()).build();
}
return health;
}

private boolean secure(Principal principal) {
return principal != null && !principal.getClass().getName().contains("Anonymous");
}

private boolean useCachedValue(Principal principal) {
long currentAccess = System.currentTimeMillis();
if (cached == null || secure(principal)
|| currentAccess - lastAccess > delegate.getTtl()) {
lastAccess = currentAccess;
return false;
}
return cached != null;
}

@Override
public String getPath() {
return "/" + this.delegate.getId();
}

@Override
public boolean isSensitive() {
return this.delegate.isSensitive();
}

@Override
@SuppressWarnings("rawtypes")
public Class<? extends Endpoint> getEndpointType() {
return this.delegate.getClass();
}

}
Expand Up @@ -48,6 +48,7 @@ public Object value(@PathVariable String name) {
return value;
}

@SuppressWarnings("serial")
@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "No such metric")
public static class NoSuchMetricException extends RuntimeException {

Expand Down
Expand Up @@ -73,8 +73,8 @@ public void testWebConfiguration() throws Exception {
PropertyPlaceholderAutoConfiguration.class);
this.context.refresh();
assertNotNull(this.context.getBean(AuthenticationManagerBuilder.class));
// 6 for static resources, one for management endpoints and one for the rest
assertEquals(8, this.context.getBean(FilterChainProxy.class).getFilterChains()
// 4 for static resources, one for management endpoints and one for the rest
assertEquals(6, this.context.getBean(FilterChainProxy.class).getFilterChains()
.size());
}

Expand Down Expand Up @@ -144,7 +144,7 @@ public void testDisableBasicAuthOnApplicationPaths() throws Exception {
this.context.refresh();
// Just the management endpoints (one filter) and ignores now plus the backup
// filter on app endpoints
assertEquals(8, this.context.getBean(FilterChainProxy.class).getFilterChains()
assertEquals(6, this.context.getBean(FilterChainProxy.class).getFilterChains()
.size());
}

Expand Down

0 comments on commit 24e71e8

Please sign in to comment.