Skip to content

Commit

Permalink
Merge pull request #148 from xenit-eu/actuator-security-autoconfig
Browse files Browse the repository at this point in the history
Actuator security autoconfig
  • Loading branch information
tgeens authored Nov 29, 2023
2 parents 418a71c + 7180b39 commit 7cdd67d
Show file tree
Hide file tree
Showing 7 changed files with 367 additions and 2 deletions.
14 changes: 13 additions & 1 deletion contentgrid-spring-boot-autoconfigure/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ configurations {
testAnnotationProcessor {
extendsFrom(annotationProcessor)
}

testImplementation {
extendsFrom(compileOnly)
}
}

dependencies {
Expand All @@ -24,11 +28,15 @@ dependencies {
compileOnly project(':contentgrid-spring-integration-events')
compileOnly project(':contentgrid-spring-data-rest')

compileOnly 'org.springframework.security:spring-security-web'
compileOnly 'org.springframework.security:spring-security-config'
compileOnly 'org.springframework.integration:spring-integration-core'
compileOnly 'org.springframework.boot:spring-boot-actuator-autoconfigure'

compileOnly "com.github.paulcwarren:spring-content-autoconfigure"
compileOnly "com.github.paulcwarren:spring-content-s3"
compileOnly "com.github.paulcwarren:spring-content-rest"

compileOnly 'org.springframework.data:spring-data-rest-webmvc'
compileOnly 'jakarta.persistence:jakarta.persistence-api'
compileOnly 'org.flywaydb:flyway-core'
Expand All @@ -39,15 +47,19 @@ dependencies {

testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.data:spring-data-rest-webmvc'
testImplementation 'com.github.paulcwarren:spring-content-s3-boot-starter'
testImplementation 'com.github.paulcwarren:spring-content-rest-boot-starter'

testCompileOnly 'org.projectlombok:lombok'

testRuntimeOnly 'jakarta.servlet:jakarta.servlet-api'
testRuntimeOnly 'org.springframework.boot:spring-boot-starter-tomcat'
testRuntimeOnly 'org.springframework.boot:spring-boot-starter-data-jpa'
testRuntimeOnly 'org.springframework:spring-webflux'
testRuntimeOnly 'jakarta.servlet:jakarta.servlet-api'
testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'io.micrometer:micrometer-core'

}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.contentgrid.spring.boot.autoconfigure.security;

import jakarta.servlet.http.HttpServletRequest;
import java.net.InetAddress;
import java.net.UnknownHostException;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest.EndpointRequestMatcher;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.actuate.metrics.MetricsEndpoint;
import org.springframework.boot.actuate.metrics.export.prometheus.PrometheusScrapeEndpoint;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;


@AutoConfiguration(
after = {
HealthEndpointAutoConfiguration.class,
InfoEndpointAutoConfiguration.class,
WebEndpointAutoConfiguration.class,
SecurityAutoConfiguration.class
})
@ConditionalOnClass(value = {HealthEndpoint.class, SecurityFilterChain.class, HttpSecurity.class})
@ConditionalOnWebApplication(type = Type.SERVLET)
public class ActuatorEndpointsWebSecurityAutoConfiguration {

/**
* List of publicly accessible management endpoints
*/
private static final EndpointRequestMatcher PUBLIC_ENDPOINTS = EndpointRequest.to(
InfoEndpoint.class,
HealthEndpoint.class
);

/**
* List of management metrics endpoints, allowed when the management port and server port are different.
*/
private static final EndpointRequestMatcher METRICS_ENDPOINTS = EndpointRequest.to(
MetricsEndpoint.class,
PrometheusScrapeEndpoint.class
);

@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
SecurityFilterChain actuatorEndpointsSecurityFilterChain(HttpSecurity http, Environment environment)
throws Exception {

http.authorizeHttpRequests((requests) -> requests.requestMatchers(
PUBLIC_ENDPOINTS,
new AndRequestMatcher(
METRICS_ENDPOINTS,
request -> ManagementPortType.get(environment) == ManagementPortType.DIFFERENT
),
new AndRequestMatcher(
EndpointRequest.toAnyEndpoint(),
new LoopbackInetAddressMatcher()
)
)
.permitAll());

// all the other /actuator endpoints fall through
return http.build();
}

private static class LoopbackInetAddressMatcher implements RequestMatcher {

@Override
public boolean matches(HttpServletRequest request) {
return isLoopbackAddress(request.getRemoteAddr());
}

boolean isLoopbackAddress(String address) {
try {
var remoteAddress = InetAddress.getByName(address);
return remoteAddress.isLoopbackAddress();
} catch (UnknownHostException ex) {
return false;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ com.contentgrid.spring.boot.autoconfigure.actuator.ActuatorAutoConfiguration
com.contentgrid.spring.boot.autoconfigure.data.web.ContentGridSpringDataRestAutoConfiguration
com.contentgrid.spring.boot.autoconfigure.integration.EventsAutoConfiguration
com.contentgrid.spring.boot.autoconfigure.s3.S3RegionAutoConfiguration
com.contentgrid.spring.boot.autoconfigure.flyway.FlywayPostgresAutoConfiguration
com.contentgrid.spring.boot.autoconfigure.flyway.FlywayPostgresAutoConfiguration
com.contentgrid.spring.boot.autoconfigure.security.ActuatorEndpointsWebSecurityAutoConfiguration
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package com.contentgrid.spring.boot.autoconfigure.security;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;

import com.contentgrid.spring.boot.autoconfigure.security.ManagementContextSupplierConfiguration.ManagementContextSupplier;
import java.net.InetAddress;
import java.util.Objects;
import java.util.function.Consumer;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.env.EnvironmentEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.web.context.ServerPortInfoApplicationContextInitializer;
import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
import org.springframework.context.ApplicationContext;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.test.web.reactive.server.StatusAssertions;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.servlet.client.MockMvcWebTestClient;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder;
import org.springframework.test.web.servlet.setup.MockMvcConfigurer;
import org.springframework.test.web.servlet.setup.MockMvcConfigurerAdapter;
import org.springframework.web.context.WebApplicationContext;

@Slf4j
class ActuatorEndpointsWebSecurityAutoConfigurationTest {

static final String ACTUATOR_ROOT = "/actuator";
static final String ACTUATOR_HEALTH = "/actuator/health";
static final String ACTUATOR_INFO = "/actuator/info";
static final String ACTUATOR_METRICS = "/actuator/metrics";
static final String ACTUATOR_ENV = "/actuator/env";

static class AutoConfigs {

static final AutoConfigurations ACTUATORS = AutoConfigurations.of(
HealthContributorAutoConfiguration.class,
HealthEndpointAutoConfiguration.class,

InfoEndpointAutoConfiguration.class,

MetricsAutoConfiguration.class,
MetricsEndpointAutoConfiguration.class,
CompositeMeterRegistryAutoConfiguration.class,

EnvironmentEndpointAutoConfiguration.class,

EndpointAutoConfiguration.class,
WebMvcAutoConfiguration.class,
WebEndpointAutoConfiguration.class
);

static final AutoConfigurations MANAGEMENT = AutoConfigurations.of(
ManagementContextAutoConfiguration.class,
ServletManagementContextAutoConfiguration.class,
ServletWebServerFactoryAutoConfiguration.class,
DispatcherServletAutoConfiguration.class
);
}

WebApplicationContextRunner runner = new WebApplicationContextRunner(
AnnotationConfigServletWebServerApplicationContext::new)
.withPropertyValues(
"management.endpoints.web.exposure.include=*"
)
.withInitializer(new ServerPortInfoApplicationContextInitializer())
.withConfiguration(AutoConfigs.ACTUATORS)
.withConfiguration(AutoConfigs.MANAGEMENT)
.withConfiguration(AutoConfigurations.of(
SecurityAutoConfiguration.class,
ManagementWebSecurityAutoConfiguration.class,
ActuatorEndpointsWebSecurityAutoConfiguration.class)
);

@Test
void whenAccessFromRemoteAddress() {

runner.run(context -> {
assertThat(context)
.hasNotFailed()
.hasBean("actuatorEndpointsSecurityFilterChain");

withWebTestClient(context, remoteAddress("192.0.2.1"), assertHttp -> {
// public endpoints
assertHttp.get(ACTUATOR_HEALTH).isOk();
assertHttp.get(ACTUATOR_INFO).isOk();

// management on primary server port
assertHttp.get(ACTUATOR_METRICS).isForbidden();

// other endpoints fall through if not from loopback-address
assertHttp.get(ACTUATOR_ENV).isForbidden();

// root forbidden
assertHttp.get(ACTUATOR_ROOT).isForbidden();
});
});
}

@Test
void whenManagementOnDifferentPort() {

runner.withPropertyValues("management.server.port=0")

.run(context -> {

assertThat(context)
.hasNotFailed()
.hasBean("actuatorEndpointsSecurityFilterChain");

withWebTestClient(context, remoteAddress("192.0.2.1"), assertHttp -> {
// public endpoints
assertHttp.get(ACTUATOR_HEALTH).isOk();
assertHttp.get(ACTUATOR_INFO).isOk();

// management on different port
assertHttp.get(ACTUATOR_METRICS).isOk();

// other endpoints fall through if not from loopback-address
assertHttp.get(ACTUATOR_ENV).isForbidden();

// root forbidden
assertHttp.get(ACTUATOR_ROOT).isForbidden();
});
});
}

@Test
void whenAccessFromLocalAddress() {

runner.run(context -> {
assertThat(context)
.hasNotFailed()
.hasBean("actuatorEndpointsSecurityFilterChain");

withWebTestClient(context, remoteAddress("localhost"), assertHttp -> {
// all /actuator endpoitns allowed when from a loopback address
assertHttp.get(ACTUATOR_HEALTH).isOk();
assertHttp.get(ACTUATOR_INFO).isOk();
assertHttp.get(ACTUATOR_METRICS).isOk();
assertHttp.get(ACTUATOR_ENV).isOk();

assertHttp.get(ACTUATOR_ROOT).isOk();
});
});
}

private void withWebTestClient(ApplicationContext context, MockMvcConfigurer remoteAddress,
Consumer<HttpAssertClient> callback) {
WebTestClient client;

var managementContext = context.getBean(ManagementContextSupplier.class).get();
client = MockMvcWebTestClient.bindToApplicationContext(Objects.requireNonNull(managementContext))
.apply(remoteAddress)
.apply(springSecurity())
.build();

callback.accept((@NonNull String endpoint) ->
client.get().uri(endpoint).accept(MediaType.APPLICATION_JSON).exchange().expectStatus());
}

@FunctionalInterface
interface HttpAssertClient {

StatusAssertions get(String endpoint);
}

@SneakyThrows
private MockMvcConfigurer remoteAddress(String remoteAddress) {
var address = InetAddress.getByName(remoteAddress);
return new RemoteAddressMockMvcConfigurer(address);
}

@RequiredArgsConstructor
static class RemoteAddressMockMvcConfigurer extends MockMvcConfigurerAdapter {

@NonNull
final InetAddress remoteAddress;

@Override
@Nullable
public RequestPostProcessor beforeMockMvcCreated(ConfigurableMockMvcBuilder<?> builder,
WebApplicationContext cxt) {
return request -> {
request.setRemoteAddr(remoteAddress.getHostAddress());
request.setRemoteHost(remoteAddress.getHostName());
return request;
};
}
}
}
Loading

0 comments on commit 7cdd67d

Please sign in to comment.