diff --git a/docs/index.md b/docs/index.md index d24cde40c..fac3a3c79 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,12 +5,12 @@ specifications promoted by the SDA SE. ## Features -| **Starter** | **Description** | -|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [sda-commons-starter-web](web/index.md) | Provides the required features for an SDA-compliant microservice including OIDC authentication, OPA authorization, health checks, OpenTracing and Prometheus metrics. | -| [sda-commons-starter-mongodb](mongodb/index.md) | Provides default configuration based on the `org.springframework.boot:spring-boot-starter-data-mongodb` | -| [sda-commons-starter-kafka](kafka/index.md) | Provides default producer und consumer configuration based on `org.springframework.kafka:spring-kafka` | -| [sda-commons-starter-s3](s3/index.md) | Provides features for dealing with the Amazon S3 file storage | +| **Starter** | **Description** | +|-------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [sda-commons-starter-web](web/index.md) | Provides the required features for an SDA-compliant microservice including OIDC authentication, OPA authorization, health checks, OpenTelemetry, Prometheus metrics and [hardening the service](security/index.md). | +| [sda-commons-starter-mongodb](mongodb/index.md) | Provides default configuration based on the `org.springframework.boot:spring-boot-starter-data-mongodb` | +| [sda-commons-starter-kafka](kafka/index.md) | Provides default producer und consumer configuration based on `org.springframework.kafka:spring-kafka` | +| [sda-commons-starter-s3](s3/index.md) | Provides features for dealing with the Amazon S3 file storage | The provided documentation aims to provide SDA-specific information. All other information are referenced in the Spring and [Spring Boot documentation](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#documentation). diff --git a/docs/security/index.md b/docs/security/index.md new file mode 100644 index 000000000..e3321bcf8 --- /dev/null +++ b/docs/security/index.md @@ -0,0 +1,69 @@ +# Security Hardening + +sda-spring-boot-commons changes some default configuration for security reasons. +This document provides a brief overview about the addressed risks. + +## Risk: Accessing critical resources from untrusted environments + +To avoid exposing internal resources, Spring Boot Actuator is configured to listen on a separate +port. +Health, metrics and other sensitive information can't be exposed to the internet by accident, e.g. +by missing to exclude the actuator path. + +Custom critical resources can be exposed at the management port by implementing +`org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint` or +`org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint`. +Note that there is an [open discussion](https://github.com/spring-projects/spring-boot/issues/31768) +about these annotations. +As long as they are not deprecated, it is suggested to use them because the use is most similar to +controllers used in regular REST APIs. + +## Risk: Root start + +If the service is started with extended privileges as the root user, an attacker can more easily +attack the operating system after taking over from the container. + +The default configuration is capable to run as no root, listening to ports 8080 and 8081. +Deployment checks must ensure, that the container is not configured with a root user. + +## Risk: Exploitation of HTTP methods + +The HTTP method `TRACE` is disabled by default to mitigate [Cross Site Tracing](https://owasp.org/www-community/attacks/Cross_Site_Tracing). + +## Risk: Loss of source IP address + +We expect, the services built with sda-spring-boot-commons are deployed behind a proxy, e.g. an +Ingress in Kubernetes. + +This library is configured by default to consider `X-Forwarded-*` headers to identify the original +caller. + +## Risk: Detection of confidential components + +Knowing the components used in a software makes it easier to look for and exploit specific CVEs. + +Custom error handlers and other configurations are used to avoid identifiable default output from +the framework and its components. + +## Risk: Lack of visibility + +If there is no visibility, there is no response to an abusive action and attackers can explore risks +undisturbed. + +Logs are written to standard out by default to comply with Kubernetes environments. +Prometheus metrics are exposed as expected by SDA environments. + +## Risk: Buffer Overflow + +The size of request and response headers is limited to 8KiB. + +## Header + +By configuring the default headers, the following risks are addressed: + +- Cross-Site Scripting +- Content interpretation by the browser +- Content loading in Flash and PDFs +- Clickjacking +- Sharing visited URLs with third parties +- Abuse from Cross-Origin Resource Sharing diff --git a/docs/web/index.md b/docs/web/index.md index 3c0fc2a12..53d4fc104 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -11,8 +11,7 @@ Features: - [Jackson Object Mapping](#jackson) - [Monitoring](#monitoring) - [Tracing](#tracing) - - [Health Checks](#health-checks) - - [Testing](#testing) + - [Health Checks](#health-checks--actuator) - [Logging](#logging) Based on: @@ -41,6 +40,7 @@ Based on: | `oidc.client.id` _string_ | The client ID for the registration. | `` | `exampleClient` | `OPA_CLIENT_ID` | | `oid.client.secret` _string_ | The Client secret of the registration. | `` | `s3cret` | `OIDC_CLIENT_SECRET` | | `oidc.client.issuer.uri` _string_ | URI that can either be an OpenID Connect discovery endpoint or an OAuth 2.0 Authorization Server Metadata endpoint defined by RFC 8414. | `` | `https://keycloak.sdadev.sda-se.io/auth/realms/exampleRealm` | `OIDC_CLIENT_ISSUER_URI` | +| `cors.allowed-origin-patterns` _string_ | Comma separated list of URL patterns for which CORS requests are allowed. | _none allowed_ | `https://*.all-subdomains.com, https://static-domain.com` | `CORS_ALLOWEDORIGINPATTERNS` | For further information have a look at the [Spring Boot documentation](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#documentation). @@ -111,9 +111,10 @@ public class MyConstraints extends AbstractConstraints { ```java @RestController public class AuthTestApp { - - @Autowired private MyConstraints myConstraints; - ... + @Autowired + private MyConstraints myConstraints; + // ... +} ``` ### Testing @@ -176,7 +177,7 @@ allow { # set some example constraints constraint1 := true # always true constraint2 := [ "v2.1", "v2.2" ] # always an array of "v2.1" and "v2.2" -constraint3[token.payload.sub]. # always a set that contains the 'sub' claim from the token +constraint3[token.payload.sub] # always a set that contains the 'sub' claim from the token # or is empty if no token is present ``` @@ -543,6 +544,3 @@ The Spring Boot default is enabled. * `classpath:org/sdase/commons/spring/logging/logback-json.xml` for Json Logging * Example: `classpath:org/sdase/commons/spring/logging/logback-json.xml` * Default: `org/springframework/boot/logging/logback/defaults.xml` - - -## Testing \ No newline at end of file diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/EnableSdaPlatform.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/EnableSdaPlatform.java index ba966139a..99183ccc5 100644 --- a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/EnableSdaPlatform.java +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/EnableSdaPlatform.java @@ -17,6 +17,7 @@ import org.sdase.commons.spring.boot.web.docs.EnableSdaDocs; import org.sdase.commons.spring.boot.web.jackson.EnableSdaRestGuide; import org.sdase.commons.spring.boot.web.monitoring.EnableSdaMonitoring; +import org.sdase.commons.spring.boot.web.security.EnableSdaWebSecurity; import org.springframework.context.annotation.Import; /** @@ -38,8 +39,9 @@ */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) -@EnableSdaDocs @EnableSdaRestGuide +@EnableSdaWebSecurity +@EnableSdaDocs @EnableSdaSecurity @EnableSdaClients @EnableSdaAsyncWithRequestContext diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaAccessDecisionManager.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaAccessDecisionManager.java index b6ff6eee5..03170dbd0 100644 --- a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaAccessDecisionManager.java +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaAccessDecisionManager.java @@ -8,6 +8,7 @@ package org.sdase.commons.spring.boot.web.auth; import java.util.List; +import org.sdase.commons.spring.boot.web.auth.management.ManagementAccessDecisionVoter; import org.sdase.commons.spring.boot.web.auth.opa.OpaAccessDecisionVoter; import org.sdase.commons.spring.boot.web.auth.opa.OpaExcludesDecisionVoter; import org.springframework.security.access.vote.UnanimousBased; @@ -17,8 +18,9 @@ public class SdaAccessDecisionManager extends UnanimousBased { public SdaAccessDecisionManager( + ManagementAccessDecisionVoter managementAccessDecisionVoter, OpaExcludesDecisionVoter opaExcludesDecisionVoter, OpaAccessDecisionVoter opaAccessDecisionVoter) { - super(List.of(opaExcludesDecisionVoter, opaAccessDecisionVoter)); + super(List.of(managementAccessDecisionVoter, opaExcludesDecisionVoter, opaAccessDecisionVoter)); } } diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaMonitoringSecurityConfiguration.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaMonitoringSecurityConfiguration.java deleted file mode 100644 index 82a44f977..000000000 --- a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaMonitoringSecurityConfiguration.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -package org.sdase.commons.spring.boot.web.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; - -@AutoConfiguration -public class SdaMonitoringSecurityConfiguration { - - private static final Logger LOG = - LoggerFactory.getLogger(SdaMonitoringSecurityConfiguration.class); - - private final int managementPort; - - public SdaMonitoringSecurityConfiguration( - @Value("${management.server.port}") int managementPort) { - this.managementPort = managementPort; - } - - @Bean - public WebSecurityCustomizer webSecurityCustomizer() { - LOG.info("Enabling Monitoring on port '{}'", managementPort); - return web -> - web.ignoring().requestMatchers(request -> request.getLocalPort() == managementPort); - } -} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaSecurityConfiguration.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaSecurityConfiguration.java index c6cbe132f..891b9a069 100644 --- a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaSecurityConfiguration.java +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/SdaSecurityConfiguration.java @@ -8,8 +8,10 @@ package org.sdase.commons.spring.boot.web.auth; import java.util.List; +import java.util.Optional; import java.util.stream.Stream; import javax.servlet.http.HttpServletRequest; +import org.sdase.commons.spring.boot.web.security.headers.SdaSecurityHeaders; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -26,6 +28,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.header.writers.StaticHeadersWriter; import org.springframework.util.StringUtils; @EnableWebSecurity @@ -41,6 +44,8 @@ public class SdaSecurityConfiguration { private final SdaAccessDecisionManager sdaAccessDecisionManager; + private final SdaSecurityHeaders sdaSecurityHeaders; + /** * @param issuers Comma separated string of open id discovery key sources with required issuers. * @param disableAuthentication Disables all authentication @@ -50,10 +55,12 @@ public class SdaSecurityConfiguration { public SdaSecurityConfiguration( @Value("${auth.issuers:}") String issuers, @Value("${auth.disable:false}") boolean disableAuthentication, - SdaAccessDecisionManager sdaAccessDecisionManager) { + SdaAccessDecisionManager sdaAccessDecisionManager, + Optional sdaSecurityHeaders) { this.issuers = issuers; this.disableAuthentication = disableAuthentication; this.sdaAccessDecisionManager = sdaAccessDecisionManager; + this.sdaSecurityHeaders = sdaSecurityHeaders.orElse(List::of); } @Bean @@ -82,7 +89,11 @@ private void oidcAuthentication(HttpSecurity http) throws Exception { authorize -> authorize.anyRequest().permitAll().accessDecisionManager(sdaAccessDecisionManager)) .oauth2ResourceServer( - oauth2 -> oauth2.authenticationManagerResolver(authenticationManagerResolver)); + oauth2 -> oauth2.authenticationManagerResolver(authenticationManagerResolver)) + .headers( + configurer -> + configurer.addHeaderWriter( + new StaticHeadersWriter(sdaSecurityHeaders.getSecurityHeaders()))); } private AuthenticationManagerResolver createAuthenticationManagerResolver() { @@ -111,7 +122,11 @@ private void noAuthentication(HttpSecurity http) throws Exception { .disable() // NOSONAR .authorizeRequests( authorize -> - authorize.anyRequest().permitAll().accessDecisionManager(sdaAccessDecisionManager)); + authorize.anyRequest().permitAll().accessDecisionManager(sdaAccessDecisionManager)) + .headers( + configurer -> + configurer.addHeaderWriter( + new StaticHeadersWriter(sdaSecurityHeaders.getSecurityHeaders()))); } private List commaSeparatedStringToList(String issuers) { diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/management/ManagementAccessDecisionVoter.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/management/ManagementAccessDecisionVoter.java new file mode 100644 index 000000000..39bd80b89 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/auth/management/ManagementAccessDecisionVoter.java @@ -0,0 +1,84 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.auth.management; + +import java.util.Collection; +import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; +import org.springframework.boot.web.servlet.context.ServletWebServerInitializedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.security.access.AccessDecisionVoter; +import org.springframework.security.access.ConfigAttribute; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.FilterInvocation; +import org.springframework.stereotype.Component; + +public interface ManagementAccessDecisionVoter extends AccessDecisionVoter { + + @Override + default boolean supports(ConfigAttribute attribute) { + return true; + } + + @Override + default boolean supports(Class clazz) { + return FilterInvocation.class.isAssignableFrom(clazz); + } + + @Component + @ConditionalOnManagementPort(ManagementPortType.DIFFERENT) + class DifferentPortManagementAccessDecisionVoter implements ManagementAccessDecisionVoter { + + /** + * The management port discovered in {@link + * #onApplicationEvent(ServletWebServerInitializedEvent)}. Initially a value that can't be an + * existing port to avoid granting access by accident to the application API. + */ + private int managementPort = -1; + + @EventListener + public void onApplicationEvent(ServletWebServerInitializedEvent event) { + if ("management".equals(event.getApplicationContext().getServerNamespace())) { + this.managementPort = event.getWebServer().getPort(); + } + } + + @Override + public int vote( + Authentication authentication, + FilterInvocation filterInvocation, + Collection attributes) { + int requestLocalPort = filterInvocation.getRequest().getLocalPort(); + return requestLocalPort == this.managementPort ? ACCESS_GRANTED : ACCESS_ABSTAIN; + } + } + + @Component + @ConditionalOnManagementPort(ManagementPortType.SAME) + class IgnoreSamePortManagementAccessDecisionVoter implements ManagementAccessDecisionVoter { + @Override + public int vote( + Authentication authentication, + FilterInvocation filterInvocation, + Collection attributes) { + return ACCESS_ABSTAIN; + } + } + + @Component + @ConditionalOnManagementPort(ManagementPortType.DISABLED) + class DisabledManagementAccessDecisionVoter implements ManagementAccessDecisionVoter { + @Override + public int vote( + Authentication authentication, + FilterInvocation filterInvocation, + Collection attributes) { + return ACCESS_ABSTAIN; + } + } +} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/error/ApiExceptionHandler.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/error/ApiExceptionHandler.java index 529a02109..bade1b49e 100644 --- a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/error/ApiExceptionHandler.java +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/error/ApiExceptionHandler.java @@ -8,6 +8,8 @@ package org.sdase.commons.spring.boot.web.error; import javax.servlet.http.HttpServletRequest; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; @@ -16,6 +18,7 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @ControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) public class ApiExceptionHandler extends ResponseEntityExceptionHandler { @ResponseBody diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/EnableSdaWebSecurity.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/EnableSdaWebSecurity.java new file mode 100644 index 000000000..dd5a18188 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/EnableSdaWebSecurity.java @@ -0,0 +1,19 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.context.annotation.Import; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Import({SdaWebSecurityConfiguration.class}) +public @interface EnableSdaWebSecurity {} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/SdaCorsConfigurer.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/SdaCorsConfigurer.java new file mode 100644 index 000000000..58e7042ea --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/SdaCorsConfigurer.java @@ -0,0 +1,43 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.config.annotation.CorsRegistration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Component +@ConfigurationProperties("cors") +public class SdaCorsConfigurer implements WebMvcConfigurer { + + /** + * Comma-separated list of origin patterns to allow. Unlike allowed origins which only supports + * '*', origin patterns are more flexible (for example 'https://*.example.com') and can be used + * when credentials are allowed. When no allowed origin patterns or allowed origins are set, CORS + * support is disabled. + */ + private List allowedOriginPatterns = new ArrayList<>(); + + @Override + public void addCorsMappings(CorsRegistry registry) { + CorsRegistration corsRegistration = registry.addMapping("/**"); + if (allowedOriginPatterns.isEmpty()) { + corsRegistration.allowedOrigins(); // effectively disallows cors + } else { + corsRegistration.allowedOriginPatterns(allowedOriginPatterns.toArray(new String[] {})); + } + } + + public void setAllowedOriginPatterns(List allowedOriginPatterns) { + this.allowedOriginPatterns = allowedOriginPatterns; + } +} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/SdaWebSecurityConfiguration.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/SdaWebSecurityConfiguration.java new file mode 100644 index 000000000..5da84e233 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/SdaWebSecurityConfiguration.java @@ -0,0 +1,33 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security; + +import org.sdase.commons.spring.boot.web.security.headers.RestfulApiSecurityConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.web.filter.ForwardedHeaderFilter; + +@EnableWebSecurity +@ComponentScan +@AutoConfiguration +@Import({RestfulApiSecurityConfiguration.class, SdaCorsConfigurer.class}) +public class SdaWebSecurityConfiguration { + @Bean + public FilterRegistrationBean forwardedHeaderFilter() { + ForwardedHeaderFilter filter = new ForwardedHeaderFilter(); + FilterRegistrationBean registration = + new FilterRegistrationBean<>(filter); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registration; + } +} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/handler/ObscuringErrorHandler.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/handler/ObscuringErrorHandler.java new file mode 100644 index 000000000..5dc9c9ff9 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/handler/ObscuringErrorHandler.java @@ -0,0 +1,88 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.handler; + +import org.sdase.commons.spring.boot.web.error.ApiError; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +/** + * Error handle that replaces default errors responses with custom {@link ApiError}. + * + *

