#2975-Suggestion-Add-Permissions-Policy-as-configurable-option-to-SecureHeaders-GatewayFilter

- 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>
This commit is contained in:
Jörg Richter
2025-03-17 19:14:08 +01:00
parent 4e0cc2eb76
commit 85ab7d9019
6 changed files with 662 additions and 157 deletions

View File

@@ -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.

View File

@@ -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: '*'

View File

@@ -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 <a href=
* "https://blog.appcanary.com/2017/http-security-headers.html">Http-Security-Headers</a>.
* 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<SecureHeadersGatewayFilterFactory.Config> {
@@ -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<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
HttpHeaders headers = exchange.getResponse().getHeaders();
List<String> disabled = properties.getDisable();
HttpHeaders responseHeaders = exchange.getResponse().getHeaders();
Set<String> 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<String> 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<String> 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<String> assembleHeaders(Config config, SecureHeadersProperties properties) {
Set<String> 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<String> 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<String> routeEnabledHeaders = new HashSet<>();
private String strictTransportSecurity;
private Set<String> 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<String> 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<String> getRouteEnabledHeaders() {
return routeEnabledHeaders;
}
/**
* bind the route specific/opt-out header names to disable, in lower case.
*/
void setDisable(Set<String> 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<String> 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;
}
}

View File

@@ -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<String> disable = new ArrayList<>();
private String permissionsPolicy = PERMISSIONS_POLICY_HEADER_OPT_IN_DEFAULT;
private final Set<String> defaultHeaders;
private Set<String> enabledHeaders = new HashSet<>();
private Set<String> disabledHeaders = new HashSet<>();
public String getXssProtectionHeader() {
return xssProtectionHeader;
@@ -149,28 +233,73 @@ public class SecureHeadersProperties {
this.permittedCrossDomainPolicies = permittedCrossDomainPolicies;
}
public List<String> 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<String> 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<String> 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<String> 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<String> enable) {
if (enable != null) {
enabledHeaders = enable.stream().map(String::toLowerCase).collect(Collectors.toUnmodifiableSet());
}
}
/**
* @return the default/opt-out header names to disable
*/
public Set<String> getDisabledHeaders() {
return disabledHeaders;
}
/**
* @return the default/opt-out header names to apply
*/
public Set<String> 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 + '}';
}
}

View File

@@ -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<ClientResponse> 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);
}

View File

@@ -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=*");
}
}