From 38e51bdb79b6e685cdd337a0bc16fa92ba0ffb13 Mon Sep 17 00:00:00 2001 From: Spencer Gibb Date: Thu, 2 Feb 2017 20:54:41 -0700 Subject: [PATCH] Adds SecureHeaders filter see gh-15 --- .../cloud/gateway/api/FilterDefinition.java | 5 +- .../config/GatewayAutoConfiguration.java | 24 +++- .../filter/route/SecureHeadersProperties.java | 106 ++++++++++++++++++ .../route/SecureHeadersRouteFilter.java | 46 ++++++++ .../gateway/test/GatewayIntegrationTests.java | 38 ++++++- src/test/resources/application.yml | 13 ++- 6 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/springframework/cloud/gateway/filter/route/SecureHeadersProperties.java create mode 100644 src/main/java/org/springframework/cloud/gateway/filter/route/SecureHeadersRouteFilter.java diff --git a/src/main/java/org/springframework/cloud/gateway/api/FilterDefinition.java b/src/main/java/org/springframework/cloud/gateway/api/FilterDefinition.java index 32ecb9a6a..8ae13fe68 100644 --- a/src/main/java/org/springframework/cloud/gateway/api/FilterDefinition.java +++ b/src/main/java/org/springframework/cloud/gateway/api/FilterDefinition.java @@ -3,7 +3,6 @@ package org.springframework.cloud.gateway.api; import java.util.Arrays; import java.util.Objects; -import javax.validation.ValidationException; import javax.validation.constraints.NotNull; import static org.springframework.util.StringUtils.tokenizeToStringArray; @@ -22,8 +21,8 @@ public class FilterDefinition { public FilterDefinition(String text) { int eqIdx = text.indexOf("="); if (eqIdx <= 0) { - throw new ValidationException("Unable to parse FilterDefinition text '" + text + "'" + - ", must be of the form name=value"); + setName(text); + return; } setName(text.substring(0, eqIdx)); diff --git a/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index 0b02fac20..a466eb024 100644 --- a/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -26,6 +26,8 @@ import org.springframework.cloud.gateway.filter.route.RemoveRequestHeaderRouteFi import org.springframework.cloud.gateway.filter.route.RemoveResponseHeaderRouteFilter; import org.springframework.cloud.gateway.filter.route.RewritePathRouteFilter; import org.springframework.cloud.gateway.filter.route.RouteFilter; +import org.springframework.cloud.gateway.filter.route.SecureHeadersProperties; +import org.springframework.cloud.gateway.filter.route.SecureHeadersRouteFilter; import org.springframework.cloud.gateway.filter.route.SetPathRouteFilter; import org.springframework.cloud.gateway.filter.route.SetResponseHeaderRouteFilter; import org.springframework.cloud.gateway.filter.route.SetStatusRouteFilter; @@ -76,11 +78,6 @@ public class GatewayAutoConfiguration { return new CachingRouteLocator(new PropertiesRouteLocator(properties)); } - @Bean - public GatewayProperties gatewayProperties() { - return new GatewayProperties(); - } - @Bean public RoutingWebHandler routingWebHandler(HttpClient httpClient) { return new RoutingWebHandler(httpClient); @@ -100,6 +97,18 @@ public class GatewayAutoConfiguration { return new RoutePredicateHandlerMapping(webHandler, predicates, routeLocator); } + // ConfigurationProperty beans + + @Bean + public GatewayProperties gatewayProperties() { + return new GatewayProperties(); + } + + @Bean + public SecureHeadersProperties secureHeadersProperties() { + return new SecureHeadersProperties(); + } + // GlobalFilter beans @Bean @@ -225,6 +234,11 @@ public class GatewayAutoConfiguration { return new SetPathRouteFilter(); } + @Bean(name = "SecureHeadersRouteFilter") + public SecureHeadersRouteFilter secureHeadersRouteFilter(SecureHeadersProperties properties) { + return new SecureHeadersRouteFilter(properties); + } + @Bean(name = "SetResponseHeaderRouteFilter") public SetResponseHeaderRouteFilter setResponseHeaderRouteFilter() { return new SetResponseHeaderRouteFilter(); diff --git a/src/main/java/org/springframework/cloud/gateway/filter/route/SecureHeadersProperties.java b/src/main/java/org/springframework/cloud/gateway/filter/route/SecureHeadersProperties.java new file mode 100644 index 000000000..86bde8572 --- /dev/null +++ b/src/main/java/org/springframework/cloud/gateway/filter/route/SecureHeadersProperties.java @@ -0,0 +1,106 @@ +package org.springframework.cloud.gateway.filter.route; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author Spencer Gibb + */ +@ConfigurationProperties("spring.cloud.gateway.filter.secureHeaders") +public class SecureHeadersProperties { + public static final String X_XSS_PROTECTION_HEADER_DEFAULT = "1; mode=block"; + public static final String STRICT_TRANSPORT_SECURITY_HEADER_DEFAULT = "max-age=631138519"; //; includeSubDomains preload") + public static final String X_FRAME_OPTIONS_HEADER_DEFAULT = "DENY"; //SAMEORIGIN = ALLOW-FROM + public static final String X_CONTENT_TYPE_OPTIONS_HEADER_DEFAULT = "nosniff"; + public static final String REFERRER_POLICY_HEADER_DEFAULT = "no-referrer"; //no-referrer-when-downgrade = origin = origin-when-cross-origin = same-origin = strict-origin = strict-origin-when-cross-origin = unsafe-url + 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'"; + public static final String X_DOWNLOAD_OPTIONS_HEADER_DEFAULT = "noopen"; + public static final String X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER_DEFAULT = "none"; + + private String xssProtectionHeader = X_XSS_PROTECTION_HEADER_DEFAULT; + private String strictTransportSecurity = STRICT_TRANSPORT_SECURITY_HEADER_DEFAULT; + private String frameOptions = X_FRAME_OPTIONS_HEADER_DEFAULT; + private String contentTypeOptions = X_CONTENT_TYPE_OPTIONS_HEADER_DEFAULT; + private String referrerPolicy = REFERRER_POLICY_HEADER_DEFAULT; + private String contentSecurityPolicy = CONTENT_SECURITY_POLICY_HEADER_DEFAULT; + private String downloadOptions = X_DOWNLOAD_OPTIONS_HEADER_DEFAULT; + private String permittedCrossDomainPolicies = X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER_DEFAULT; + + public String getXssProtectionHeader() { + return xssProtectionHeader; + } + + public void setXssProtectionHeader(String xssProtectionHeader) { + this.xssProtectionHeader = xssProtectionHeader; + } + + public String getStrictTransportSecurity() { + return strictTransportSecurity; + } + + public void setStrictTransportSecurity(String strictTransportSecurity) { + this.strictTransportSecurity = strictTransportSecurity; + } + + public String getFrameOptions() { + return frameOptions; + } + + public void setFrameOptions(String frameOptions) { + this.frameOptions = frameOptions; + } + + public String getContentTypeOptions() { + return contentTypeOptions; + } + + public void setContentTypeOptions(String contentTypeOptions) { + this.contentTypeOptions = contentTypeOptions; + } + + public String getReferrerPolicy() { + return referrerPolicy; + } + + public void setReferrerPolicy(String referrerPolicy) { + this.referrerPolicy = referrerPolicy; + } + + public String getContentSecurityPolicy() { + return contentSecurityPolicy; + } + + public void setContentSecurityPolicy(String contentSecurityPolicy) { + this.contentSecurityPolicy = contentSecurityPolicy; + } + + public String getDownloadOptions() { + return downloadOptions; + } + + public void setDownloadOptions(String downloadOptions) { + this.downloadOptions = downloadOptions; + } + + public String getPermittedCrossDomainPolicies() { + return permittedCrossDomainPolicies; + } + + public void setPermittedCrossDomainPolicies(String permittedCrossDomainPolicies) { + this.permittedCrossDomainPolicies = permittedCrossDomainPolicies; + } + + @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('}'); + return sb.toString(); + } +} diff --git a/src/main/java/org/springframework/cloud/gateway/filter/route/SecureHeadersRouteFilter.java b/src/main/java/org/springframework/cloud/gateway/filter/route/SecureHeadersRouteFilter.java new file mode 100644 index 000000000..e2d6965e8 --- /dev/null +++ b/src/main/java/org/springframework/cloud/gateway/filter/route/SecureHeadersRouteFilter.java @@ -0,0 +1,46 @@ +package org.springframework.cloud.gateway.filter.route; + +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.WebFilter; + +/** + * https://blog.appcanary.com/2017/http-security-headers.html + * @author Spencer Gibb + */ +public class SecureHeadersRouteFilter implements RouteFilter { + + public static final String X_XSS_PROTECTION_HEADER = "X-Xss-Protection"; + public static final String STRICT_TRANSPORT_SECURITY_HEADER = "Strict-Transport-Security"; + public static final String X_FRAME_OPTIONS_HEADER = "X-Frame-Options"; + public static final String X_CONTENT_TYPE_OPTIONS_HEADER = "X-Content-Type-Options"; + public static final String REFERRER_POLICY_HEADER = "Referrer-Policy"; + public static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy"; + public static final String X_DOWNLOAD_OPTIONS_HEADER = "X-Download-Options"; + public static final String X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER = "X-Permitted-Cross-Domain-Policies"; + + private final SecureHeadersProperties properties; + + public SecureHeadersRouteFilter(SecureHeadersProperties properties) { + this.properties = properties; + } + + @Override + public WebFilter apply(String... args) { + //TODO: allow args to override properties + + return (exchange, chain) -> { + HttpHeaders headers = exchange.getResponse().getHeaders(); + + headers.add(X_XSS_PROTECTION_HEADER, properties.getXssProtectionHeader()); + headers.add(STRICT_TRANSPORT_SECURITY_HEADER, properties.getStrictTransportSecurity()); + headers.add(X_FRAME_OPTIONS_HEADER, properties.getFrameOptions()); + headers.add(X_CONTENT_TYPE_OPTIONS_HEADER, properties.getContentTypeOptions()); + headers.add(REFERRER_POLICY_HEADER, properties.getReferrerPolicy()); + headers.add(CONTENT_SECURITY_POLICY_HEADER, properties.getContentSecurityPolicy()); + headers.add(X_DOWNLOAD_OPTIONS_HEADER, properties.getDownloadOptions()); + headers.add(X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER, properties.getPermittedCrossDomainPolicies()); + + return chain.filter(exchange); + }; + } +} diff --git a/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java b/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java index e337e2435..0ad5f2fc2 100644 --- a/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java +++ b/src/test/java/org/springframework/cloud/gateway/test/GatewayIntegrationTests.java @@ -14,6 +14,7 @@ import org.springframework.boot.context.embedded.LocalServerPort; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.gateway.api.Route; import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.cloud.gateway.filter.route.SecureHeadersProperties; import org.springframework.cloud.gateway.handler.RoutePredicateHandlerMapping; import org.springframework.context.annotation.Bean; import org.springframework.core.annotation.Order; @@ -26,6 +27,14 @@ import org.springframework.web.server.ServerWebExchange; 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.route.SecureHeadersRouteFilter.CONTENT_SECURITY_POLICY_HEADER; +import static org.springframework.cloud.gateway.filter.route.SecureHeadersRouteFilter.REFERRER_POLICY_HEADER; +import static org.springframework.cloud.gateway.filter.route.SecureHeadersRouteFilter.STRICT_TRANSPORT_SECURITY_HEADER; +import static org.springframework.cloud.gateway.filter.route.SecureHeadersRouteFilter.X_CONTENT_TYPE_OPTIONS_HEADER; +import static org.springframework.cloud.gateway.filter.route.SecureHeadersRouteFilter.X_DOWNLOAD_OPTIONS_HEADER; +import static org.springframework.cloud.gateway.filter.route.SecureHeadersRouteFilter.X_FRAME_OPTIONS_HEADER; +import static org.springframework.cloud.gateway.filter.route.SecureHeadersRouteFilter.X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER; +import static org.springframework.cloud.gateway.filter.route.SecureHeadersRouteFilter.X_XSS_PROTECTION_HEADER; import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_HANDLER_MAPPER_ATTR; import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR; import static org.springframework.web.reactive.function.BodyExtractors.toMono; @@ -307,7 +316,34 @@ public class GatewayIntegrationTests { } @Test - public void setPathFilterWorks() { + public void secureHeadersFilterWorks() { + Mono result = webClient.get() + .uri("/headers") + .header("Host", "www.secureheaders.org") + .exchange(); + + SecureHeadersProperties defaults = new SecureHeadersProperties(); + + 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()); + assertThat(httpHeaders.getFirst(X_CONTENT_TYPE_OPTIONS_HEADER)).isEqualTo(defaults.getContentTypeOptions()); + assertThat(httpHeaders.getFirst(REFERRER_POLICY_HEADER)).isEqualTo(defaults.getReferrerPolicy()); + assertThat(httpHeaders.getFirst(CONTENT_SECURITY_POLICY_HEADER)).isEqualTo(defaults.getContentSecurityPolicy()); + assertThat(httpHeaders.getFirst(X_DOWNLOAD_OPTIONS_HEADER)).isEqualTo(defaults.getDownloadOptions()); + assertThat(httpHeaders.getFirst(X_PERMITTED_CROSS_DOMAIN_POLICIES_HEADER)).isEqualTo(defaults.getPermittedCrossDomainPolicies()); + }) + .expectComplete() + .verify(DURATION); + } + + @Test + public void setPathFilterDefaultValuesWork() { Mono result = webClient.get() .uri("/foo/get") .header("Host", "www.setpath.org") diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 2d5094fd0..e9b99c392 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -97,6 +97,15 @@ spring: - AddResponseHeader=X-Request-Foo, Bar - RemoveResponseHeader=X-Request-Foo + # ===================================== + - id: secure_headers_test + uri: http://httpbin.org:80 + predicates: + - Host=**.secureheaders.org + - Url=/headers + filters: + - SecureHeaders + # ===================================== - id: set_path_test uri: http://httpbin.org:80 @@ -165,8 +174,8 @@ myservice: logging: level: org.springframework.cloud.gateway: TRACE - org.springframework.http.server.reactive: DEBUG - reactor.ipc.netty: DEBUG +# org.springframework.http.server.reactive: DEBUG +# reactor.ipc.netty: DEBUG management: context-path: /admin