From 85ab7d9019b762be0bd1faab69cbccf93cf5d79f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Richter?= <96986086+joerg-richter-5234@users.noreply.github.com> Date: Mon, 17 Mar 2025 19:14:08 +0100 Subject: [PATCH] #2975-Suggestion-Add-Permissions-Policy-as-configurable-option-to-SecureHeaders-GatewayFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - added Permissions-Policy header as an opt-in header & default value - updated documentation with Permissions-Policy and resources - updated tests to include Permissions-Policy - SecurityHeaders and Permissions-Policy may be configured globally / per route - updated structure of public methods and class members to match previous version structure Fixes gh-2975 Signed-off-by: Jörg Richter <96986086+joerg-richter-5234@users.noreply.github.com> --- .../secureheaders-factory.adoc | 83 +++- .../resources/application-secureheaders.yml | 50 +++ .../SecureHeadersGatewayFilterFactory.java | 373 ++++++++++++------ .../factory/SecureHeadersProperties.java | 165 +++++++- ...ecureHeadersGatewayFilterFactoryTests.java | 22 +- ...eHeadersGatewayFilterFactoryUnitTests.java | 126 +++++- 6 files changed, 662 insertions(+), 157 deletions(-) create mode 100644 spring-cloud-gateway-sample/src/main/resources/application-secureheaders.yml diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/secureheaders-factory.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/secureheaders-factory.adoc index 84254642..12177b8a 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/secureheaders-factory.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/secureheaders-factory.adoc @@ -29,10 +29,87 @@ The following properties are available: To disable the default values set the `spring.cloud.gateway.filter.secure-headers.disable` property with comma-separated values. The following example shows how to do so: -[source] +.application.yml +[source,yaml] ---- -spring.cloud.gateway.filter.secure-headers.disable=x-frame-options,strict-transport-security +spring: + cloud: + gateway: + filter: + secure-headers: + disable: x-frame-options,strict-transport-security ---- -NOTE: The lowercase full name of the secure header needs to be used to disable it.. +To apply the `SecureHeaders` filter to a specific route, add the filter to the list of filters of that route. +You can customize the route filter using arguments. Route configuration overrides the global default configuration for this route. +.application.yml +[source,yaml] +---- + - id: secureheaders_route + uri: http://example.org + predicates: + - Path=/** + filters: + - name: SecureHeaders + args: + disable: x-frame-options +---- + +NOTE: The lowercase full name of the secure header needs to be used to disable it. + +== Further options + +You may opt in to add the `Permissions-Policy` header to the response. Permissions Policy is a security header +that allows web developers to manage which browser features a website can utilize. Please see +https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy[Permissions-Policy] and +https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy#directives[Directives] to configure it +for your environment. + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + filter: + secure-headers: + enable: permissions-policy + permissions-policy : geolocation=(self "https://example.com") +---- + +In the above https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy/geolocation[example] +the Geolocation API is disabled within all browsing contexts except for its own origin and those whose origin is "https://example.com". +The Permissions-Policy may be configured separately for each route. + +.application.yml +[source,yaml] +---- + - id: secureheaders_route + uri: http://anotherexample.org + predicates: + - Path=/** + filters: + - name: SecureHeaders + args: + disable: x-frame-options + enable: permissions-policy + permissions-policy : geolocation=("https://anotherexample.org") +---- + +WARNING: When you enable Permissions-Policy and do not explicitly configure any directives, a default value will be applied. +Specifically, this default value disables a wide range of standardized and experimental features. +This behavior might not be appropriate for your specific environment or use case. + +Permissions-Policy default value when enabled and no explicit configuration: + +`Permissions-Policy: accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), +display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), +fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), +payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), +web-share=(), xr-spatial-tracking=()` + + +NOTE: You can check the Permissions Policy feature list for Chrome with https://developer.chrome.com/docs/privacy-security/permissions-policy#chrome_devtools_integration[DevTool Integration]. + +When you configure the header value for your environment, make sure to check the browser console for syntax errors. diff --git a/spring-cloud-gateway-sample/src/main/resources/application-secureheaders.yml b/spring-cloud-gateway-sample/src/main/resources/application-secureheaders.yml new file mode 100644 index 00000000..ddae382f --- /dev/null +++ b/spring-cloud-gateway-sample/src/main/resources/application-secureheaders.yml @@ -0,0 +1,50 @@ +test: + hostport: httpbin.org:80 + # hostport: localhost:5000 + uri: http://${test.hostport} + #uri: lb://httpbin + + +spring: + cloud: + gateway: + filter: + default-filters: + #- PrefixPath=/httpbin + #- AddResponseHeader=X-Response-Default-Foo, Default-Bar + + routes: + # ===================================== + # to run server + # $ wscat --listen 9000 + # to run client + # $ wscat --connect ws://localhost:8080/echo + - id: websocket_test + uri: ws://localhost:9000 + order: 9000 + predicates: + - Path=/echo + # ===================================== + - id: default_path_to_httpbin_secureheaders + uri: ${test.uri} + order: 10000 + predicates: + - Path=/** + filters: + - name: SecureHeaders + args: + disable: x-frame-options + enable: permissions-policy + permissions-policy : geolocation=("https://example.net") + +logging: + level: + org.springframework.cloud.gateway: TRACE + org.springframework.http.server.reactive: DEBUG + org.springframework.web.reactive: DEBUG + reactor.ipc.netty: DEBUG + reactor.netty: DEBUG + +management.endpoints.web.exposure.include: '*' + + diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactory.java index 3a079968..ce699763 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactory.java @@ -16,8 +16,10 @@ package org.springframework.cloud.gateway.filter.factory; -import java.util.List; +import java.util.HashSet; import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; import reactor.core.publisher.Mono; @@ -29,9 +31,14 @@ import org.springframework.web.server.ServerWebExchange; import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator; /** - * https://blog.appcanary.com/2017/http-security-headers.html. + * GatewayFilterFactory to provide a route filter that applies security headers to the + * HTTP response. External configuration {@link SecureHeadersProperties} provides + * opinionated defaults. Following the recommendations made in Http-Security-Headers. + * When opt-out headers are not disabled or explicitly configured, sensible defaults are + * applied. Additionally, opt-in headers, such as Permissions-Policy, may be applied. * - * @author Spencer Gibb, Thirunavukkarasu Ravichandran + * @author Spencer Gibb, Thirunavukkarasu Ravichandran, Jörg Richter */ public class SecureHeadersGatewayFilterFactory extends AbstractGatewayFilterFactory { @@ -39,42 +46,43 @@ public class SecureHeadersGatewayFilterFactory /** * Xss-Protection header name. */ - public static final String X_XSS_PROTECTION_HEADER = "X-Xss-Protection"; + public static final String X_XSS_PROTECTION_HEADER = SecureHeadersProperties.X_XSS_PROTECTION_HEADER; /** * Strict transport security header name. */ - public static final String STRICT_TRANSPORT_SECURITY_HEADER = "Strict-Transport-Security"; + public static final String STRICT_TRANSPORT_SECURITY_HEADER = SecureHeadersProperties.STRICT_TRANSPORT_SECURITY_HEADER; /** * Frame options header name. */ - public static final String X_FRAME_OPTIONS_HEADER = "X-Frame-Options"; + public static final String X_FRAME_OPTIONS_HEADER = SecureHeadersProperties.X_FRAME_OPTIONS_HEADER; /** * Content-Type Options header name. */ - public static final String X_CONTENT_TYPE_OPTIONS_HEADER = "X-Content-Type-Options"; + public static final String X_CONTENT_TYPE_OPTIONS_HEADER = SecureHeadersProperties.X_CONTENT_TYPE_OPTIONS_HEADER; /** * Referrer Policy header name. */ - public static final String REFERRER_POLICY_HEADER = "Referrer-Policy"; + public static final String REFERRER_POLICY_HEADER = SecureHeadersProperties.REFERRER_POLICY_HEADER; /** * Content-Security Policy header name. */ - public static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy"; + public static final String CONTENT_SECURITY_POLICY_HEADER = SecureHeadersProperties.CONTENT_SECURITY_POLICY_HEADER; /** * Download Options header name. */ - public static final String X_DOWNLOAD_OPTIONS_HEADER = "X-Download-Options"; + public static final String X_DOWNLOAD_OPTIONS_HEADER = SecureHeadersProperties.X_DOWNLOAD_OPTIONS_HEADER; /** * Permitted Cross-Domain Policies header name. */ - public static final String X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER = "X-Permitted-Cross-Domain-Policies"; + public static final String X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER = SecureHeadersProperties.X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER; + private final SecureHeadersProperties properties; @@ -83,50 +91,23 @@ public class SecureHeadersGatewayFilterFactory this.properties = properties; } + /** + * Returns a GatewayFilter that applies security headers to the HTTP response. + * @param originalConfig the original security configuration + * @return a GatewayFilter instance that applies security headers to the HTTP response + */ @Override public GatewayFilter apply(Config originalConfig) { return new GatewayFilter() { @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - HttpHeaders headers = exchange.getResponse().getHeaders(); - List disabled = properties.getDisable(); + HttpHeaders responseHeaders = exchange.getResponse().getHeaders(); + Set headersToAddToResponse = assembleHeaders(originalConfig, properties); + Config config = originalConfig.withDefaults(properties); - - return chain.filter(exchange).then(Mono.fromRunnable(() -> { - if (isEnabled(disabled, X_XSS_PROTECTION_HEADER)) { - headers.addIfAbsent(X_XSS_PROTECTION_HEADER, config.getXssProtectionHeader()); - } - - if (isEnabled(disabled, STRICT_TRANSPORT_SECURITY_HEADER)) { - headers.addIfAbsent(STRICT_TRANSPORT_SECURITY_HEADER, config.getStrictTransportSecurity()); - } - - if (isEnabled(disabled, X_FRAME_OPTIONS_HEADER)) { - headers.addIfAbsent(X_FRAME_OPTIONS_HEADER, config.getFrameOptions()); - } - - if (isEnabled(disabled, X_CONTENT_TYPE_OPTIONS_HEADER)) { - headers.addIfAbsent(X_CONTENT_TYPE_OPTIONS_HEADER, config.getContentTypeOptions()); - } - - if (isEnabled(disabled, REFERRER_POLICY_HEADER)) { - headers.addIfAbsent(REFERRER_POLICY_HEADER, config.getReferrerPolicy()); - } - - if (isEnabled(disabled, CONTENT_SECURITY_POLICY_HEADER)) { - headers.addIfAbsent(CONTENT_SECURITY_POLICY_HEADER, config.getContentSecurityPolicy()); - } - - if (isEnabled(disabled, X_DOWNLOAD_OPTIONS_HEADER)) { - headers.addIfAbsent(X_DOWNLOAD_OPTIONS_HEADER, config.getDownloadOptions()); - } - - if (isEnabled(disabled, X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER)) { - headers.addIfAbsent(X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER, - config.getPermittedCrossDomainPolicies()); - } - })); + return chain.filter(exchange).then(Mono.fromRunnable(() -> + applySecurityHeaders(responseHeaders, headersToAddToResponse, config))); } @Override @@ -136,135 +117,299 @@ public class SecureHeadersGatewayFilterFactory }; } - private boolean isEnabled(List disabledHeaders, String header) { - return !disabledHeaders.contains(header.toLowerCase(Locale.ROOT)); + /** + * Applies security headers to the response using the given filter configuration. + * @param responseHeaders - the http headers of the response + * @param headersToAddToResponse - the security headers that are to be added to the response + * @param config - the security filter configuration + */ + private void applySecurityHeaders(HttpHeaders responseHeaders, Set headersToAddToResponse, Config config) { + + addHeaderIfEnabled(responseHeaders, headersToAddToResponse, + SecureHeadersProperties.X_XSS_PROTECTION_HEADER, config.getXssProtectionHeaderValue()); + + addHeaderIfEnabled(responseHeaders, headersToAddToResponse, + SecureHeadersProperties.STRICT_TRANSPORT_SECURITY_HEADER, + config.getStrictTransportSecurityHeaderValue()); + + addHeaderIfEnabled(responseHeaders, headersToAddToResponse, + SecureHeadersProperties.X_FRAME_OPTIONS_HEADER, + config.getFrameOptionsHeaderValue()); + + addHeaderIfEnabled(responseHeaders, headersToAddToResponse, + SecureHeadersProperties.X_CONTENT_TYPE_OPTIONS_HEADER, + config.getContentTypeOptionsHeaderValue()); + + addHeaderIfEnabled(responseHeaders, headersToAddToResponse, + SecureHeadersProperties.REFERRER_POLICY_HEADER, + config.getReferrerPolicyHeaderValue()); + + addHeaderIfEnabled(responseHeaders, headersToAddToResponse, + SecureHeadersProperties.CONTENT_SECURITY_POLICY_HEADER, + config.getContentSecurityPolicyHeaderValue()); + + addHeaderIfEnabled(responseHeaders, headersToAddToResponse, + SecureHeadersProperties.X_DOWNLOAD_OPTIONS_HEADER, + config.getDownloadOptionsHeaderValue()); + + addHeaderIfEnabled(responseHeaders, headersToAddToResponse, + SecureHeadersProperties.X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER, + config.getPermittedCrossDomainPoliciesHeaderValue()); + + String permissionPolicyHeaderValue = config.getPermissionPolicyHeaderValue(); + if (config.isRouteFilterConfigProvided()) { + String routePermissionPolicyHeaderValue = config.getRoutePermissionsPolicyHeaderValue(); + if (routePermissionPolicyHeaderValue != null) { + permissionPolicyHeaderValue = routePermissionPolicyHeaderValue; + } + } + + addHeaderIfEnabled(responseHeaders, headersToAddToResponse, + SecureHeadersProperties.PERMISSIONS_POLICY_HEADER, + permissionPolicyHeaderValue); } + /** + * Assembles the set of security headers that are to be applied to the response + * - When route specific arguments are set, route specific headers are applied. + * - When no route specific arguments are set, global default headers are applied. + * @param config - the global / route configuration supplied + * @param properties - default security headers configuration provided + * @return set of security headers that are to be added to the response + */ + private Set assembleHeaders(Config config, SecureHeadersProperties properties) { + Set headersToAddToResponse = new HashSet<>(properties.getDefaultHeaders()); + if (config.isRouteFilterConfigProvided()) { + headersToAddToResponse.addAll(config.getRouteEnabledHeaders()); + headersToAddToResponse.removeAll(config.getRouteDisabledHeaders()); + } + else { + headersToAddToResponse.addAll(properties.getEnabledHeaders()); + headersToAddToResponse.removeAll(properties.getDisabledHeaders()); + } + return headersToAddToResponse; + } + + + private void addHeaderIfEnabled(HttpHeaders headers, Set headersToAdd, String headerName, String headerValue) { + if (headersToAdd.contains(headerName.toLowerCase(Locale.ROOT))) { + headers.addIfAbsent(headerName, headerValue); + } + } + + /** + * POJO for {@link SecureHeadersGatewayFilterFactory} filter configuration. + */ public static class Config { - private String xssProtectionHeader; + private Set routeEnabledHeaders = new HashSet<>(); - private String strictTransportSecurity; + private Set routeDisabledHeaders = new HashSet<>(); - private String frameOptions; + private String routePermissionsPolicyHeaderValue; - private String contentTypeOptions; + private boolean routeFilterConfigProvided; - private String referrerPolicy; + private String xssProtectionHeaderValue; - private String contentSecurityPolicy; + private String strictTransportSecurityHeaderValue; - private String downloadOptions; + private String frameOptionsHeaderValue; - private String permittedCrossDomainPolicies; + private String contentTypeOptionsHeaderValue; + + private String referrerPolicyHeaderValue; + + private String contentSecurityPolicyHeaderValue; + + private String downloadOptionsHeaderValue; + + private String permittedCrossDomainPoliciesHeaderValue; + + private String permissionPolicyHeaderValue; public Config withDefaults(SecureHeadersProperties properties) { Config config = new Config(); - config.setXssProtectionHeader(xssProtectionHeader); - config.setStrictTransportSecurity(strictTransportSecurity); - config.setFrameOptions(frameOptions); - config.setContentTypeOptions(contentTypeOptions); - config.setReferrerPolicy(referrerPolicy); - config.setContentSecurityPolicy(contentSecurityPolicy); - config.setDownloadOptions(downloadOptions); - config.setPermittedCrossDomainPolicies(permittedCrossDomainPolicies); - if (config.xssProtectionHeader == null) { - config.xssProtectionHeader = properties.getXssProtectionHeader(); + config.setEnable(routeEnabledHeaders); + config.setDisable(routeDisabledHeaders); + config.setPermissionsPolicy(routePermissionsPolicyHeaderValue); + + config.setXssProtectionHeaderValue(xssProtectionHeaderValue); + config.setStrictTransportSecurityHeaderValue(strictTransportSecurityHeaderValue); + config.setFrameOptionsHeaderValue(frameOptionsHeaderValue); + config.setContentTypeOptionsHeaderValue(contentTypeOptionsHeaderValue); + config.setReferrerPolicyHeaderValue(referrerPolicyHeaderValue); + config.setContentSecurityPolicyHeaderValue(contentSecurityPolicyHeaderValue); + config.setDownloadOptionsHeaderValue(downloadOptionsHeaderValue); + config.setPermittedCrossDomainPoliciesHeaderValue(permittedCrossDomainPoliciesHeaderValue); + config.setPermissionPolicyHeaderValue(permissionPolicyHeaderValue); + + if (config.xssProtectionHeaderValue == null) { + config.xssProtectionHeaderValue = properties.getXssProtectionHeader(); } - if (config.strictTransportSecurity == null) { - config.strictTransportSecurity = properties.getStrictTransportSecurity(); + if (config.strictTransportSecurityHeaderValue == null) { + config.strictTransportSecurityHeaderValue = properties.getStrictTransportSecurity(); } - if (config.frameOptions == null) { - config.frameOptions = properties.getFrameOptions(); + if (config.frameOptionsHeaderValue == null) { + config.frameOptionsHeaderValue = properties.getFrameOptions(); } - if (config.contentTypeOptions == null) { - config.contentTypeOptions = properties.getContentTypeOptions(); + if (config.contentTypeOptionsHeaderValue == null) { + config.contentTypeOptionsHeaderValue = properties.getContentTypeOptions(); } - if (config.referrerPolicy == null) { - config.referrerPolicy = properties.getReferrerPolicy(); + if (config.referrerPolicyHeaderValue == null) { + config.referrerPolicyHeaderValue = properties.getReferrerPolicy(); } - if (config.contentSecurityPolicy == null) { - config.contentSecurityPolicy = properties.getContentSecurityPolicy(); + if (config.contentSecurityPolicyHeaderValue == null) { + config.contentSecurityPolicyHeaderValue = properties.getContentSecurityPolicy(); } - if (config.downloadOptions == null) { - config.downloadOptions = properties.getDownloadOptions(); + if (config.downloadOptionsHeaderValue == null) { + config.downloadOptionsHeaderValue = properties.getDownloadOptions(); } - if (config.permittedCrossDomainPolicies == null) { - config.permittedCrossDomainPolicies = properties.getPermittedCrossDomainPolicies(); + if (config.permittedCrossDomainPoliciesHeaderValue == null) { + config.permittedCrossDomainPoliciesHeaderValue = properties.getPermittedCrossDomainPolicies(); } + + if (config.permissionPolicyHeaderValue == null) { + config.permissionPolicyHeaderValue = properties.getPermissionsPolicy(); + } + return config; } - public String getXssProtectionHeader() { - return xssProtectionHeader; + public String getXssProtectionHeaderValue() { + return xssProtectionHeaderValue; } - public void setXssProtectionHeader(String xssProtectionHeader) { - this.xssProtectionHeader = xssProtectionHeader; + public void setXssProtectionHeaderValue(String xssProtectionHeaderHeaderValue) { + this.xssProtectionHeaderValue = xssProtectionHeaderHeaderValue; } - public String getStrictTransportSecurity() { - return strictTransportSecurity; + public String getStrictTransportSecurityHeaderValue() { + return strictTransportSecurityHeaderValue; } - public void setStrictTransportSecurity(String strictTransportSecurity) { - this.strictTransportSecurity = strictTransportSecurity; + public void setStrictTransportSecurityHeaderValue(String strictTransportSecurityHeaderValue) { + this.strictTransportSecurityHeaderValue = strictTransportSecurityHeaderValue; } - public String getFrameOptions() { - return frameOptions; + public String getFrameOptionsHeaderValue() { + return frameOptionsHeaderValue; } - public void setFrameOptions(String frameOptions) { - this.frameOptions = frameOptions; + public void setFrameOptionsHeaderValue(String frameOptionsHeaderValue) { + this.frameOptionsHeaderValue = frameOptionsHeaderValue; } - public String getContentTypeOptions() { - return contentTypeOptions; + public String getContentTypeOptionsHeaderValue() { + return contentTypeOptionsHeaderValue; } - public void setContentTypeOptions(String contentTypeOptions) { - this.contentTypeOptions = contentTypeOptions; + public void setContentTypeOptionsHeaderValue(String contentTypeOptionsHeaderValue) { + this.contentTypeOptionsHeaderValue = contentTypeOptionsHeaderValue; } - public String getReferrerPolicy() { - return referrerPolicy; + public String getReferrerPolicyHeaderValue() { + return referrerPolicyHeaderValue; } - public void setReferrerPolicy(String referrerPolicy) { - this.referrerPolicy = referrerPolicy; + public void setReferrerPolicyHeaderValue(String referrerPolicyHeaderValue) { + this.referrerPolicyHeaderValue = referrerPolicyHeaderValue; } - public String getContentSecurityPolicy() { - return contentSecurityPolicy; + public String getContentSecurityPolicyHeaderValue() { + return contentSecurityPolicyHeaderValue; } - public void setContentSecurityPolicy(String contentSecurityPolicy) { - this.contentSecurityPolicy = contentSecurityPolicy; + public void setContentSecurityPolicyHeaderValue(String contentSecurityPolicyHeaderValue) { + this.contentSecurityPolicyHeaderValue = contentSecurityPolicyHeaderValue; } - public String getDownloadOptions() { - return downloadOptions; + public String getDownloadOptionsHeaderValue() { + return downloadOptionsHeaderValue; } - public void setDownloadOptions(String downloadOptions) { - this.downloadOptions = downloadOptions; + public void setDownloadOptionsHeaderValue(String downloadOptionHeaderValue) { + this.downloadOptionsHeaderValue = downloadOptionsHeaderValue; } - public String getPermittedCrossDomainPolicies() { - return permittedCrossDomainPolicies; + public String getPermittedCrossDomainPoliciesHeaderValue() { + return permittedCrossDomainPoliciesHeaderValue; } - public void setPermittedCrossDomainPolicies(String permittedCrossDomainPolicies) { - this.permittedCrossDomainPolicies = permittedCrossDomainPolicies; + public void setPermittedCrossDomainPoliciesHeaderValue(String permittedCrossDomainPoliciesHeaderValue) { + this.permittedCrossDomainPoliciesHeaderValue = permittedCrossDomainPoliciesHeaderValue; + } + + public String getPermissionPolicyHeaderValue() { + return permissionPolicyHeaderValue; + } + + public void setPermissionPolicyHeaderValue(String permissionPolicyHeaderValue) { + this.permissionPolicyHeaderValue = permissionPolicyHeaderValue; + } + + /** + * bind the route specific/opt-in header names to enable, in lower case. + */ + void setEnable(Set enable) { + if (enable != null) { + this.routeFilterConfigProvided = true; + this.routeEnabledHeaders = enable.stream().map(String::toLowerCase).collect(Collectors.toUnmodifiableSet()); + } + } + + /** + * @return the route specific/opt-in header names to enable, in lower case. + */ + Set getRouteEnabledHeaders() { + return routeEnabledHeaders; + } + + /** + * bind the route specific/opt-out header names to disable, in lower case. + */ + void setDisable(Set disable) { + if (disable != null) { + this.routeFilterConfigProvided = true; + this.routeDisabledHeaders = disable.stream().map(String::toLowerCase).collect(Collectors.toUnmodifiableSet()); + } + } + + /** + * @return the route specific/opt-out header names to disable, in lower case + */ + Set getRouteDisabledHeaders() { + return routeDisabledHeaders; + } + + /** + * @return the route specific/opt-out permission policies. + */ + String getRoutePermissionsPolicyHeaderValue() { + return routePermissionsPolicyHeaderValue; + } + + /** + * bind the route specific/opt-out permissions policy. + */ + void setPermissionsPolicy(String permissionsPolicy) { + this.routeFilterConfigProvided = true; + this.routePermissionsPolicyHeaderValue = permissionsPolicy; + } + + /** + * @return flag whether route specific arguments were bound. + */ + boolean isRouteFilterConfigProvided() { + return routeFilterConfigProvided; } } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersProperties.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersProperties.java index 6802575a..f9ec7b35 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersProperties.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersProperties.java @@ -16,57 +16,135 @@ package org.springframework.cloud.gateway.filter.factory; -import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.springframework.boot.context.properties.ConfigurationProperties; /** - * @author Spencer Gibb, Thirunavukkarasu Ravichandran + * @author Spencer Gibb, Thirunavukkarasu Ravichandran, Jörg Richter */ @ConfigurationProperties("spring.cloud.gateway.filter.secure-headers") public class SecureHeadersProperties { + /** + * Xss-Protection header name. + */ + public static final String X_XSS_PROTECTION_HEADER = "X-Xss-Protection"; + /** * Xss-Protection header default. */ public static final String X_XSS_PROTECTION_HEADER_DEFAULT = "1 ; mode=block"; + /** + * Strict transport security header name. + */ + public static final String STRICT_TRANSPORT_SECURITY_HEADER = "Strict-Transport-Security"; + /** * Strict transport security header default. */ public static final String STRICT_TRANSPORT_SECURITY_HEADER_DEFAULT = "max-age=631138519"; + /** + * Frame options header name. + */ + public static final String X_FRAME_OPTIONS_HEADER = "X-Frame-Options"; + /** * Frame Options header default. */ public static final String X_FRAME_OPTIONS_HEADER_DEFAULT = "DENY"; + /** + * Content-Type Options header name. + */ + public static final String X_CONTENT_TYPE_OPTIONS_HEADER = "X-Content-Type-Options"; + /** * Content-Type Options header default. */ public static final String X_CONTENT_TYPE_OPTIONS_HEADER_DEFAULT = "nosniff"; + /** + * Referrer Policy header name. + */ + public static final String REFERRER_POLICY_HEADER = "Referrer-Policy"; + /** * Referrer Policy header default. */ public static final String REFERRER_POLICY_HEADER_DEFAULT = "no-referrer"; + /** + * Content-Security Policy header name. + */ + public static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy"; + /** * Content-Security Policy header default. */ public static final String CONTENT_SECURITY_POLICY_HEADER_DEFAULT = "default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'"; + /** + * Download Options header name. + */ + public static final String X_DOWNLOAD_OPTIONS_HEADER = "X-Download-Options"; + /** * Download Options header default. */ public static final String X_DOWNLOAD_OPTIONS_HEADER_DEFAULT = "noopen"; + /** + * Permitted Cross-Domain Policies header name. + */ + public static final String X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER = "X-Permitted-Cross-Domain-Policies"; + /** * Permitted Cross-Domain Policies header default. */ public static final String X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER_DEFAULT = "none"; + /** + * Permissions Policy header name. Opt-In required by external configuration. + */ + public static final String PERMISSIONS_POLICY_HEADER = "Permissions-Policy"; + + /** + * Permissions Policy header default. Opt-In by external configuration required, + * because the header default disables a comprehensive list of features. + */ + public static final String PERMISSIONS_POLICY_HEADER_OPT_IN_DEFAULT = "accelerometer=(), ambient-light-sensor=(), " + + "autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), " + + "encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), " + + "geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), " + + "navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), " + + "screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()"; + + + /** + * Default constructor for {@link SecureHeadersProperties}. + * Initializes the `defaultHeaders` set with a predefined list of security headers. + * The headers are transformed to lowercase for case-insensitive comparison. + **/ + public SecureHeadersProperties() { + + defaultHeaders = Stream.of(SecureHeadersProperties.X_XSS_PROTECTION_HEADER, + SecureHeadersProperties.STRICT_TRANSPORT_SECURITY_HEADER, + SecureHeadersProperties.X_FRAME_OPTIONS_HEADER, SecureHeadersProperties.X_CONTENT_TYPE_OPTIONS_HEADER, + SecureHeadersProperties.REFERRER_POLICY_HEADER, SecureHeadersProperties.CONTENT_SECURITY_POLICY_HEADER, + SecureHeadersProperties.X_DOWNLOAD_OPTIONS_HEADER, + SecureHeadersProperties.X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER) + .map(String::toLowerCase) + .collect(Collectors.toUnmodifiableSet()); + + } + private String xssProtectionHeader = X_XSS_PROTECTION_HEADER_DEFAULT; private String strictTransportSecurity = STRICT_TRANSPORT_SECURITY_HEADER_DEFAULT; @@ -83,7 +161,13 @@ public class SecureHeadersProperties { private String permittedCrossDomainPolicies = X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER_DEFAULT; - private List disable = new ArrayList<>(); + private String permissionsPolicy = PERMISSIONS_POLICY_HEADER_OPT_IN_DEFAULT; + + private final Set defaultHeaders; + + private Set enabledHeaders = new HashSet<>(); + + private Set disabledHeaders = new HashSet<>(); public String getXssProtectionHeader() { return xssProtectionHeader; @@ -149,28 +233,73 @@ public class SecureHeadersProperties { this.permittedCrossDomainPolicies = permittedCrossDomainPolicies; } - public List getDisable() { - return disable; + public String getPermissionsPolicy() { + return permissionsPolicy; } + public void setPermissionsPolicy(String permissionsPolicy) { + this.permissionsPolicy = permissionsPolicy; + } + + /** + * @return the default/opt-out header names to disable + */ + public List getDisable() { + return disabledHeaders.stream().toList(); + } + + /** + * Binds the list of default/opt-out header names to disable, transforms them into a lowercase set. + * This is to ensure case-insensitive comparison. + * @param disable - list of default/opt-out header names to disable + */ public void setDisable(List disable) { - this.disable = disable; + if (disable != null) { + disabledHeaders = disable.stream().map(String::toLowerCase).collect(Collectors.toUnmodifiableSet()); + } + } + + /** + * @return the opt-in header names to enable + */ + public Set getEnabledHeaders() { + return enabledHeaders; + } + + /** + * Binds the list of default/opt-out header names to enable, transforms them into a lowercase set. + * This is to ensure case-insensitive comparison. + * @param enable - list of default/opt-out header enable + */ + public void setEnable(List enable) { + if (enable != null) { + enabledHeaders = enable.stream().map(String::toLowerCase).collect(Collectors.toUnmodifiableSet()); + } + } + + /** + * @return the default/opt-out header names to disable + */ + public Set getDisabledHeaders() { + return disabledHeaders; + } + + /** + * @return the default/opt-out header names to apply + */ + public Set getDefaultHeaders() { + return defaultHeaders; } @Override public String toString() { - final StringBuffer sb = new StringBuffer("SecureHeadersProperties{"); - sb.append("xssProtectionHeader='").append(xssProtectionHeader).append('\''); - sb.append(", strictTransportSecurity='").append(strictTransportSecurity).append('\''); - sb.append(", frameOptions='").append(frameOptions).append('\''); - sb.append(", contentTypeOptions='").append(contentTypeOptions).append('\''); - sb.append(", referrerPolicy='").append(referrerPolicy).append('\''); - sb.append(", contentSecurityPolicy='").append(contentSecurityPolicy).append('\''); - sb.append(", downloadOptions='").append(downloadOptions).append('\''); - sb.append(", permittedCrossDomainPolicies='").append(permittedCrossDomainPolicies).append('\''); - sb.append(", disabled='").append(disable).append('\''); - sb.append('}'); - return sb.toString(); + return "SecureHeadersProperties{" + "xssProtectionHeader='" + xssProtectionHeader + '\'' + + ", strictTransportSecurity='" + strictTransportSecurity + '\'' + ", frameOptions='" + frameOptions + + '\'' + ", contentTypeOptions='" + contentTypeOptions + '\'' + ", referrerPolicy='" + referrerPolicy + + '\'' + ", contentSecurityPolicy='" + contentSecurityPolicy + '\'' + ", downloadOptions='" + + downloadOptions + '\'' + ", permittedCrossDomainPolicies='" + permittedCrossDomainPolicies + '\'' + + ", permissionsPolicy='" + permissionsPolicy + '\'' + ", defaultHeaders=" + defaultHeaders + + ", enable=" + enabledHeaders + ", disable=" + disabledHeaders + '}'; } } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactoryTests.java index 1742a19e..75751f05 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactoryTests.java @@ -34,19 +34,25 @@ import org.springframework.web.reactive.function.client.ClientResponse; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.CONTENT_SECURITY_POLICY_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.REFERRER_POLICY_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.STRICT_TRANSPORT_SECURITY_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.X_CONTENT_TYPE_OPTIONS_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.X_DOWNLOAD_OPTIONS_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.X_FRAME_OPTIONS_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.CONTENT_SECURITY_POLICY_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.PERMISSIONS_POLICY_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.REFERRER_POLICY_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.STRICT_TRANSPORT_SECURITY_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.X_CONTENT_TYPE_OPTIONS_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.X_DOWNLOAD_OPTIONS_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.X_FRAME_OPTIONS_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER; import static org.springframework.cloud.gateway.test.TestUtils.assertStatus; @SpringBootTest(webEnvironment = RANDOM_PORT) @DirtiesContext public class SecureHeadersGatewayFilterFactoryTests extends BaseWebClientTests { + /** + * This test ensures that the response includes a set of default security headers, + * which are defined in {@link SecureHeadersProperties}. It also confirms that the + * opt-in "Permissions-Policy" header is not included in the response. + */ @Test public void secureHeadersFilterWorks() { Mono result = webClient.get() @@ -59,7 +65,6 @@ public class SecureHeadersGatewayFilterFactoryTests extends BaseWebClientTests { StepVerifier.create(result).consumeNextWith(response -> { assertStatus(response, HttpStatus.OK); HttpHeaders httpHeaders = response.headers().asHttpHeaders(); - // assertThat(httpHeaders.getFirst(X_XSS_PROTECTION_HEADER)).isEqualTo(defaults.getXssProtectionHeader()); assertThat(httpHeaders.getFirst(STRICT_TRANSPORT_SECURITY_HEADER)) .isEqualTo(defaults.getStrictTransportSecurity()); assertThat(httpHeaders.getFirst(X_FRAME_OPTIONS_HEADER)).isEqualTo(defaults.getFrameOptions()); @@ -70,6 +75,7 @@ public class SecureHeadersGatewayFilterFactoryTests extends BaseWebClientTests { assertThat(httpHeaders.getFirst(X_DOWNLOAD_OPTIONS_HEADER)).isEqualTo(defaults.getDownloadOptions()); assertThat(httpHeaders.getFirst(X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER)) .isEqualTo(defaults.getPermittedCrossDomainPolicies()); + assertThat(httpHeaders.getOrEmpty(PERMISSIONS_POLICY_HEADER)).isEmpty(); }).expectComplete().verify(DURATION); } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactoryUnitTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactoryUnitTests.java index 65dc9809..46284f91 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactoryUnitTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactoryUnitTests.java @@ -16,6 +16,10 @@ package org.springframework.cloud.gateway.filter.factory; +import java.util.Collections; +import java.util.List; +import java.util.Set; + import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -33,18 +37,19 @@ import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.CONTENT_SECURITY_POLICY_HEADER; import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.Config; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.REFERRER_POLICY_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.STRICT_TRANSPORT_SECURITY_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.X_CONTENT_TYPE_OPTIONS_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.X_DOWNLOAD_OPTIONS_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.X_FRAME_OPTIONS_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER; -import static org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory.X_XSS_PROTECTION_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.CONTENT_SECURITY_POLICY_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.PERMISSIONS_POLICY_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.REFERRER_POLICY_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.STRICT_TRANSPORT_SECURITY_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.X_CONTENT_TYPE_OPTIONS_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.X_DOWNLOAD_OPTIONS_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.X_FRAME_OPTIONS_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER; +import static org.springframework.cloud.gateway.filter.factory.SecureHeadersProperties.X_XSS_PROTECTION_HEADER; /** - * @author Thirunavukkarasu Ravichandran + * @author Thirunavukkarasu Ravichandran, Jörg Richter */ public class SecureHeadersGatewayFilterFactoryUnitTests { @@ -75,7 +80,7 @@ public class SecureHeadersGatewayFilterFactoryUnitTests { filter.filter(exchange, filterChain).block(); ServerHttpResponse response = exchange.getResponse(); - assertThat(response.getHeaders()).containsKeys(X_XSS_PROTECTION_HEADER, STRICT_TRANSPORT_SECURITY_HEADER, + assertThat(response.getHeaders()).containsOnlyKeys(X_XSS_PROTECTION_HEADER, STRICT_TRANSPORT_SECURITY_HEADER, X_FRAME_OPTIONS_HEADER, X_CONTENT_TYPE_OPTIONS_HEADER, REFERRER_POLICY_HEADER, CONTENT_SECURITY_POLICY_HEADER, X_DOWNLOAD_OPTIONS_HEADER, X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER); } @@ -105,8 +110,8 @@ public class SecureHeadersGatewayFilterFactoryUnitTests { SecureHeadersGatewayFilterFactory filterFactory = new SecureHeadersGatewayFilterFactory( new SecureHeadersProperties()); Config config = new Config(); - config.setStrictTransportSecurity("max-age=65535"); - config.setReferrerPolicy("referrer"); + config.setStrictTransportSecurityHeaderValue("max-age=65535"); + config.setReferrerPolicyHeaderValue("referrer"); filter = filterFactory.apply(config); filter.filter(exchange, filterChain).block(); @@ -135,13 +140,19 @@ public class SecureHeadersGatewayFilterFactoryUnitTests { @Test public void doesNotDuplicateHeaders() { String originalHeaderValue = "original-header-value"; + + SecureHeadersProperties secureHeadersProperties = new SecureHeadersProperties(); + secureHeadersProperties.setDisable(Collections.emptyList()); + secureHeadersProperties.setEnable(List.of(PERMISSIONS_POLICY_HEADER)); + SecureHeadersGatewayFilterFactory filterFactory = new SecureHeadersGatewayFilterFactory( - new SecureHeadersProperties()); + secureHeadersProperties); + Config config = new Config(); String[] headers = { X_XSS_PROTECTION_HEADER, STRICT_TRANSPORT_SECURITY_HEADER, X_FRAME_OPTIONS_HEADER, X_CONTENT_TYPE_OPTIONS_HEADER, REFERRER_POLICY_HEADER, CONTENT_SECURITY_POLICY_HEADER, - X_DOWNLOAD_OPTIONS_HEADER, X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER }; + X_DOWNLOAD_OPTIONS_HEADER, X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER, PERMISSIONS_POLICY_HEADER }; for (String header : headers) { filter = filterFactory.apply(config); @@ -163,4 +174,91 @@ public class SecureHeadersGatewayFilterFactoryUnitTests { Assertions.assertThat(filter.toString()).contains("SecureHeaders"); } + @Test + public void doNotAddPermissionsPolicyWhenNotEnabled() { + SecureHeadersProperties properties = new SecureHeadersProperties(); + + SecureHeadersGatewayFilterFactory filterFactory = new SecureHeadersGatewayFilterFactory(properties); + filter = filterFactory.apply(new Config()); + + filter.filter(exchange, filterChain).block(); + + ServerHttpResponse response = captor.getValue().getResponse(); + assertThat(response.getHeaders()).doesNotContainKeys(PERMISSIONS_POLICY_HEADER); + } + + @Test + public void addPermissionsPolicyWhenEnabled() { + SecureHeadersProperties properties = new SecureHeadersProperties(); + properties.setEnable(List.of("permissions-policy")); + + SecureHeadersGatewayFilterFactory filterFactory = new SecureHeadersGatewayFilterFactory(properties); + filter = filterFactory.apply(new Config()); + + filter.filter(exchange, filterChain).block(); + + ServerHttpResponse response = captor.getValue().getResponse(); + + assertThat(response.getHeaders()).containsKeys(X_XSS_PROTECTION_HEADER, STRICT_TRANSPORT_SECURITY_HEADER, + X_FRAME_OPTIONS_HEADER, X_CONTENT_TYPE_OPTIONS_HEADER, REFERRER_POLICY_HEADER, + CONTENT_SECURITY_POLICY_HEADER, X_DOWNLOAD_OPTIONS_HEADER, X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER); + + assertThat(response.getHeaders().get(PERMISSIONS_POLICY_HEADER)) + .containsExactly(SecureHeadersProperties.PERMISSIONS_POLICY_HEADER_OPT_IN_DEFAULT); + } + + @Test + public void addPermissionsPolicyAndOverrideDefaults() { + SecureHeadersProperties properties = new SecureHeadersProperties(); + properties.setEnable(List.of("permissions-policy")); + properties.setPermissionsPolicy("camera=*"); + + SecureHeadersGatewayFilterFactory filterFactory = new SecureHeadersGatewayFilterFactory(properties); + filter = filterFactory.apply(new Config()); + + filter.filter(exchange, filterChain).block(); + + ServerHttpResponse response = captor.getValue().getResponse(); + assertThat(response.getHeaders().get(PERMISSIONS_POLICY_HEADER)).containsExactly("camera=*"); + } + + @Test + public void applyCompositionWithDisabledHeadersAndPermissionPolicy() { + SecureHeadersProperties properties = new SecureHeadersProperties(); + properties.setDisable(asList("x-xss-protection", "strict-transport-security", "x-frame-options", + "x-content-type-options", "referrer-policy", "content-security-policy", "x-download-options")); + properties.setEnable(List.of("permissions-policy")); + + SecureHeadersGatewayFilterFactory filterFactory = new SecureHeadersGatewayFilterFactory(properties); + filter = filterFactory.apply(new Config()); + + filter.filter(exchange, filterChain).block(); + + ServerHttpResponse response = captor.getValue().getResponse(); + assertThat(response.getHeaders()).containsOnlyKeys(X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER, + PERMISSIONS_POLICY_HEADER); + } + + @Test + public void overrideDefaultInSecurityPropertiesWhenRouteConfigIsProvided() { + + SecureHeadersGatewayFilterFactory filterFactory = new SecureHeadersGatewayFilterFactory(new SecureHeadersProperties()); + + Config config = new Config(); + config.setDisable(Set.of("strict-transport-security")); + config.setEnable(Set.of("permissions-policy")); + config.setPermissionsPolicy("camera=*"); + + filter = filterFactory.apply(config); + + filter.filter(exchange, filterChain).block(); + + ServerHttpResponse response = exchange.getResponse(); + assertThat(response.getHeaders()).containsOnlyKeys(X_XSS_PROTECTION_HEADER, X_FRAME_OPTIONS_HEADER, + X_CONTENT_TYPE_OPTIONS_HEADER, REFERRER_POLICY_HEADER, CONTENT_SECURITY_POLICY_HEADER, + X_DOWNLOAD_OPTIONS_HEADER, X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER, + PERMISSIONS_POLICY_HEADER); + assertThat(response.getHeaders().get(PERMISSIONS_POLICY_HEADER)).containsExactly("camera=*"); + } + }