Allow the user to opt-out of anonymous access restrictions for /health
By default, when /health is accessed anonymously, the details are stripped, i.e. the response will only indicate UP or DOWN. Furthermore the response is cached for a configurable period to prevent a denial of service attack. This commit adds a configuration property, endpoints.health.restrict-anonymous-access, that can be set to false to allow full anonymous access to /health. When full access is allowed, the details will be included in the response and the response will not be cached. Closes gh-1977
This commit is contained in:
parent
5854ea189e
commit
26a511495e
|
|
@ -30,6 +30,7 @@ import org.springframework.util.Assert;
|
||||||
*
|
*
|
||||||
* @author Dave Syer
|
* @author Dave Syer
|
||||||
* @author Christian Dupuis
|
* @author Christian Dupuis
|
||||||
|
* @author Andy Wilkinson
|
||||||
*/
|
*/
|
||||||
@ConfigurationProperties(prefix = "endpoints.health", ignoreUnknownFields = true)
|
@ConfigurationProperties(prefix = "endpoints.health", ignoreUnknownFields = true)
|
||||||
public class HealthEndpoint extends AbstractEndpoint<Health> {
|
public class HealthEndpoint extends AbstractEndpoint<Health> {
|
||||||
|
|
@ -38,18 +39,7 @@ public class HealthEndpoint extends AbstractEndpoint<Health> {
|
||||||
|
|
||||||
private long timeToLive = 1000;
|
private long timeToLive = 1000;
|
||||||
|
|
||||||
/**
|
private boolean restrictAnonymousAccess = true;
|
||||||
* 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 getTimeToLive() {
|
|
||||||
return this.timeToLive;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTimeToLive(long ttl) {
|
|
||||||
this.timeToLive = ttl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new {@link HealthIndicator} instance.
|
* Create a new {@link HealthIndicator} instance.
|
||||||
|
|
@ -69,6 +59,27 @@ public class HealthEndpoint extends AbstractEndpoint<Health> {
|
||||||
this.healthIndicator = healthIndicator;
|
this.healthIndicator = healthIndicator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 getTimeToLive() {
|
||||||
|
return this.timeToLive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeToLive(long ttl) {
|
||||||
|
this.timeToLive = ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRestrictAnonymousAccess() {
|
||||||
|
return this.restrictAnonymousAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRestrictAnonymousAccess(boolean restrictAnonymousAccess) {
|
||||||
|
this.restrictAnonymousAccess = restrictAnonymousAccess;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoke all {@link HealthIndicator} delegates and collect their health information.
|
* Invoke all {@link HealthIndicator} delegates and collect their health information.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
*
|
*
|
||||||
* @author Christian Dupuis
|
* @author Christian Dupuis
|
||||||
* @author Dave Syer
|
* @author Dave Syer
|
||||||
|
* @author Andy Wilkinson
|
||||||
* @since 1.1.0
|
* @since 1.1.0
|
||||||
*/
|
*/
|
||||||
public class HealthMvcEndpoint implements MvcEndpoint {
|
public class HealthMvcEndpoint implements MvcEndpoint {
|
||||||
|
|
@ -121,7 +122,7 @@ public class HealthMvcEndpoint implements MvcEndpoint {
|
||||||
// Not too worried about concurrent access here, the worst that can happen is the
|
// Not too worried about concurrent access here, the worst that can happen is the
|
||||||
// odd extra call to delegate.invoke()
|
// odd extra call to delegate.invoke()
|
||||||
this.cached = health;
|
this.cached = health;
|
||||||
if (!secure(principal)) {
|
if (this.delegate.isRestrictAnonymousAccess() && !secure(principal)) {
|
||||||
// If not secure we only expose the status
|
// If not secure we only expose the status
|
||||||
health = Health.status(health.getStatus()).build();
|
health = Health.status(health.getStatus()).build();
|
||||||
}
|
}
|
||||||
|
|
@ -133,15 +134,20 @@ public class HealthMvcEndpoint implements MvcEndpoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean useCachedValue(Principal principal) {
|
private boolean useCachedValue(Principal principal) {
|
||||||
long currentAccess = System.currentTimeMillis();
|
long accessTime = System.currentTimeMillis();
|
||||||
if (this.cached == null || secure(principal)
|
if (cacheIsStale(accessTime) || secure(principal)
|
||||||
|| (currentAccess - this.lastAccess) > this.delegate.getTimeToLive()) {
|
|| !this.delegate.isRestrictAnonymousAccess()) {
|
||||||
this.lastAccess = currentAccess;
|
this.lastAccess = accessTime;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.cached != null;
|
return this.cached != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean cacheIsStale(long accessTime) {
|
||||||
|
return this.cached == null
|
||||||
|
|| (accessTime - this.lastAccess) > this.delegate.getTimeToLive();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getPath() {
|
public String getPath() {
|
||||||
return "/" + this.delegate.getId();
|
return "/" + this.delegate.getId();
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.authority.AuthorityUtils;
|
import org.springframework.security.core.authority.AuthorityUtils;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
|
import static org.hamcrest.Matchers.is;
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertThat;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
import static org.mockito.BDDMockito.given;
|
import static org.mockito.BDDMockito.given;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
@ -93,6 +96,7 @@ public class HealthMvcEndpointTests {
|
||||||
public void secure() {
|
public void secure() {
|
||||||
given(this.endpoint.invoke()).willReturn(
|
given(this.endpoint.invoke()).willReturn(
|
||||||
new Health.Builder().up().withDetail("foo", "bar").build());
|
new Health.Builder().up().withDetail("foo", "bar").build());
|
||||||
|
given(this.endpoint.isRestrictAnonymousAccess()).willReturn(true);
|
||||||
Object result = this.mvc.invoke(this.user);
|
Object result = this.mvc.invoke(this.user);
|
||||||
assertTrue(result instanceof Health);
|
assertTrue(result instanceof Health);
|
||||||
assertTrue(((Health) result).getStatus() == Status.UP);
|
assertTrue(((Health) result).getStatus() == Status.UP);
|
||||||
|
|
@ -102,6 +106,7 @@ public class HealthMvcEndpointTests {
|
||||||
@Test
|
@Test
|
||||||
public void secureNotCached() {
|
public void secureNotCached() {
|
||||||
given(this.endpoint.getTimeToLive()).willReturn(10000L);
|
given(this.endpoint.getTimeToLive()).willReturn(10000L);
|
||||||
|
given(this.endpoint.isRestrictAnonymousAccess()).willReturn(true);
|
||||||
given(this.endpoint.invoke()).willReturn(
|
given(this.endpoint.invoke()).willReturn(
|
||||||
new Health.Builder().up().withDetail("foo", "bar").build());
|
new Health.Builder().up().withDetail("foo", "bar").build());
|
||||||
Object result = this.mvc.invoke(this.user);
|
Object result = this.mvc.invoke(this.user);
|
||||||
|
|
@ -117,16 +122,66 @@ public class HealthMvcEndpointTests {
|
||||||
@Test
|
@Test
|
||||||
public void unsecureCached() {
|
public void unsecureCached() {
|
||||||
given(this.endpoint.getTimeToLive()).willReturn(10000L);
|
given(this.endpoint.getTimeToLive()).willReturn(10000L);
|
||||||
|
given(this.endpoint.isRestrictAnonymousAccess()).willReturn(true);
|
||||||
given(this.endpoint.invoke()).willReturn(
|
given(this.endpoint.invoke()).willReturn(
|
||||||
new Health.Builder().up().withDetail("foo", "bar").build());
|
new Health.Builder().up().withDetail("foo", "bar").build());
|
||||||
Object result = this.mvc.invoke(this.user);
|
Object result = this.mvc.invoke(this.user);
|
||||||
assertTrue(result instanceof Health);
|
assertTrue(result instanceof Health);
|
||||||
assertTrue(((Health) result).getStatus() == Status.UP);
|
Health health = (Health) result;
|
||||||
|
assertTrue(health.getStatus() == Status.UP);
|
||||||
|
assertThat(health.getDetails().size(), is(equalTo(1)));
|
||||||
|
assertThat(health.getDetails().get("foo"), is(equalTo((Object) "bar")));
|
||||||
given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build());
|
given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build());
|
||||||
result = this.mvc.invoke(null); // insecure now
|
result = this.mvc.invoke(null); // insecure now
|
||||||
Health health = (Health) result;
|
assertTrue(result instanceof Health);
|
||||||
|
health = (Health) result;
|
||||||
// so the result is cached
|
// so the result is cached
|
||||||
assertTrue(health.getStatus() == Status.UP);
|
assertTrue(health.getStatus() == Status.UP);
|
||||||
|
// but the details are hidden
|
||||||
|
assertThat(health.getDetails().size(), is(equalTo(0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void unsecureAnonymousAccessUnrestricted() {
|
||||||
|
given(this.endpoint.invoke()).willReturn(
|
||||||
|
new Health.Builder().up().withDetail("foo", "bar").build());
|
||||||
|
given(this.endpoint.isRestrictAnonymousAccess()).willReturn(false);
|
||||||
|
Object result = this.mvc.invoke(null);
|
||||||
|
assertTrue(result instanceof Health);
|
||||||
|
assertTrue(((Health) result).getStatus() == Status.UP);
|
||||||
|
assertEquals("bar", ((Health) result).getDetails().get("foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void unsecureIsNotCachedWhenAnonymousAccessIsUnrestricted() {
|
||||||
|
given(this.endpoint.getTimeToLive()).willReturn(10000L);
|
||||||
|
given(this.endpoint.isRestrictAnonymousAccess()).willReturn(false);
|
||||||
|
given(this.endpoint.invoke()).willReturn(
|
||||||
|
new Health.Builder().up().withDetail("foo", "bar").build());
|
||||||
|
Object result = this.mvc.invoke(null);
|
||||||
|
assertTrue(result instanceof Health);
|
||||||
|
assertTrue(((Health) result).getStatus() == Status.UP);
|
||||||
|
given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build());
|
||||||
|
result = this.mvc.invoke(null);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Health health = ((ResponseEntity<Health>) result).getBody();
|
||||||
|
assertTrue(health.getStatus() == Status.DOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void newValueIsReturnedOnceTtlExpires() throws InterruptedException {
|
||||||
|
given(this.endpoint.getTimeToLive()).willReturn(50L);
|
||||||
|
given(this.endpoint.isRestrictAnonymousAccess()).willReturn(true);
|
||||||
|
given(this.endpoint.invoke()).willReturn(
|
||||||
|
new Health.Builder().up().withDetail("foo", "bar").build());
|
||||||
|
Object result = this.mvc.invoke(null);
|
||||||
|
assertTrue(result instanceof Health);
|
||||||
|
assertTrue(((Health) result).getStatus() == Status.UP);
|
||||||
|
Thread.sleep(100);
|
||||||
|
given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build());
|
||||||
|
result = this.mvc.invoke(null);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Health health = ((ResponseEntity<Health>) result).getBody();
|
||||||
|
assertTrue(health.getStatus() == Status.DOWN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,7 @@ content into your application; rather pick only the properties that you need.
|
||||||
endpoints.health.id=health
|
endpoints.health.id=health
|
||||||
endpoints.health.sensitive=false
|
endpoints.health.sensitive=false
|
||||||
endpoints.health.enabled=true
|
endpoints.health.enabled=true
|
||||||
|
endpoints.health.restrict-anonymous-access=true
|
||||||
endpoints.health.time-to-live=1000
|
endpoints.health.time-to-live=1000
|
||||||
endpoints.info.id=info
|
endpoints.info.id=info
|
||||||
endpoints.info.sensitive=false
|
endpoints.info.sensitive=false
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue