#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

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