-
-
Notifications
You must be signed in to change notification settings - Fork 973
Add default configurable Grails security response headers #15967
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 8.0.x
Are you sure you want to change the base?
Changes from all commits
4bc02bf
109f183
7f23ba6
ca355c4
e38c2e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * https://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, | ||
| * software distributed under the License is distributed on an | ||
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| * KIND, either express or implied. See the License for the | ||
| * specific language governing permissions and limitations | ||
| * under the License. | ||
| */ | ||
| package org.grails.plugins.web.controllers; | ||
|
|
||
| import java.util.EnumSet; | ||
|
|
||
| import jakarta.servlet.DispatcherType; | ||
|
|
||
| import org.springframework.boot.autoconfigure.AutoConfiguration; | ||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; | ||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; | ||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; | ||
| import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||
| import org.springframework.boot.web.servlet.FilterRegistrationBean; | ||
| import org.springframework.context.annotation.Bean; | ||
|
|
||
| import org.grails.web.config.http.GrailsFilters; | ||
|
|
||
| @AutoConfiguration | ||
| @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) | ||
| @ConditionalOnBooleanProperty(name = "grails.security.headers.enabled", matchIfMissing = true) | ||
| @EnableConfigurationProperties(GrailsSecurityHeadersProperties.class) | ||
| public class GrailsSecurityHeadersAutoConfiguration { | ||
|
|
||
| @Bean | ||
| @ConditionalOnMissingBean(value = GrailsSecurityHeadersFilter.class, name = "grailsSecurityHeadersFilter") | ||
| public GrailsSecurityHeadersFilter securityHeadersFilter(GrailsSecurityHeadersProperties properties) { | ||
| return new GrailsSecurityHeadersFilter(properties); | ||
| } | ||
|
|
||
| @Bean | ||
| @ConditionalOnMissingBean(name = "grailsSecurityHeadersFilter") | ||
| public FilterRegistrationBean<GrailsSecurityHeadersFilter> grailsSecurityHeadersFilter( | ||
| GrailsSecurityHeadersFilter securityHeadersFilter) { | ||
| FilterRegistrationBean<GrailsSecurityHeadersFilter> registrationBean = new FilterRegistrationBean<>(); | ||
| registrationBean.setFilter(securityHeadersFilter); | ||
| registrationBean.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD, | ||
| DispatcherType.INCLUDE, DispatcherType.ERROR)); | ||
| registrationBean.addUrlPatterns("/*"); | ||
| registrationBean.setOrder(GrailsFilters.LAST.getOrder()); | ||
| return registrationBean; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * https://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, | ||
| * software distributed under the License is distributed on an | ||
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| * KIND, either express or implied. See the License for the | ||
| * specific language governing permissions and limitations | ||
| * under the License. | ||
| */ | ||
| package org.grails.plugins.web.controllers; | ||
|
|
||
| import java.io.IOException; | ||
|
|
||
| import jakarta.servlet.FilterChain; | ||
| import jakarta.servlet.ServletException; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
|
|
||
| import org.springframework.util.StringUtils; | ||
| import org.springframework.web.filter.OncePerRequestFilter; | ||
|
|
||
| public class GrailsSecurityHeadersFilter extends OncePerRequestFilter { | ||
|
|
||
| private final GrailsSecurityHeadersProperties properties; | ||
|
|
||
| public GrailsSecurityHeadersFilter(GrailsSecurityHeadersProperties properties) { | ||
| this.properties = properties; | ||
| } | ||
|
|
||
| @Override | ||
| protected boolean shouldNotFilterErrorDispatch() { | ||
| return false; | ||
| } | ||
|
|
||
| @Override | ||
| protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) | ||
| throws ServletException, IOException { | ||
| applyHeader(response, "X-Content-Type-Options", properties.getContentTypeOptions()); | ||
| applyHeader(response, "X-Frame-Options", properties.getFrameOptions()); | ||
| applyHeader(response, "Referrer-Policy", properties.getReferrerPolicy()); | ||
| applyHeader(response, "X-XSS-Protection", properties.getXssProtection()); | ||
| if (request.isSecure()) { | ||
| applyHeader(response, "Strict-Transport-Security", properties.getHsts()); | ||
| } | ||
| applyHeader(response, "Content-Security-Policy", properties.getContentSecurityPolicy()); | ||
| filterChain.doFilter(request, response); | ||
|
jamesfredley marked this conversation as resolved.
|
||
| } | ||
|
|
||
| private static void applyHeader(HttpServletResponse response, String name, | ||
| GrailsSecurityHeadersProperties.Header header) { | ||
| if (header != null && header.isEnabled() && StringUtils.hasText(header.getValue()) && | ||
| !response.containsHeader(name)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Since this is on by default, existing proxy-managed deployments change behavior silently on upgrade. At minimum the security guide and the 8.0 upgrade notes should call this out explicitly with the mitigation (set |
||
| response.setHeader(name, header.getValue()); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| /* | ||
| * Licensed to the Apache Software Foundation (ASF) under one | ||
| * or more contributor license agreements. See the NOTICE file | ||
| * distributed with this work for additional information | ||
| * regarding copyright ownership. The ASF licenses this file | ||
| * to you under the Apache License, Version 2.0 (the | ||
| * "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at | ||
| * | ||
| * https://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, | ||
| * software distributed under the License is distributed on an | ||
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| * KIND, either express or implied. See the License for the | ||
| * specific language governing permissions and limitations | ||
| * under the License. | ||
| */ | ||
| package org.grails.plugins.web.controllers; | ||
|
|
||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||
|
|
||
| @ConfigurationProperties(prefix = "grails.security.headers") | ||
| public class GrailsSecurityHeadersProperties { | ||
|
|
||
| private boolean enabled = true; | ||
|
|
||
| private Header contentTypeOptions = new Header(true, "nosniff"); | ||
|
|
||
| private Header frameOptions = new Header(true, "SAMEORIGIN"); | ||
|
|
||
| private Header referrerPolicy = new Header(true, "strict-origin-when-cross-origin"); | ||
|
|
||
| private Header xssProtection = new Header(true, "0"); | ||
|
|
||
| private Header hsts = new Header(false, "max-age=31536000"); | ||
|
|
||
| private Header contentSecurityPolicy = new Header(false, null); | ||
|
|
||
| public boolean isEnabled() { | ||
| return enabled; | ||
| } | ||
|
|
||
| public void setEnabled(boolean enabled) { | ||
| this.enabled = enabled; | ||
| } | ||
|
|
||
| public Header getContentTypeOptions() { | ||
| return contentTypeOptions; | ||
| } | ||
|
|
||
| public void setContentTypeOptions(Header contentTypeOptions) { | ||
| this.contentTypeOptions = contentTypeOptions; | ||
| } | ||
|
|
||
| public Header getFrameOptions() { | ||
| return frameOptions; | ||
| } | ||
|
|
||
| public void setFrameOptions(Header frameOptions) { | ||
| this.frameOptions = frameOptions; | ||
| } | ||
|
|
||
| public Header getReferrerPolicy() { | ||
| return referrerPolicy; | ||
| } | ||
|
|
||
| public void setReferrerPolicy(Header referrerPolicy) { | ||
| this.referrerPolicy = referrerPolicy; | ||
| } | ||
|
|
||
| public Header getXssProtection() { | ||
| return xssProtection; | ||
| } | ||
|
|
||
| public void setXssProtection(Header xssProtection) { | ||
| this.xssProtection = xssProtection; | ||
| } | ||
|
|
||
| public Header getHsts() { | ||
| return hsts; | ||
| } | ||
|
|
||
| public void setHsts(Header hsts) { | ||
| this.hsts = hsts; | ||
| } | ||
|
|
||
| public Header getContentSecurityPolicy() { | ||
| return contentSecurityPolicy; | ||
| } | ||
|
|
||
| public void setContentSecurityPolicy(Header contentSecurityPolicy) { | ||
| this.contentSecurityPolicy = contentSecurityPolicy; | ||
| } | ||
|
|
||
| public static class Header { | ||
|
|
||
| private boolean enabled; | ||
|
|
||
| private String value; | ||
|
|
||
| public Header() { | ||
| } | ||
|
|
||
| Header(boolean enabled, String value) { | ||
| this.enabled = enabled; | ||
| this.value = value; | ||
| } | ||
|
|
||
| public boolean isEnabled() { | ||
| return enabled; | ||
| } | ||
|
|
||
| public void setEnabled(boolean enabled) { | ||
| this.enabled = enabled; | ||
| } | ||
|
|
||
| public String getValue() { | ||
| return value; | ||
| } | ||
|
|
||
| public void setValue(String value) { | ||
| this.value = value; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| { | ||
| "properties": [ | ||
| { | ||
| "name": "grails.security.headers.enabled", | ||
| "description": "Whether Grails registers the default servlet HTTP security headers filter.", | ||
| "type": "java.lang.Boolean", | ||
| "defaultValue": true | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.content-type-options.enabled", | ||
| "description": "Whether to send the X-Content-Type-Options response header.", | ||
| "type": "java.lang.Boolean", | ||
| "defaultValue": true | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.content-type-options.value", | ||
| "description": "Value for the X-Content-Type-Options response header.", | ||
| "type": "java.lang.String", | ||
| "defaultValue": "nosniff" | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.frame-options.enabled", | ||
| "description": "Whether to send the X-Frame-Options response header.", | ||
| "type": "java.lang.Boolean", | ||
| "defaultValue": true | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.frame-options.value", | ||
| "description": "Value for the X-Frame-Options response header.", | ||
| "type": "java.lang.String", | ||
| "defaultValue": "SAMEORIGIN" | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.referrer-policy.enabled", | ||
| "description": "Whether to send the Referrer-Policy response header.", | ||
| "type": "java.lang.Boolean", | ||
| "defaultValue": true | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.referrer-policy.value", | ||
| "description": "Value for the Referrer-Policy response header.", | ||
| "type": "java.lang.String", | ||
| "defaultValue": "strict-origin-when-cross-origin" | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.xss-protection.enabled", | ||
| "description": "Whether to send the X-XSS-Protection response header.", | ||
| "type": "java.lang.Boolean", | ||
| "defaultValue": true | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.xss-protection.value", | ||
| "description": "Value for the X-XSS-Protection response header.", | ||
| "type": "java.lang.String", | ||
| "defaultValue": "0" | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.hsts.enabled", | ||
| "description": "Whether to send Strict-Transport-Security on secure requests.", | ||
| "type": "java.lang.Boolean", | ||
| "defaultValue": false | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.hsts.value", | ||
| "description": "Value for the Strict-Transport-Security response header when HSTS is enabled and the request is secure.", | ||
| "type": "java.lang.String", | ||
| "defaultValue": "max-age=31536000" | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.content-security-policy.enabled", | ||
| "description": "Whether to send the Content-Security-Policy response header.", | ||
| "type": "java.lang.Boolean", | ||
| "defaultValue": false | ||
| }, | ||
| { | ||
| "name": "grails.security.headers.content-security-policy.value", | ||
| "description": "Value for the Content-Security-Policy response header when CSP is enabled.", | ||
| "type": "java.lang.String" | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| org.grails.plugins.web.controllers.ControllersAutoConfiguration | ||
| org.grails.plugins.web.controllers.GrailsFormContentFilterAutoConfiguration | ||
| org.grails.plugins.web.controllers.GrailsSecurityHeadersAutoConfiguration | ||
| org.grails.plugins.web.controllers.GrailsViewResolverAutoConfiguration | ||
| org.grails.plugins.web.controllers.GrailsWelcomePageAutoConfiguration |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Behind a TLS-terminating proxy (the typical nginx/haproxy topology), the backend connection is plain HTTP, so
request.isSecure()isfalseand HSTS is silently never sent — even when the user has explicitly setgrails.security.headers.hsts.enabled: true. It only works if the app is also configured withserver.forward-headers-strategy: framework|nativesoX-Forwarded-Protois honored (theForwardedHeaderFilter/RemoteIpValverun well before this filter, so ordering is fine once that's set).The docs currently just say HSTS is sent "only for secure requests" — that should spell out the proxy case: either configure
server.forward-headers-strategy, or (usually better) set HSTS at the proxy where TLS actually terminates. Otherwise enabling HSTS here is a silent no-op for most real deployments.