This handler is invoked for explicitly defined error responses like {@code return + * ResponseEntity.status(404).build();} and is not invoked for response indirectly create by {@code + * throw new NotFoundException()}. + * + *

This handler addresses risks identified in the security guide as: + * + *

    + *
  • "Risk: Detection of confidential components ... Removal of application related Error + * messages." + *
+ */ +@RestControllerAdvice +public class ObscuringErrorHandler implements ResponseBodyAdvice { + private static final String RESPONSE_ENTITY_TYPE = ResponseEntity.class.getName(); + private static final String ACTUATOR_PACKAGE_NAME = + "org.springframework.boot.actuate.endpoint.web.servlet"; + private static final String API_ERROR_RESPONSE_ENTITY_TYPE = + String.format("%s<%s>", ResponseEntity.class.getTypeName(), ApiError.class.getTypeName()); + + @Override + public boolean supports( + MethodParameter returnType, Class> converterType) { + return shouldTransformResponse(returnType); + } + + @Override + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response) { + var responseStatus = + HttpStatus.resolve(((ServletServerHttpResponse) response).getServletResponse().getStatus()); + + // No replacement for `ResponseEntity.status(204).build()` + if (responseStatus == null || !responseStatus.isError()) { + return body; + } + + // Replacement error responses with standard errors, e.g + // `ResponseEntity.internalServerError().body(customErrorBody) + var apiError = new ApiError(); + apiError.setTitle("HTTP Error " + responseStatus.value() + " occurred."); + return apiError; + } + + private boolean shouldTransformResponse(MethodParameter returnType) { + // No replacement for `ResponseEntity` + if (API_ERROR_RESPONSE_ENTITY_TYPE.equals(returnType.getGenericParameterType().getTypeName())) { + return false; + } + + // No replacement for `ResponseEntity` + if (!RESPONSE_ENTITY_TYPE.equals(returnType.getParameterType().getTypeName())) { + return false; + } + + // No replacement for `ResponseEntity` returned by spring actuator + return !ACTUATOR_PACKAGE_NAME.equals( + returnType.getExecutable().getDeclaringClass().getPackageName()); + } +} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/handler/RunTimeExceptionHandler.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/handler/RunTimeExceptionHandler.java new file mode 100644 index 000000000..9d270561c --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/handler/RunTimeExceptionHandler.java @@ -0,0 +1,42 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.handler; + +import org.sdase.commons.spring.boot.web.error.ApiError; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +/** + * This handler addresses risks identified in the security guide as: + * + *
    + *
  • "Risk: Detection of confidential components ... Removal of application related Error + * messages." + *
+ */ +@ControllerAdvice +public class RunTimeExceptionHandler extends ResponseEntityExceptionHandler { + private static final String ERROR_MESSAGE = "An exception occurred."; + private static final Logger LOG = LoggerFactory.getLogger(RunTimeExceptionHandler.class); + + @ExceptionHandler(value = {RuntimeException.class}) + protected ResponseEntity handleRuntimeException( + RuntimeException ex, WebRequest request) { + LOG.error(ERROR_MESSAGE, ex); + var headers = new HttpHeaders(); + return new ResponseEntity<>( + new ApiError(ERROR_MESSAGE), headers, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/EnableFrontendSecurity.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/EnableFrontendSecurity.java new file mode 100644 index 000000000..6475b336b --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/EnableFrontendSecurity.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.headers; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.context.annotation.Import; + +/** + * Activates setting headers to the response that enhance the security of web applications. Usually + * we do not provide web content from services. But we address the risks identified in the security + * guide as: + * + *
    + *
  • "Risk: Cross Site Scripting (XSS)" + *
  • "Risk: Reloading content into Flash and PDFs" + *
  • "Risk: Clickjacking" + *
  • "Risk: Passing on visited URLs to third parties" + *
  • "Risk: Interpretation of content by the browser" + *
+ * + *

This feature should only be enabled in services that provide frontend resources like HTML + * pages themselves. Services that provide REST APIs only shall not activate this feature. If not + * enabled, headers are set {@linkplain RestfulApiSecurityConfiguration according risks for backend + * service}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Import({FrontendSecurityConfiguration.class}) +public @interface EnableFrontendSecurity {} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/FrontendSecurityConfiguration.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/FrontendSecurityConfiguration.java new file mode 100644 index 000000000..10c8c02dc --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/FrontendSecurityConfiguration.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.headers; + +import org.springframework.context.annotation.Bean; + +/** + * This filter adds headers to the response that enhance the security of web applications. Usually + * we do not provide web content from services. But we address the risks identified in the security + * guide as: + * + *

    + *
  • "Risk: Cross Site Scripting (XSS)" + *
  • "Risk: Reloading content into Flash and PDFs" + *
  • "Risk: Clickjacking" + *
  • "Risk: Passing on visited URLs to third parties" + *
  • "Risk: Interpretation of content by the browser" + *
+ * + *

This feature should only be enabled in services that provide frontend resources like HTML + * pages themselves. Services that provide REST APIs only shall not activate this feature. If not + * enabled, headers are set {@linkplain RestfulApiSecurityConfiguration according risks for backend + * service}. + */ +public class FrontendSecurityConfiguration { + + public static final String FRONTEND_SECURITY_ADVICE_BEAN_NAME = "frontendHeadersAdvice"; + + @Bean(FRONTEND_SECURITY_ADVICE_BEAN_NAME) + public SdaSecurityHeaders sdaSecurityHeaders() { + return SdaSecurityType.FRONTEND_SECURITY::headers; + } +} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/RestfulApiSecurityConfiguration.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/RestfulApiSecurityConfiguration.java new file mode 100644 index 000000000..2cdc0d0ee --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/RestfulApiSecurityConfiguration.java @@ -0,0 +1,33 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.headers; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * This filter adds headers to the response that enhance the security of applications that serve + * only REST APIs. The following risks are addressed: + * + *

    + *
  • "Risk: Cross Site Scripting (XSS)" + *
  • "Risk: Reloading content into Flash and PDFs" + *
  • "Risk: Clickjacking" + *
  • "Risk: Passing on visited URLs to third parties" + *
  • "Risk: Interpretation of content by the browser" + *
+ */ +@Configuration +public class RestfulApiSecurityConfiguration { + @Bean + @ConditionalOnMissingBean(name = FrontendSecurityConfiguration.FRONTEND_SECURITY_ADVICE_BEAN_NAME) + public SdaSecurityHeaders sdaSecurityHeaders() { + return SdaSecurityType.RESTFUL_SECURITY::headers; + } +} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/SdaSecurityHeaders.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/SdaSecurityHeaders.java new file mode 100644 index 000000000..1d4d7a15a --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/SdaSecurityHeaders.java @@ -0,0 +1,16 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.headers; + +import java.util.List; +import org.springframework.security.web.header.Header; + +public interface SdaSecurityHeaders { + + List
getSecurityHeaders(); +} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/SdaSecurityType.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/SdaSecurityType.java new file mode 100644 index 000000000..98b7f9258 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/headers/SdaSecurityType.java @@ -0,0 +1,70 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.headers; + +import static java.util.Arrays.asList; +import static org.sdase.commons.spring.boot.web.security.headers.SdaSecurityType.Common.WEB_SECURITY_HEADERS; + +import java.util.List; +import java.util.stream.Stream; +import org.springframework.security.web.header.Header; + +public enum SdaSecurityType { + RESTFUL_SECURITY( + Stream.concat( + WEB_SECURITY_HEADERS.stream(), + Stream.of( + new Header( + "Content-Security-Policy", + String.join( + "; ", + asList("default-src 'none'", "frame-ancestors 'none'", "sandbox"))))) + .toList()), + + FRONTEND_SECURITY( + Stream.concat( + WEB_SECURITY_HEADERS.stream(), + Stream.of( + new Header( + "Content-Security-Policy", + String.join( + "; ", + asList( + "default-src 'self'", + "script-src 'self'", + "img-src 'self'", + "style-src 'self'", + "font-src 'self'", + "frame-src 'none'", + "object-src 'none'"))))) + .toList()); + + private final List
headers; + + SdaSecurityType(List
headers) { + this.headers = headers; + } + + public List
headers() { + return headers; + } + + static class Common { + private Common() { + // just to hold WEB_SECURITY_HEADERS + } + + static final List
WEB_SECURITY_HEADERS = + List.of( + new Header("X-Frame-Options", "DENY"), + new Header("X-Content-Type-Options", "nosniff"), + new Header("X-XSS-Protection", "1; mode=block"), + new Header("Referrer-Policy", "same-origin"), + new Header("X-Permitted-Cross-Domain-Policies", "none")); + } +} diff --git a/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/validation/HttpMethodsSecurityAdvice.java b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/validation/HttpMethodsSecurityAdvice.java new file mode 100644 index 000000000..33696375d --- /dev/null +++ b/sda-commons-web-autoconfigure/src/main/java/org/sdase/commons/spring/boot/web/security/validation/HttpMethodsSecurityAdvice.java @@ -0,0 +1,57 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.validation; + +import org.apache.catalina.connector.Connector; +import org.sdase.commons.spring.boot.web.security.exception.InsecureConfigurationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.context.WebServerInitializedEvent; +import org.springframework.boot.web.embedded.tomcat.TomcatWebServer; +import org.springframework.boot.web.server.WebServer; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +/** + * Checks that secure defaults of used {@link Connector} instances are not modified and overwrites + * insecure defaults. This class checks for the risks identified in the security guide as: + * + *
    + *
  • "Risk: Exploitation of HTTP-Methods" + *
+ */ +@Component +public class HttpMethodsSecurityAdvice implements ApplicationListener { + private static final Logger LOG = LoggerFactory.getLogger(HttpMethodsSecurityAdvice.class); + + @Override + public void onApplicationEvent(WebServerInitializedEvent event) { + assertWebServerDoesNotAllowTrace(event.getWebServer()); + } + + private static void assertWebServerDoesNotAllowTrace(WebServer webServer) { + if (webServer instanceof TomcatWebServer tomcatWebServer) { + Connector[] connectors = tomcatWebServer.getTomcat().getService().findConnectors(); + for (Connector connector : connectors) { + HttpMethodsSecurityAdvice.assertTomcatDoesNotAllowTrace(connector); + } + } else { + // may add more checks for other webservers (jetty, undertow...) + LOG.warn( + "Security for web server of type {} is not supported yet.", + webServer.getClass().getSimpleName()); + } + } + + private static void assertTomcatDoesNotAllowTrace(Connector connector) { + // Prevent the application from starting + if (connector.getAllowTrace()) { + throw new InsecureConfigurationException("The server accepts insecure methods."); + } + } +} diff --git a/sda-commons-web-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sda-commons-web-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 5ac3dd64c..6c7071a5a 100644 --- a/sda-commons-web-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/sda-commons-web-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,12 +1,11 @@ org.sdase.commons.spring.boot.web.SdaSpringConfiguration org.sdase.commons.spring.boot.web.async.SdaAsyncConfiguration org.sdase.commons.spring.boot.web.auth.SdaSecurityConfiguration -org.sdase.commons.spring.boot.web.auth.SdaMonitoringSecurityConfiguration org.sdase.commons.spring.boot.web.auth.opa.OpaRestTemplateConfiguration org.sdase.commons.spring.boot.web.client.SdaClientConfiguration -org.sdase.commons.spring.boot.web.async.SdaAsyncConfiguration org.sdase.commons.spring.boot.web.client.SdaOidcClientConfiguration org.sdase.commons.spring.boot.web.docs.SdaOpenApiCustomizerConfiguration org.sdase.commons.spring.boot.web.jackson.SdaObjectMapperConfiguration org.sdase.commons.spring.boot.web.monitoring.SdaMonitoringConfiguration org.sdase.commons.spring.boot.web.monitoring.SdaTracingConfiguration +org.sdase.commons.spring.boot.web.security.SdaWebSecurityConfiguration diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/auth/AuthenticationIT.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/auth/AuthenticationIT.java index e3333a1a4..94e0730ed 100644 --- a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/auth/AuthenticationIT.java +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/auth/AuthenticationIT.java @@ -179,6 +179,7 @@ void shouldRejectAlgNone() { @Test void shouldNotCreateSession() { + authMock.authorizeRequest().withHttpMethod("GET").withPath("/ping").allow(); var response = authMock .authentication() diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/BehindProxyTest.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/BehindProxyTest.java new file mode 100644 index 000000000..e95acaa09 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/BehindProxyTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sdase.commons.spring.boot.web.security.test.SecurityTestApp; +import org.sdase.commons.spring.boot.web.testing.auth.AuthMock; +import org.sdase.commons.spring.boot.web.testing.auth.EnableSdaAuthMockInitializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.test.context.ContextConfiguration; + +@SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = EnableSdaAuthMockInitializer.class) +class BehindProxyTest { + + @LocalServerPort private int port; + + @Autowired private TestRestTemplate client; + + @Autowired private AuthMock authMock; + + @BeforeEach + void allow() { + authMock.authorizeAnyRequest().allow(); + } + + @Test + void useRegularIpWithoutForwardedByHeader() { + HttpHeaders headers = acceptTextPlainHeaders(); + String caller = getCaller(headers); + assertThat(caller).contains("127.0.0.1"); + } + + @Test + void useForwardedForHeader() { + HttpHeaders headers = acceptTextPlainHeaders(); + headers.set("X-Forwarded-For", "192.168.123.123"); + String caller = getCaller(headers); + assertThat(caller).contains("192.168.123.123"); + } + + @Test + void createLinkWithoutForwardedProtoAndHostHeader() { + HttpHeaders headers = acceptTextPlainHeaders(); + String caller = getLink(headers); + assertThat(caller).contains("http://localhost:" + port); + } + + @Test + void useForwardedProtoAndHostHeaderToCreateLink() { + HttpHeaders headers = acceptTextPlainHeaders(); + headers.set("X-Forwarded-Proto", "https"); + headers.set("X-Forwarded-Host", "from.external.example.com"); + String caller = getLink(headers); + assertThat(caller).contains("https://from.external.example.com"); + } + + private static HttpHeaders acceptTextPlainHeaders() { + var headers = new HttpHeaders(); + headers.setAccept(List.of(org.springframework.http.MediaType.TEXT_PLAIN)); + return headers; + } + + private String getCaller(HttpHeaders headers) { + return client + .exchange( + getServerBaseUrl() + "/api/caller", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class) + .getBody(); + } + + private String getLink(HttpHeaders headers) { + return client + .exchange( + getServerBaseUrl() + "/api/link", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class) + .getBody(); + } + + String getServerBaseUrl() { + return String.format("http://localhost:%s", port); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/CorsOriginsTest.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/CorsOriginsTest.java new file mode 100644 index 000000000..ad5a09933 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/CorsOriginsTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import java.util.List; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sdase.commons.spring.boot.web.security.test.SecurityTestApp; +import org.sdase.commons.spring.boot.web.testing.auth.DisableSdaAuthInitializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.test.context.ContextConfiguration; + +@SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + "cors.allowed-origin-patterns=https://allowed.com, https://*.foo.com, https://foo-*.bar.com") +@ContextConfiguration(initializers = DisableSdaAuthInitializer.class) +class CorsOriginsTest { + + @LocalServerPort private int port; + + @Autowired private TestRestTemplate client; + + @ParameterizedTest + @ValueSource( + strings = { + "https://allowed.com", + "https://bar.foo.com", + "https://deep.matching.foo.com", + "https://foo-pr-1.bar.com" + }) + void verifyCorsAllowed(String givenOrigin) { + var headers = new HttpHeaders(); + headers.set("Access-Control-Request-Method", "GET"); + headers.set("Access-Control-Request-Headers", "origin, authorization"); + headers.set("Origin", givenOrigin); + + var actual = + client.exchange( + getServerBaseUrl() + "/api/resource", + HttpMethod.OPTIONS, + new HttpEntity<>(headers), + String.class); + assertThat(actual.getHeaders()) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsEntry("Access-Control-Allow-Origin", List.of(givenOrigin)); + } + + @ParameterizedTest + @ValueSource(strings = {"https://unknown.com", "https://not-matching.allowed.com"}) + void verifyCorsNotMatching(String givenOrigin) { + var headers = new HttpHeaders(); + headers.set("Access-Control-Request-Method", "GET"); + headers.set("Access-Control-Request-Headers", "origin, authorization"); + headers.set("Origin", givenOrigin); + + var actual = + client.exchange( + getServerBaseUrl() + "/api/resource", + HttpMethod.OPTIONS, + new HttpEntity<>(headers), + String.class); + assertThat(actual.getHeaders()) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .doesNotContainKey("Access-Control-Allow-Origin"); + } + + String getServerBaseUrl() { + return String.format("http://localhost:%s", port); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/CorsUndefinedTest.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/CorsUndefinedTest.java new file mode 100644 index 000000000..99d46ac94 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/CorsUndefinedTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; +import org.sdase.commons.spring.boot.web.security.test.SecurityTestApp; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; + +@SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CorsUndefinedTest { + + @LocalServerPort private int port; + + @Autowired private TestRestTemplate client; + + @Test + void verifyCorsNotMatching() { + var headers = new HttpHeaders(); + headers.set("Access-Control-Request-Method", "GET"); + headers.set("Access-Control-Request-Headers", "origin, authorization"); + headers.set("Origin", "https://external.example.com"); + + var actual = + client.exchange( + getServerBaseUrl() + "/api/resource", + HttpMethod.OPTIONS, + new HttpEntity<>(headers), + String.class); + assertThat(actual.getHeaders()) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .doesNotContainKey("Access-Control-Allow-Origin"); + } + + String getServerBaseUrl() { + return String.format("http://localhost:%s", port); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/CustomAdminEndpointTest.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/CustomAdminEndpointTest.java new file mode 100644 index 000000000..4ebd427ff --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/CustomAdminEndpointTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sdase.commons.spring.boot.web.security.test.SecurityTestApp; +import org.sdase.commons.spring.boot.web.security.test.TestResource; +import org.sdase.commons.spring.boot.web.testing.auth.AuthMock; +import org.sdase.commons.spring.boot.web.testing.auth.EnableSdaAuthMockInitializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; + +@SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = EnableSdaAuthMockInitializer.class) +class CustomAdminEndpointTest { + + @LocalServerPort private int port; + + @LocalManagementPort private int managementPort; + + @Autowired private TestRestTemplate client; + + @Autowired private AuthMock authMock; + + @Test + void shouldAccessCustomEndpointAtManagementPort() { + authMock.authorizeAnyRequest().deny(); + ResponseEntity actual = + client.getForEntity( + String.format("http://localhost:%s", managementPort) + "/tasks/doSomething", + TestResource.class); + assertThat(actual).extracting(ResponseEntity::getStatusCode).isEqualTo(HttpStatus.OK); + assertThat(actual) + .extracting(ResponseEntity::getBody) + .extracting(TestResource::getValue) + .isEqualTo("This is from the management server."); + } + + @ParameterizedTest + @ValueSource( + strings = { + "/api/tasks/doSomething", + "/api/doSomething", + "/actuator/tasks/doSomething", + "/actuator/doSomething", + "/api/actuator/tasks/doSomething", + "/api/actuator/doSomething", + "/tasks/doSomething", + "/doSomething" + }) + void shouldNotAccessCustomEndpointAtAppPort(String pathToTry) { + authMock.authorizeAnyRequest().allow(); + ResponseEntity actual = + client.getForEntity(String.format("http://localhost:%s", port) + pathToTry, String.class); + assertThat(actual).extracting(ResponseEntity::getStatusCode).isEqualTo(HttpStatus.NOT_FOUND); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/handler/ObscuringErrorHandlerTest.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/handler/ObscuringErrorHandlerTest.java new file mode 100644 index 000000000..2deefc296 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/handler/ObscuringErrorHandlerTest.java @@ -0,0 +1,139 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.handler; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import org.junit.jupiter.api.Test; +import org.sdase.commons.spring.boot.web.error.ApiError; +import org.sdase.commons.spring.boot.web.security.test.SecurityTestApp; +import org.sdase.commons.spring.boot.web.security.test.TestResource; +import org.sdase.commons.spring.boot.web.testing.auth.DisableSdaAuthInitializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; + +@SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = DisableSdaAuthInitializer.class) +class ObscuringErrorHandlerTest { + + @LocalServerPort private int port; + + @Autowired private TestRestTemplate client; + + @Test + void shouldTransformToStandardErrors() { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity exchange = + client.exchange( + getServerBaseUrl() + "/api/errorEntity", + HttpMethod.GET, + new HttpEntity<>(headers), + ApiError.class); + assertThat(exchange) + .isNotNull() + .extracting(ResponseEntity::getBody) + .extracting(ApiError::getTitle) + .isEqualTo("HTTP Error 500 occurred."); + } + + @Test + void shouldNotTransformApiErrors() { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity exchange = + client.exchange( + getServerBaseUrl() + "/api/apiError", + HttpMethod.GET, + new HttpEntity<>(headers), + ApiError.class); + assertThat(exchange) + .isNotNull() + .extracting(ResponseEntity::getStatusCode, r -> r.getBody().getTitle()) + .containsExactly(HttpStatus.NOT_IMPLEMENTED, "This method is not implemented yet."); + } + + @Test + void shouldMapExceptions() { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity exchange = + client.exchange( + getServerBaseUrl() + "/api/forcedError", + HttpMethod.GET, + new HttpEntity<>(headers), + ApiError.class); + assertThat(exchange) + .isNotNull() + .extracting(ResponseEntity::getStatusCode, r -> r.getBody().getTitle()) + .containsExactly(HttpStatus.INTERNAL_SERVER_ERROR, "An exception occurred."); + } + + @Test + void shouldGetResponseEntityWithBody() { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity exchange = + client.exchange( + getServerBaseUrl() + "/api/response", + HttpMethod.GET, + new HttpEntity<>(headers), + TestResource.class); + assertThat(exchange) + .isNotNull() + .extracting(ResponseEntity::getStatusCode, r -> r.getBody().getValue()) + .containsExactly(HttpStatus.CREATED, "This will not be altered."); + } + + @Test + void shouldGetErrorResponseFromVoidReturnType() { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity exchange = + client.exchange( + getServerBaseUrl() + "/api/voidError", + HttpMethod.GET, + new HttpEntity<>(headers), + ApiError.class); + assertThat(exchange) + .isNotNull() + .extracting(ResponseEntity::getStatusCode, r -> r.getBody().getTitle()) + .containsExactly(HttpStatus.NOT_FOUND, "HTTP Error 404 occurred."); + } + + @Test + void shouldGetResource() { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity exchange = + client.exchange( + getServerBaseUrl() + "/api/resource", + HttpMethod.GET, + new HttpEntity<>(headers), + TestResource.class); + assertThat(exchange) + .isNotNull() + .extracting(ResponseEntity::getStatusCode, r -> r.getBody().getValue()) + .containsExactly(HttpStatus.OK, "This will not be altered."); + } + + String getServerBaseUrl() { + return String.format("http://localhost:%s", port); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/AbstractSecurityHeadersTest.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/AbstractSecurityHeadersTest.java new file mode 100644 index 000000000..78d52462e --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/AbstractSecurityHeadersTest.java @@ -0,0 +1,199 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.headers; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.junit.jupiter.params.provider.Arguments.of; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.sdase.commons.spring.boot.web.security.test.SecurityTestApp; +import org.sdase.commons.spring.boot.web.security.test.TestResource; +import org.sdase.commons.spring.boot.web.testing.auth.DisableSdaAuthInitializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; + +@SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = DisableSdaAuthInitializer.class) +abstract class AbstractSecurityHeadersTest { + @LocalServerPort private int port; + + @Autowired private TestRestTemplate client; + + static Stream predefinedRestfulApiSecurityHeaders() { + return Stream.of( + // cache headers + of("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"), + of("Pragma", "no-cache"), + of("Expires", "0"), + + // security headers + of("X-Frame-Options", "DENY"), + of("X-Content-Type-Options", "nosniff"), + of("X-XSS-Protection", "1; mode=block"), + of("Referrer-Policy", "same-origin"), + of("X-Permitted-Cross-Domain-Policies", "none"), + of("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; sandbox")); + } + + static Stream predefinedFrontendSecurityHeaders() { + return Stream.of( + // cache headers + of("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"), + of("Pragma", "no-cache"), + of("Expires", "0"), + + // security headers + of("X-Frame-Options", "DENY"), + of("X-Content-Type-Options", "nosniff"), + of("X-XSS-Protection", "1; mode=block"), + of("Referrer-Policy", "same-origin"), + of("X-Permitted-Cross-Domain-Policies", "none"), + of( + "Content-Security-Policy", + String.join( + "; ", + asList( + "default-src 'self'", + "script-src 'self'", + "img-src 'self'", + "style-src 'self'", + "font-src 'self'", + "frame-src 'none'", + "object-src 'none'")))); + } + + protected abstract Stream predefinedSecurityHeaders(); + + @ParameterizedTest + @MethodSource("predefinedRestfulApiSecurityHeaders") + void shouldAddSecurityHeaders(String predefinedHeaderName, String expectedPredefinedHeaderValue) { + + ResponseEntity actual = + client.getForEntity(getServerBaseUrl() + "/api/resource", TestResource.class); + assertThat(actual) + .isNotNull() + .extracting(ResponseEntity::getStatusCode) + .isEqualTo(HttpStatus.OK); + + assertThat(actual) + .extracting(HttpEntity::getHeaders) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsEntry(predefinedHeaderName, List.of(expectedPredefinedHeaderValue)); + } + + @ParameterizedTest + @MethodSource("predefinedRestfulApiSecurityHeaders") + void shouldAllowOverwritingHeaders(String predefinedHeaderName) { + // for unknown reason, X-Frame-Options can't be modified + assumeThat(predefinedHeaderName).isNotEqualTo("X-Frame-Options"); + + ResponseEntity actual = + client.getForEntity( + getServerBaseUrl() + "/api/header?headerName={headerName}&headerValue={headerValue}", + TestResource.class, + Map.of("headerName", predefinedHeaderName, "headerValue", "CUSTOM_VALUE")); + assertThat(actual) + .isNotNull() + .extracting(ResponseEntity::getStatusCode) + .isEqualTo(HttpStatus.OK); + + assertThat(actual) + .extracting(HttpEntity::getHeaders) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsEntry(predefinedHeaderName, List.of("CUSTOM_VALUE")); + } + + /** + * This test verifies that no headers are exposed, that identify Spring Boot or other components + * of the service. + */ + @Test + void verifyExistingDefaultHeaders() { + ResponseEntity actual = + client.getForEntity(getServerBaseUrl() + "/api/resource", TestResource.class); + assertThat(actual) + .isNotNull() + .extracting(ResponseEntity::getStatusCode) + .isEqualTo(HttpStatus.OK); + HttpHeaders actualHeaders = actual.getHeaders(); + assertThat(actualHeaders) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsOnlyKeys( + Stream.concat( + Stream.of( + "Content-Type", + "Transfer-Encoding", + "Date", + "Keep-Alive", + "Connection", + "Vary"), + predefinedSecurityHeaders().map(Arguments::get).map(it -> it[0])) + .collect(Collectors.toList())); + } + + /** + * This test verifies that no headers are exposed, that identify Spring Boot or other components + * of the service. + */ + @Test + void verifyExistingDefaultHeadersInCaseOfError() { + ResponseEntity actual = + client.getForEntity(getServerBaseUrl() + "/api/does/not/exist", TestResource.class); + assertThat(actual) + .isNotNull() + .extracting(ResponseEntity::getStatusCode) + .isEqualTo(HttpStatus.NOT_FOUND); + HttpHeaders actualHeaders = actual.getHeaders(); + assertThat(actualHeaders) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .containsOnlyKeys( + Stream.concat( + Stream.of( + "Content-Type", + "Transfer-Encoding", + "Date", + "Keep-Alive", + "Connection", + "Vary"), + predefinedSecurityHeaders().map(Arguments::get).map(it -> it[0])) + .collect(Collectors.toList())); + } + + @Test + void verifyAllowedMethods() { + List httpMethods = + client.optionsForAllow(getServerBaseUrl() + "/api/resource").stream().toList(); + assertThat(httpMethods) + .asList() + .containsExactlyInAnyOrder(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); + } + + String getServerBaseUrl() { + return String.format("http://localhost:%s", port); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/FrontendSecurityHeadersTestHolder.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/FrontendSecurityHeadersTestHolder.java new file mode 100644 index 000000000..49382bbea --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/FrontendSecurityHeadersTestHolder.java @@ -0,0 +1,63 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.headers; + +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.sdase.commons.spring.boot.web.security.test.SecurityTestApp; +import org.sdase.commons.spring.boot.web.testing.auth.AuthMock; +import org.sdase.commons.spring.boot.web.testing.auth.DisableSdaAuthInitializer; +import org.sdase.commons.spring.boot.web.testing.auth.EnableSdaAuthMockInitializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +/** This test class depends on the {@code @EnableFrontendSecurity} annotation being present. */ +@EnableFrontendSecurity +abstract class FrontendSecurityHeadersTestHolder extends AbstractSecurityHeadersTest { + + @SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + @ContextConfiguration(initializers = EnableSdaAuthMockInitializer.class) + static class AuthEnabledTest extends FrontendSecurityHeadersTestHolder { + + @Autowired AuthMock authMock; + + @BeforeEach + void allow() { + authMock.authorizeAnyRequest().allow(); + } + } + + @SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + @ContextConfiguration(initializers = DisableSdaAuthInitializer.class) + static class AuthDisabledTest extends FrontendSecurityHeadersTestHolder {} + + @Override + protected Stream predefinedSecurityHeaders() { + return AbstractSecurityHeadersTest.predefinedFrontendSecurityHeaders(); + } + + @ParameterizedTest + @MethodSource("predefinedFrontendSecurityHeaders") + void shouldAddSecurityHeaders(String predefinedHeaderName, String expectedPredefinedHeaderValue) { + super.shouldAddSecurityHeaders(predefinedHeaderName, expectedPredefinedHeaderValue); + } + + @ParameterizedTest + @MethodSource("predefinedFrontendSecurityHeaders") + void shouldAllowOverwritingSecurityHeaders(String predefinedHeaderName) { + super.shouldAllowOverwritingHeaders(predefinedHeaderName); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/RequestHeadersTest.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/RequestHeadersTest.java index 35f10226c..e2e30ad0e 100644 --- a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/RequestHeadersTest.java +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/RequestHeadersTest.java @@ -27,8 +27,7 @@ @SpringBootTest( classes = SecurityTestApp.class, - webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = {"management.server.port=0"}) + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ContextConfiguration(initializers = DisableSdaAuthInitializer.class) class RequestHeadersTest { diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/RestfulApiSecurityHeadersTestHolder.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/RestfulApiSecurityHeadersTestHolder.java new file mode 100644 index 000000000..c88911cc5 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/headers/RestfulApiSecurityHeadersTestHolder.java @@ -0,0 +1,61 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.headers; + +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.sdase.commons.spring.boot.web.security.test.SecurityTestApp; +import org.sdase.commons.spring.boot.web.testing.auth.AuthMock; +import org.sdase.commons.spring.boot.web.testing.auth.DisableSdaAuthInitializer; +import org.sdase.commons.spring.boot.web.testing.auth.EnableSdaAuthMockInitializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +abstract class RestfulApiSecurityHeadersTestHolder extends AbstractSecurityHeadersTest { + + @SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + @ContextConfiguration(initializers = EnableSdaAuthMockInitializer.class) + static class AuthEnabledTest extends RestfulApiSecurityHeadersTestHolder { + + @Autowired AuthMock authMock; + + @BeforeEach + void allow() { + authMock.authorizeAnyRequest().allow(); + } + } + + @SpringBootTest( + classes = SecurityTestApp.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + @ContextConfiguration(initializers = DisableSdaAuthInitializer.class) + static class AuthDisabledTest extends RestfulApiSecurityHeadersTestHolder {} + + @Override + protected Stream predefinedSecurityHeaders() { + return AbstractSecurityHeadersTest.predefinedRestfulApiSecurityHeaders(); + } + + @ParameterizedTest + @MethodSource("predefinedRestfulApiSecurityHeaders") + void shouldAddSecurityHeaders(String predefinedHeaderName, String expectedPredefinedHeaderValue) { + super.shouldAddSecurityHeaders(predefinedHeaderName, expectedPredefinedHeaderValue); + } + + @ParameterizedTest + @MethodSource("predefinedRestfulApiSecurityHeaders") + void shouldAllowOverwritingHeaders(String predefinedHeaderName) { + super.shouldAllowOverwritingHeaders(predefinedHeaderName); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/test/AdminController.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/test/AdminController.java new file mode 100644 index 000000000..2dc1590c6 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/test/AdminController.java @@ -0,0 +1,21 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.test; + +import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.GetMapping; + +@Component +@RestControllerEndpoint(id = "tasks") +public class AdminController { + @GetMapping(value = "/doSomething") + public TestResource resource() { + return new TestResource().setValue("This is from the management server."); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/test/SecurityTestApp.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/test/SecurityTestApp.java index 9b5f80672..4b352bbd9 100644 --- a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/test/SecurityTestApp.java +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/test/SecurityTestApp.java @@ -7,52 +7,46 @@ */ package org.sdase.commons.spring.boot.web.security.test; -import java.io.IOException; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Context; +import org.sdase.commons.spring.boot.web.error.ApiException; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @SpringBootApplication @RestController public class SecurityTestApp { - @GetMapping("/fixedTime") - public Object getFixedTime() { - return new Object() { - private final ZonedDateTime time = - // the configured ObjectMapper should truncate the nanos to seconds - ZonedDateTime.of(LocalDateTime.of(2018, 11, 21, 13, 16, 47, 965_000_300), ZoneOffset.UTC); - - @SuppressWarnings("unused") - public ZonedDateTime getTime() { - return time; - } - }; - } - @RequestMapping(value = "/traced", method = RequestMethod.TRACE) public String serveTrace() { return "This should not be allowed"; } - @GetMapping(value = "/error") - public Object throwError() throws IOException { - throw new IOException("This should not be allowed"); + @GetMapping(value = "/forcedError") + public Object throwError() { + throw new RuntimeException("This should not be allowed"); + } + + @GetMapping(value = "/apiError") + public void throwApiError() { + throw ApiException.builder() + .httpCode(HttpStatus.NOT_IMPLEMENTED.value()) + .title("This method is not implemented yet.") + .build(); } @GetMapping(value = "/errorEntity") public ResponseEntity responseError() { - var body = - ResponseEntity.internalServerError() - .body(new TestResource().setValue("This should not be leaked.")); - return body; + return ResponseEntity.internalServerError() + .body(new TestResource().setValue("This should not be leaked.")); } @GetMapping(value = "/response") @@ -65,4 +59,28 @@ public ResponseEntity responseEntity() { public TestResource resource() { return new TestResource().setValue("This will not be altered."); } + + @GetMapping(value = "/header") + public ResponseEntity header( + @RequestParam("headerName") String headerName, + @RequestParam("headerValue") String headerValue) { + var headers = new HttpHeaders(); + headers.add(headerName, headerValue); + return new ResponseEntity<>(new TestResource(), headers, HttpStatus.OK); + } + + @GetMapping(value = "/voidError") + public ResponseEntity voidError() { + return ResponseEntity.notFound().build(); + } + + @GetMapping(value = "caller", produces = "text/plain") + public String identifyCaller(@Context HttpServletRequest request) { + return request.getRemoteAddr(); + } + + @GetMapping(value = "link", produces = "text/plain") + public String identifyLink(@Context HttpServletRequest request) { + return ServletUriComponentsBuilder.fromCurrentContextPath().toUriString(); + } } diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/CustomObjectMapperAdviceTest.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/om/CustomObjectMapperAdviceTest.java similarity index 75% rename from sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/CustomObjectMapperAdviceTest.java rename to sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/om/CustomObjectMapperAdviceTest.java index 35c24b359..beeb3a645 100644 --- a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/CustomObjectMapperAdviceTest.java +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/om/CustomObjectMapperAdviceTest.java @@ -5,12 +5,11 @@ * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ -package org.sdase.commons.spring.boot.web.security.validation; +package org.sdase.commons.spring.boot.web.security.validation.om; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; -import org.sdase.commons.spring.boot.web.jackson.SdaObjectMapperConfiguration; import org.sdase.commons.spring.boot.web.security.exception.InsecureConfigurationException; import org.sdase.commons.spring.boot.web.security.test.ContextUtils; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -18,7 +17,7 @@ class CustomObjectMapperAdviceTest { @Test - void shouldPreventStartupIfTracingIsEnabled() { + void shouldPreventStartupIfSdaObjectMapperIsNotConfigured() { assertThat(ContextUtils.createTestContext(NoSdaObjectMapperApp.class)) .hasFailed() .getFailure() @@ -29,7 +28,10 @@ void shouldPreventStartupIfTracingIsEnabled() { + "The Jackson2ObjectMapperBuilder component registers custom mappers."); } - @SpringBootApplication(exclude = SdaObjectMapperConfiguration.class) - @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + @SpringBootApplication + @SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + "spring.autoconfigure.exclude=org.sdase.commons.spring.boot.web.jackson.SdaObjectMapperConfiguration") public static class NoSdaObjectMapperApp {} } diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/trace/AllowTraceMethodConfig.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/trace/AllowTraceMethodConfig.java new file mode 100644 index 000000000..abfcaa476 --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/trace/AllowTraceMethodConfig.java @@ -0,0 +1,32 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.validation.trace; + +import java.util.Arrays; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.security.web.firewall.HttpFirewall; +import org.springframework.security.web.firewall.StrictHttpFirewall; + +public class AllowTraceMethodConfig { + @Bean + public static HttpFirewall configureFirewall() { + var strictHttpFirewall = new StrictHttpFirewall(); + // even allowed in the configured filterChain the `TRACE` method is rejected by the webserver + strictHttpFirewall.setAllowedHttpMethods( + Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "TRACE")); + return strictHttpFirewall; + } + + @Bean + public static WebServerFactoryCustomizer tomcatCustomizer() { + return customizer -> + customizer.addConnectorCustomizers(connector -> connector.setAllowTrace(true)); + } +} diff --git a/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/trace/SecureMethodsAdviceTest.java b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/trace/SecureMethodsAdviceTest.java new file mode 100644 index 000000000..5e678326d --- /dev/null +++ b/sda-commons-web-autoconfigure/src/test/java/org/sdase/commons/spring/boot/web/security/validation/trace/SecureMethodsAdviceTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022- SDA SE Open Industry Solutions (https://www.sda.se) + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.sdase.commons.spring.boot.web.security.validation.trace; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.sdase.commons.spring.boot.web.security.exception.InsecureConfigurationException; +import org.sdase.commons.spring.boot.web.security.test.ContextUtils; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +class SecureMethodsAdviceTest { + + @Test + void shouldPreventStartupIfTracingIsEnabled() { + Assertions.assertThat(ContextUtils.createTestContext(TraceAllowedApp.class)) + .hasFailed() + .getFailure() + .getRootCause() + .isInstanceOfSatisfying( + InsecureConfigurationException.class, + e -> assertThat(e.getMessage()).isEqualTo("The server accepts insecure methods.")); + } + + @SpringBootApplication + @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + @ContextConfiguration(classes = {AllowTraceMethodConfig.class}) + public static class TraceAllowedApp {} +}