Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()) {

Copy link
Copy Markdown
Contributor

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() is false and HSTS is silently never sent — even when the user has explicitly set grails.security.headers.hsts.enabled: true. It only works if the app is also configured with server.forward-headers-strategy: framework|native so X-Forwarded-Proto is honored (the ForwardedHeaderFilter / RemoteIpValve run 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.

applyHeader(response, "Strict-Transport-Security", properties.getHsts());
}
applyHeader(response, "Content-Security-Policy", properties.getContentSecurityPolicy());
filterChain.doFilter(request, response);
Comment thread
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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The containsHeader guard can't see headers injected by a reverse proxy, because those are added after the response leaves the app. In the very common setup where nginx already sends these headers via add_header (which appends and never replaces upstream headers), upgrading to 8.0 means the client suddenly receives the header twice — e.g. proxy X-Frame-Options: DENY plus app SAMEORIGIN. Browsers handle conflicting duplicates inconsistently: duplicate conflicting X-Frame-Options causes Chrome to block framing outright, duplicate Referrer-Policy resolves to the last valid value, and multiple Content-Security-Policy headers are enforced as the intersection of all policies (strictly tighter, can break pages). haproxy http-response set-header replaces, so it's unaffected, but nginx add_header deployments will double-send by default.

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 grails.security.headers.enabled: false — or per-header enabled: false — when the edge proxy owns these headers, or strip the app's copies at the proxy). Worth also considering whether on-by-default is the right trade-off for an upgrade release given this.

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
Loading
Loading