diff --git a/core/src/main/java/org/apache/struts2/interceptor/csp/CspInterceptor.java b/core/src/main/java/org/apache/struts2/interceptor/csp/CspInterceptor.java index 32d6777869..54d9eeab1c 100644 --- a/core/src/main/java/org/apache/struts2/interceptor/csp/CspInterceptor.java +++ b/core/src/main/java/org/apache/struts2/interceptor/csp/CspInterceptor.java @@ -46,6 +46,9 @@ public final class CspInterceptor extends AbstractInterceptor { private boolean prependServletContext = true; private boolean enforcingMode; private String reportUri; + private String reportTo; + + private String defaultCspSettingsClassName = DefaultCspSettings.class.getName(); @Override public String intercept(ActionInvocation invocation) throws Exception { @@ -54,8 +57,24 @@ public String intercept(ActionInvocation invocation) throws Exception { LOG.trace("Using CspSettings provided by the action: {}", action); applySettings(invocation, ((CspSettingsAware) action).getCspSettings()); } else { - LOG.trace("Using DefaultCspSettings with action: {}", action); - applySettings(invocation, new DefaultCspSettings()); + LOG.trace("Using {} with action: {}", defaultCspSettingsClassName, action); + + // if the defaultCspSettingsClassName is not a real class, throw an exception + try { + Class.forName(defaultCspSettingsClassName, false, Thread.currentThread().getContextClassLoader()); + } + catch (ClassNotFoundException e) { + throw new IllegalArgumentException("The defaultCspSettingsClassName must be a real class."); + } + + // if defaultCspSettingsClassName does not implement CspSettings, throw an exception + if (!CspSettings.class.isAssignableFrom(Class.forName(defaultCspSettingsClassName))) { + throw new IllegalArgumentException("The defaultCspSettingsClassName must implement CspSettings."); + } + + CspSettings cspSettings = (CspSettings) Class.forName(defaultCspSettingsClassName) + .getDeclaredConstructor().newInstance(); + applySettings(invocation, cspSettings); } return invocation.invoke(); } @@ -76,6 +95,12 @@ private void applySettings(ActionInvocation invocation, CspSettings cspSettings) } cspSettings.setReportUri(finalReportUri); + + // apply reportTo if set + if (reportTo != null) { + LOG.trace("Applying: {} to reportTo", reportTo); + cspSettings.setReportTo(reportTo); + } } invocation.addPreResultListener((actionInvocation, resultCode) -> { @@ -97,6 +122,18 @@ public void setReportUri(String reportUri) { this.reportUri = reportUri; } + /** + * Sets the report group where csp violation reports will be sent. This will + * only be used if the reportUri is set. + * + * @param reportTo the report group where csp violation reports will be sent + * + * @since Struts 6.5.0 + */ + public void setReportTo(String reportTo) { + this.reportTo = reportTo; + } + private Optional buildUri(String reportUri) { try { return Optional.of(URI.create(reportUri)); @@ -124,4 +161,13 @@ public void setPrependServletContext(boolean prependServletContext) { this.prependServletContext = prependServletContext; } -} + /** + * Sets the class name of the default {@link CspSettings} implementation to use when the action does not + * set its own values. If not set, the default is {@link DefaultCspSettings}. + * + * @since Struts 6.5.0 + */ + public void setDefaultCspSettingsClassName(String defaultCspSettingsClassName) { + this.defaultCspSettingsClassName = defaultCspSettingsClassName; + } +} \ No newline at end of file diff --git a/core/src/main/java/org/apache/struts2/interceptor/csp/CspSettings.java b/core/src/main/java/org/apache/struts2/interceptor/csp/CspSettings.java index acb1429629..a8c2a68c2e 100644 --- a/core/src/main/java/org/apache/struts2/interceptor/csp/CspSettings.java +++ b/core/src/main/java/org/apache/struts2/interceptor/csp/CspSettings.java @@ -37,6 +37,7 @@ public interface CspSettings { String SCRIPT_SRC = "script-src"; String BASE_URI = "base-uri"; String REPORT_URI = "report-uri"; + String REPORT_TO = "report-to"; String NONE = "none"; String STRICT_DYNAMIC = "strict-dynamic"; String HTTP = "http:"; @@ -56,6 +57,13 @@ public interface CspSettings { */ void setReportUri(String uri); + /** + * Sets the report group where csp violation reports will be sent + * + * @since Struts 6.5.0 + */ + void setReportTo(String group); + /** * Sets CSP headers in enforcing mode when true, and report-only when false */ diff --git a/core/src/main/java/org/apache/struts2/interceptor/csp/DefaultCspSettings.java b/core/src/main/java/org/apache/struts2/interceptor/csp/DefaultCspSettings.java index d1768e8696..b245ab3526 100644 --- a/core/src/main/java/org/apache/struts2/interceptor/csp/DefaultCspSettings.java +++ b/core/src/main/java/org/apache/struts2/interceptor/csp/DefaultCspSettings.java @@ -20,6 +20,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.struts2.action.CspSettingsAware; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -31,7 +32,11 @@ /** * Default implementation of {@link CspSettings}. - * The default policy implements strict CSP with a nonce based approach and follows the guide: https://csp.withgoogle.com/docs/index.html/ + * The default policy implements strict CSP with a nonce based approach and follows the guide: + * https://csp.withgoogle.com/docs/index.html/ + * You may extend or replace this class if you wish to customize the default policy further, and use your class + * by setting the {@link CspInterceptor} defaultCspSettingsClassName parameter. Actions that + * implement the {@link CspSettingsAware} interface will ignore the defaultCspSettingsClassName parameter. * * @see CspSettings * @see CspInterceptor @@ -42,20 +47,22 @@ public class DefaultCspSettings implements CspSettings { private final SecureRandom sRand = new SecureRandom(); - private String reportUri; + protected String reportUri; + protected String reportTo; // default to reporting mode - private String cspHeader = CSP_REPORT_HEADER; + protected String cspHeader = CSP_REPORT_HEADER; @Override public void addCspHeaders(HttpServletResponse response) { throw new UnsupportedOperationException("Unsupported implementation, use #addCspHeaders(HttpServletRequest request, HttpServletResponse response)"); } + @Override public void addCspHeaders(HttpServletRequest request, HttpServletResponse response) { if (isSessionActive(request)) { LOG.trace("Session is active, applying CSP settings"); associateNonceWithSession(request); - response.setHeader(cspHeader, cratePolicyFormat(request)); + response.setHeader(cspHeader, createPolicyFormat(request)); } else { LOG.trace("Session is not active, ignoring CSP settings"); } @@ -70,7 +77,7 @@ private void associateNonceWithSession(HttpServletRequest request) { request.getSession().setAttribute("nonce", nonceValue); } - private String cratePolicyFormat(HttpServletRequest request) { + protected String createPolicyFormat(HttpServletRequest request) { StringBuilder policyFormatBuilder = new StringBuilder() .append(OBJECT_SRC) .append(format(" '%s'; ", NONE)) @@ -84,13 +91,18 @@ private String cratePolicyFormat(HttpServletRequest request) { if (reportUri != null) { policyFormatBuilder .append(REPORT_URI) - .append(format(" %s", reportUri)); + .append(format(" %s; ", reportUri)); + if(reportTo != null) { + policyFormatBuilder + .append(REPORT_TO) + .append(format(" %s; ", reportTo)); + } } return format(policyFormatBuilder.toString(), getNonceString(request)); } - private String getNonceString(HttpServletRequest request) { + protected String getNonceString(HttpServletRequest request) { Object nonce = request.getSession().getAttribute("nonce"); return Objects.toString(nonce); } @@ -101,20 +113,28 @@ private byte[] getRandomBytes() { return ret; } + @Override public void setEnforcingMode(boolean enforcingMode) { if (enforcingMode) { cspHeader = CSP_ENFORCE_HEADER; } } + @Override public void setReportUri(String reportUri) { this.reportUri = reportUri; } + @Override + public void setReportTo(String reportTo) { + this.reportTo = reportTo; + } + @Override public String toString() { return "DefaultCspSettings{" + "reportUri='" + reportUri + '\'' + + ", reportTo='" + reportTo + '\'' + ", cspHeader='" + cspHeader + '\'' + '}'; } diff --git a/core/src/test/java/org/apache/struts2/interceptor/CspInterceptorTest.java b/core/src/test/java/org/apache/struts2/interceptor/CspInterceptorTest.java index 0b03c6e54c..cd59c347da 100644 --- a/core/src/test/java/org/apache/struts2/interceptor/CspInterceptorTest.java +++ b/core/src/test/java/org/apache/struts2/interceptor/CspInterceptorTest.java @@ -31,6 +31,7 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import static org.junit.Assert.assertNotEquals; @@ -74,8 +75,10 @@ public void test_whenNonceAlreadySetInSession_andRequestReceived_thenNewNonceIsS public void testEnforcingCspHeadersSet() throws Exception { String reportUri = "/csp-reports"; + String reportTo = "csp-group"; boolean enforcingMode = true; interceptor.setReportUri(reportUri); + interceptor.setReportTo(reportTo); interceptor.setEnforcingMode(enforcingMode); session.setAttribute("nonce", "foo"); @@ -84,13 +87,15 @@ public void testEnforcingCspHeadersSet() throws Exception { assertNotNull("Nonce key does not exist", session.getAttribute("nonce")); assertFalse("Nonce value is empty", Strings.isEmpty((String) session.getAttribute("nonce"))); assertNotEquals("New nonce value couldn't be set", "foo", session.getAttribute("nonce")); - checkHeader(reportUri, enforcingMode); + checkHeader(reportUri, reportTo, enforcingMode); } public void testReportingCspHeadersSet() throws Exception { String reportUri = "/csp-reports"; + String reportTo = "csp-group"; boolean enforcingMode = false; interceptor.setReportUri(reportUri); + interceptor.setReportTo(reportTo); interceptor.setEnforcingMode(enforcingMode); session.setAttribute("nonce", "foo"); @@ -98,7 +103,7 @@ public void testReportingCspHeadersSet() throws Exception { assertNotNull("Nonce value is empty", session.getAttribute("nonce")); assertNotEquals("New nonce value couldn't be set", "foo", session.getAttribute("nonce")); - checkHeader(reportUri, enforcingMode); + checkHeader(reportUri, reportTo, enforcingMode); } public void test_uriSetOnlyWhenSetIsCalled() throws Exception { @@ -174,7 +179,47 @@ public void testNoPrependContext() throws Exception { checkHeader("/report-uri", enforcingMode); } + public void testInvalidDefaultCspSettingsClassName() throws Exception { + boolean enforcingMode = true; + mai.setAction(new TestAction()); + request.setContextPath("/app"); + + interceptor.setEnforcingMode(enforcingMode); + interceptor.setReportUri("/report-uri"); + interceptor.setPrependServletContext(false); + + try { + interceptor.setDefaultCspSettingsClassName("foo"); + interceptor.intercept(mai); + assert (false); + } catch (IllegalArgumentException e) { + assert (true); + } + } + + public void testCustomDefaultCspSettingsClassName() throws Exception { + boolean enforcingMode = true; + mai.setAction(new TestAction()); + request.setContextPath("/app"); + + interceptor.setEnforcingMode(enforcingMode); + interceptor.setReportUri("/report-uri"); + interceptor.setPrependServletContext(false); + interceptor.setDefaultCspSettingsClassName(CustomDefaultCspSettings.class.getName()); + + interceptor.intercept(mai); + + String header = response.getHeader(CspSettings.CSP_ENFORCE_HEADER); + + // no other customization matters for this particular class + assertEquals("foo", header); + } + public void checkHeader(String reportUri, boolean enforcingMode) { + checkHeader(reportUri, null, enforcingMode); + } + + public void checkHeader(String reportUri, String reportTo, boolean enforcingMode) { String expectedCspHeader; if (Strings.isEmpty(reportUri)) { expectedCspHeader = String.format("%s '%s'; %s 'nonce-%s' '%s' %s %s; %s '%s'; ", @@ -183,12 +228,23 @@ public void checkHeader(String reportUri, boolean enforcingMode) { CspSettings.BASE_URI, CspSettings.NONE ); } else { - expectedCspHeader = String.format("%s '%s'; %s 'nonce-%s' '%s' %s %s; %s '%s'; %s %s", - CspSettings.OBJECT_SRC, CspSettings.NONE, - CspSettings.SCRIPT_SRC, session.getAttribute("nonce"), CspSettings.STRICT_DYNAMIC, CspSettings.HTTP, CspSettings.HTTPS, - CspSettings.BASE_URI, CspSettings.NONE, - CspSettings.REPORT_URI, reportUri - ); + if (Strings.isEmpty(reportTo)) { + expectedCspHeader = String.format("%s '%s'; %s 'nonce-%s' '%s' %s %s; %s '%s'; %s %s; ", + CspSettings.OBJECT_SRC, CspSettings.NONE, + CspSettings.SCRIPT_SRC, session.getAttribute("nonce"), CspSettings.STRICT_DYNAMIC, CspSettings.HTTP, CspSettings.HTTPS, + CspSettings.BASE_URI, CspSettings.NONE, + CspSettings.REPORT_URI, reportUri + ); + } + else { + expectedCspHeader = String.format("%s '%s'; %s 'nonce-%s' '%s' %s %s; %s '%s'; %s %s; %s %s; ", + CspSettings.OBJECT_SRC, CspSettings.NONE, + CspSettings.SCRIPT_SRC, session.getAttribute("nonce"), CspSettings.STRICT_DYNAMIC, CspSettings.HTTP, CspSettings.HTTPS, + CspSettings.BASE_URI, CspSettings.NONE, + CspSettings.REPORT_URI, reportUri, + CspSettings.REPORT_TO, reportTo + ); + } } String header; @@ -230,4 +286,15 @@ public CspSettings getCspSettings() { return settings; } } + + /** + * Custom DefaultCspSettings class that overrides the createPolicyFormat method + * to return a fixed value. + */ + public static class CustomDefaultCspSettings extends DefaultCspSettings { + + protected String createPolicyFormat(HttpServletRequest request) { + return "foo"; + } + } }