You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@wicket.apache.org by pa...@apache.org on 2020/01/17 21:26:56 UTC
[wicket] 01/08: WICKET-6727: first code drop for configurable CSP
This is an automated email from the ASF dual-hosted git repository.
papegaaij pushed a commit to branch csp-configurable
in repository https://gitbox.apache.org/repos/asf/wicket.git
commit 0a5cdf17b06b231074788608196c0c7b768f986f
Author: Emond Papegaaij <em...@topicus.nl>
AuthorDate: Mon Jan 13 22:04:00 2020 +0100
WICKET-6727: first code drop for configurable CSP
---
.../wicket/csp/CSPSettingRequestCycleListener.java | 535 +++++++++++++++++++++
.../csp/CspNonceHeaderResponseDecorator.java | 56 +++
.../csp/CSPSettingRequestCycleListenerTest.java | 448 +++++++++++++++++
3 files changed, 1039 insertions(+)
diff --git a/wicket-core/src/main/java/org/apache/wicket/csp/CSPSettingRequestCycleListener.java b/wicket-core/src/main/java/org/apache/wicket/csp/CSPSettingRequestCycleListener.java
new file mode 100644
index 0000000..a32af03
--- /dev/null
+++ b/wicket-core/src/main/java/org/apache/wicket/csp/CSPSettingRequestCycleListener.java
@@ -0,0 +1,535 @@
+package org.apache.wicket.csp;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.apache.wicket.MetaDataKey;
+import org.apache.wicket.request.IRequestHandler;
+import org.apache.wicket.request.cycle.IRequestCycleListener;
+import org.apache.wicket.request.cycle.RequestCycle;
+import org.apache.wicket.request.http.WebResponse;
+import org.apache.wicket.util.string.Strings;
+
+/**
+ * An {@link IRequestCycleListener} that adds {@code Content-Security-Policy} and/or
+ * {@code Content-Security-Policy-Report-Only} headers based on the supplied configuration.
+ *
+ * See also the {@code CSPSettingRequestCycleListenerTest}.
+ *
+ * Example usage:
+ *
+ * <pre>
+ * {@code
+ * myApplication.getRequestCycleListeners().add(
+ * new CSPSettingRequestCycleListener()
+ * .addBlockingDirective(CSPDirective.DEFAULT_SRC, CSPDirectiveSrcValue.NONE)
+ * .addBlockingDirective(CSPDirective.SCRIPT_SRC, CSPDirectiveSrcValue.SELF)
+ * .addBlockingDirective(CSPDirective.IMG_SRC, CSPDirectiveSrcValue.SELF)
+ * .addBlockingDirective(CSPDirective.FONT_SRC, CSPDirectiveSrcValue.SELF));
+ *
+ * myApplication.getRequestCycleListeners().add(
+ * new CSPSettingRequestCycleListener()
+ * .addReportingDirective(CSPDirective.DEFAULT_SRC, CSPDirectiveSrcValue.NONE)
+ * .addReportingDirective(CSPDirective.IMG_SRC, CSPDirectiveSrcValue.SELF)
+ * .addReportingDirective(CSPDirective.FONT_SRC, CSPDirectiveSrcValue.SELF)
+ * .addReportingDirective(CSPDirective.SCRIPT_SRC, CSPDirectiveSrcValue.SELF));
+ * }
+ * </pre>
+ *
+ * {@code frame-src} has been deprecated since CSP 2.0 and replaced by {@code child-src}. Some
+ * browsers do not yet support {@code child-src} and expect {@code frame-src} instead. When
+ * {@code child-src} is added, a matching {@code frame-src} is added automatically for
+ * compatibility.
+ *
+ * @see "http://www.w3.org/TR/CSP2/"
+ * @see "https://developer.mozilla.org/en-US/docs/Web/Security/CSP/CSP_policy_directives"
+ *
+ * @author Sven Haster
+ * @author Emond Papegaaij
+ */
+public class CSPSettingRequestCycleListener implements IRequestCycleListener
+{
+ public static MetaDataKey<String> NONCE_KEY = new MetaDataKey<>()
+ {
+ private static final long serialVersionUID = 1L;
+ };
+
+ public static interface CSPRenderable
+ {
+ public String render(CSPSettingRequestCycleListener listener, RequestCycle cycle);
+ }
+
+ private static final class FixedCSPDirective implements CSPRenderable
+ {
+ private String value;
+
+ public FixedCSPDirective(String value)
+ {
+ if (Strings.isEmpty(value))
+ throw new IllegalArgumentException(
+ "CSP directive cannot have empty or null values");
+ this.value = value;
+ }
+
+ @Override
+ public String render(CSPSettingRequestCycleListener listener, RequestCycle cycle)
+ {
+ return value;
+ }
+ }
+
+ /**
+ * An enum holding the default values for -src directives including the mandatory single quotes
+ */
+ public enum CSPDirectiveSrcValue implements CSPRenderable
+ {
+ NONE("'none'"),
+ WILDCARD("*"),
+ SELF("'self'"),
+ UNSAFE_INLINE("'unsafe-inline'"),
+ UNSAFE_EVAL("'unsafe-eval'"),
+ STRICT_DYNAMIC("'strict-dynamic'"),
+ NONCE("'nonce-%1$s'")
+ {
+ @Override
+ public String render(CSPSettingRequestCycleListener listener, RequestCycle cycle)
+ {
+ return String.format(getValue(), listener.getNonce(cycle));
+ }
+ };
+
+ private String value;
+
+ private CSPDirectiveSrcValue(String value)
+ {
+ this.value = value;
+ }
+
+ @Override
+ public String render(CSPSettingRequestCycleListener listener, RequestCycle cycle)
+ {
+ return value;
+ }
+
+ public String getValue()
+ {
+ return value;
+ }
+ }
+
+ /**
+ * An enum representing the only possible values for the sandbox directive
+ */
+ public enum CSPDirectiveSandboxValue implements CSPRenderable
+ {
+ ALLOW_FORMS("allow-forms"),
+ ALLOW_SAME_ORIGIN("allow-same-origin"),
+ ALLOW_SCRIPTS("allow-scripts"),
+ ALLOW_TOP_NAVIGATION("allow-top-navigation"),
+ EMPTY("");
+
+ private String value;
+
+ private CSPDirectiveSandboxValue(String value)
+ {
+ this.value = value;
+ }
+
+ public String getValue()
+ {
+ return value;
+ }
+
+ @Override
+ public String render(CSPSettingRequestCycleListener listener, RequestCycle cycle)
+ {
+ return value;
+ }
+ }
+
+ /** An enum holding the possible CSP Directives */
+ public enum CSPDirective
+ {
+ DEFAULT_SRC("default-src"),
+ SCRIPT_SRC("script-src"),
+ STYLE_SRC("style-src"),
+ IMG_SRC("img-src"),
+ CONNECT_SRC("connect-src"),
+ FONT_SRC("font-src"),
+ OBJECT_SRC("object-src"),
+ MANIFEST_SRC("manifest-src"),
+ MEDIA_SRC("media-src"),
+ CHILD_SRC("child-src"),
+ FRAME_ANCESTORS("frame-ancestors"),
+ @Deprecated
+ /** @deprecated Gebruik CHILD-SRC, deze zet ook automatisch FRAME-SRC. */
+ FRAME_SRC("frame-src"),
+ SANDBOX("sandbox")
+ {
+ @Override
+ protected void checkValueForDirective(CSPRenderable value,
+ List<CSPRenderable> existingDirectiveValues)
+ {
+ if (!existingDirectiveValues.isEmpty())
+ {
+ if (CSPDirectiveSandboxValue.EMPTY.equals(value))
+ {
+ throw new IllegalArgumentException(
+ "A sandbox directive can't contain an empty string if it already contains other values ");
+ }
+ if (existingDirectiveValues.contains(CSPDirectiveSandboxValue.EMPTY))
+ {
+ throw new IllegalArgumentException(
+ "A sandbox directive can't contain other values if it already contains an empty string");
+ }
+ }
+
+ if (!(value instanceof CSPDirectiveSandboxValue))
+ {
+ throw new IllegalArgumentException(
+ "A sandbox directive can only contain values from CSPDirectiveSandboxValue or be empty");
+ }
+ }
+ },
+ REPORT_URI("report-uri")
+ {
+ @Override
+ protected void checkValueForDirective(CSPRenderable value,
+ List<CSPRenderable> existingDirectiveValues)
+ {
+ if (!existingDirectiveValues.isEmpty())
+ {
+ throw new IllegalArgumentException(
+ "A report-uri directive can only contain one uri");
+ }
+ if (!(value instanceof FixedCSPDirective))
+ {
+ throw new IllegalArgumentException(
+ "A report-uri directive can only contain an URI");
+ }
+ try
+ {
+ new URI(value.render(null, null));
+ }
+ catch (URISyntaxException urise)
+ {
+ throw new IllegalArgumentException("Illegal URI for report-uri directive",
+ urise);
+ }
+ }
+ };
+
+ private String value;
+
+ private CSPDirective(String value)
+ {
+ this.value = value;
+ }
+
+ public String getValue()
+ {
+ return value;
+ }
+
+ protected void checkValueForDirective(CSPRenderable value,
+ List<CSPRenderable> existingDirectiveValues)
+ {
+ if (!existingDirectiveValues.isEmpty())
+ {
+ if (CSPDirectiveSrcValue.WILDCARD.equals(value)
+ || CSPDirectiveSrcValue.NONE.equals(value))
+ {
+ throw new IllegalArgumentException(
+ "A -src directive can't contain an * or a 'none' if it already contains other values ");
+ }
+ if (existingDirectiveValues.contains(CSPDirectiveSrcValue.WILDCARD)
+ || existingDirectiveValues.contains(CSPDirectiveSrcValue.NONE))
+ {
+ throw new IllegalArgumentException(
+ "A -src directive can't contain other values if it already contains an * or a 'none'");
+ }
+ }
+
+ if (value instanceof CSPDirectiveSrcValue)
+ {
+ return;
+ }
+
+ if (value instanceof CSPDirectiveSandboxValue)
+ {
+ throw new IllegalArgumentException(
+ "A -src directive can't contain any of the sandbox directive values");
+ }
+
+ String strValue = value.render(null, null);
+ if ("data:".equals(strValue) || "https:".equals(strValue))
+ {
+ return;
+ }
+
+ // strip off "*." so "*.example.com" becomes "example.com" and we can check if
+ // it
+ // is a valid uri
+ if (strValue.startsWith("*."))
+ {
+ strValue = strValue.substring(2);
+ }
+
+ try
+ {
+ new URI(strValue);
+ }
+ catch (URISyntaxException urise)
+ {
+ throw new IllegalArgumentException("Illegal URI for -src directive", urise);
+ }
+ }
+
+ /**
+ * @return The CSPDirective constant whose value-parameter equals the input-parameter or
+ * {@code null} if none can be found.
+ */
+ public static CSPDirective fromValue(String value)
+ {
+ if (Strings.isEmpty(value))
+ return null;
+ for (int i = 0; i < values().length; i++)
+ {
+ if (value.equals(values()[i].getValue()))
+ return values()[i];
+ }
+ return null;
+ }
+ }
+
+ private enum CSPHeaderMode
+ {
+ BLOCKING,
+ REPORT_ONLY;
+ }
+
+ private static String HEADER_CSP = "Content-Security-Policy";
+
+ private static String HEADER_CSP_REPORT = "Content-Security-Policy-Report-Only";
+
+ private static String HEADER_CSP_IE = "X-Content-Security-Policy";
+
+ private static String HEADER_CSP_REPORT_IE = "X-Content-Security-Policy-Report-Only";
+
+ // Directives for the 'Content-Security-Policy' header
+ private Map<CSPDirective, List<CSPRenderable>> blockingDirectives =
+ new EnumMap<>(CSPDirective.class);
+
+ // Directives for the 'Content-Security-Policy-Report-Only' header
+ private Map<CSPDirective, List<CSPRenderable>> reportingDirectives =
+ new EnumMap<>(CSPDirective.class);
+
+ private Function<Integer, byte[]> randomSupplier;
+
+ private boolean addLegacyHeaders = false;
+
+ public CSPSettingRequestCycleListener()
+ {
+ }
+
+ public CSPSettingRequestCycleListener(Function<Integer, byte[]> randomSupplier)
+ {
+ this.randomSupplier = randomSupplier;
+ }
+
+ /**
+ * True when legacy headers should be added.
+ *
+ * @return True when legacy headers should be added.
+ */
+ public boolean isAddLegacyHeaders()
+ {
+ return addLegacyHeaders;
+ }
+
+ /**
+ * Enable legacy {@code X-Content-Security-Policy} headers for older browsers, such as IE.
+ *
+ * @param addLegacyHeaders True when the legacy headers should be added.
+ * @return {@code this} for chaining
+ */
+ public CSPSettingRequestCycleListener setAddLegacyHeaders(boolean addLegacyHeaders)
+ {
+ this.addLegacyHeaders = addLegacyHeaders;
+ return this;
+ }
+
+ /**
+ * Adds any of the default values to a -src directive for the 'blocking' CSP header
+ */
+ public CSPSettingRequestCycleListener addBlockingDirective(CSPDirective directive,
+ CSPDirectiveSrcValue... values)
+ {
+ for (CSPDirectiveSrcValue value : values)
+ {
+ addDirective(directive, value, CSPHeaderMode.BLOCKING);
+ }
+ return this;
+ }
+
+ /**
+ * Adds any of the default values to the sandbox directive for the 'blocking' CSP header. Use
+ * {@link #addBlockingDirective(CSPDirective, String...)} with the sandbox {@link CSPDirective}
+ * and a single empty string (<em>not</em> {@code null}) to add the empty sandbox directive.
+ */
+ public CSPSettingRequestCycleListener addBlockingDirective(CSPDirective sandboxDirective,
+ CSPDirectiveSandboxValue... values)
+ {
+ for (CSPDirectiveSandboxValue value : values)
+ {
+ addDirective(sandboxDirective, value, CSPHeaderMode.BLOCKING);
+ }
+ return this;
+ }
+
+ /**
+ * Adds any value to a directive for the 'blocking' CSP header. Use
+ * {@link #addBlockingDirective(CSPDirective, CSPDirectiveSandboxValue...)} and
+ * {@link #addBlockingDirective(CSPDirective, CSPDirectiveSrcValue...)} for the default values
+ * for the sandbox and -src directives.
+ */
+ public CSPSettingRequestCycleListener addBlockingDirective(CSPDirective directive,
+ String... values)
+ {
+ for (String value : values)
+ {
+ addDirective(directive, new FixedCSPDirective(value), CSPHeaderMode.BLOCKING);
+ }
+ return this;
+ }
+
+ /**
+ * Adds any of the default values to a -src directive for the 'reporting-only' CSP header
+ */
+ public CSPSettingRequestCycleListener addReportingDirective(CSPDirective directive,
+ CSPDirectiveSrcValue... values)
+ {
+ for (CSPDirectiveSrcValue value : values)
+ {
+ addDirective(directive, value, CSPHeaderMode.REPORT_ONLY);
+ }
+ return this;
+ }
+
+ /**
+ * Adds any of the default values to the sandbox directive for the 'reporting-only' CSP header.
+ * Use {@link #addReportingDirective(CSPDirective, String...)} with the sandbox
+ * {@link CSPDirective} and a single empty string (<em>not</em> {@code null}) to add the empty
+ * sandbox directive.
+ */
+ public CSPSettingRequestCycleListener addReportingDirective(CSPDirective sandboxDirective,
+ CSPDirectiveSandboxValue... values)
+ {
+ for (CSPDirectiveSandboxValue value : values)
+ {
+ addDirective(sandboxDirective, value, CSPHeaderMode.REPORT_ONLY);
+ }
+ return this;
+ }
+
+ /**
+ * Adds any value to a directive for the 'reporting-only' CSP header. Use
+ * {@link #addReportingDirective(CSPDirective, CSPDirectiveSandboxValue...)} and
+ * {@link #addReportingDirective(CSPDirective, CSPDirectiveSrcValue...)} for the default values
+ * for the sandbox and -src directives.
+ */
+ public CSPSettingRequestCycleListener addReportingDirective(CSPDirective directive,
+ String... values)
+ {
+ for (String value : values)
+ {
+ addDirective(directive, new FixedCSPDirective(value), CSPHeaderMode.REPORT_ONLY);
+ }
+ return this;
+ }
+
+ private CSPSettingRequestCycleListener addDirective(CSPDirective directive, CSPRenderable value,
+ CSPHeaderMode mode)
+ {
+ // Add backwards compatible frame-src
+ // see http://caniuse.com/#feat=contentsecuritypolicy2
+ if (CSPDirective.CHILD_SRC.equals(directive))
+ {
+ addDirective(CSPDirective.FRAME_SRC, value, mode);
+ }
+ switch (mode)
+ {
+ case BLOCKING:
+ if (blockingDirectives.get(directive) == null)
+ {
+ blockingDirectives.put(directive, new ArrayList<>());
+ }
+ directive.checkValueForDirective(value, blockingDirectives.get(directive));
+ blockingDirectives.get(directive).add(value);
+ return this;
+ case REPORT_ONLY:
+ if (reportingDirectives.get(directive) == null)
+ {
+ reportingDirectives.put(directive, new ArrayList<>());
+ }
+ directive.checkValueForDirective(value, reportingDirectives.get(directive));
+ reportingDirectives.get(directive).add(value);
+ return this;
+ default:
+ throw new IllegalArgumentException("Incorrect CSPHeaderMode!");
+ }
+
+ }
+
+ @Override
+ public void onRequestHandlerResolved(RequestCycle cycle, IRequestHandler handler)
+ {
+ WebResponse webResponse = (WebResponse) cycle.getResponse();
+ if (!reportingDirectives.isEmpty())
+ {
+ String reportHeaderValue = getCSPHeaderValue(reportingDirectives, cycle);
+ webResponse.setHeader(HEADER_CSP_REPORT, reportHeaderValue);
+ if (addLegacyHeaders)
+ webResponse.setHeader(HEADER_CSP_REPORT_IE, reportHeaderValue);
+ }
+ if (!blockingDirectives.isEmpty())
+ {
+ String blockHeaderValue = getCSPHeaderValue(blockingDirectives, cycle);
+ webResponse.setHeader(HEADER_CSP, blockHeaderValue);
+ if (addLegacyHeaders)
+ webResponse.setHeader(HEADER_CSP_IE, blockHeaderValue);
+ }
+ }
+
+ public String getNonce(RequestCycle cycle)
+ {
+ String nonce = cycle.getMetaData(NONCE_KEY);
+ if (nonce == null)
+ {
+ nonce = Base64.getEncoder().encodeToString(randomSupplier.apply(12));
+ cycle.setMetaData(NONCE_KEY, nonce);
+ }
+ return nonce;
+ }
+
+ // @returns "key1 value1a value1b; key2 value2a; key3 value3a value3b value3c"
+ private String getCSPHeaderValue(Map<CSPDirective, List<CSPRenderable>> directiveValuesMap,
+ RequestCycle cycle)
+ {
+ return directiveValuesMap.entrySet()
+ .stream()
+ .map(e -> e.getKey().getValue() + " "
+ + e.getValue()
+ .stream()
+ .map(r -> r.render(this, cycle))
+ .collect(Collectors.joining(" ")))
+ .collect(Collectors.joining("; "));
+ }
+}
diff --git a/wicket-core/src/main/java/org/apache/wicket/csp/CspNonceHeaderResponseDecorator.java b/wicket-core/src/main/java/org/apache/wicket/csp/CspNonceHeaderResponseDecorator.java
new file mode 100644
index 0000000..5aabcda
--- /dev/null
+++ b/wicket-core/src/main/java/org/apache/wicket/csp/CspNonceHeaderResponseDecorator.java
@@ -0,0 +1,56 @@
+/*
+ * 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 http://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.apache.wicket.csp;
+
+import org.apache.wicket.Application;
+import org.apache.wicket.markup.head.AbstractCspHeaderItem;
+import org.apache.wicket.markup.head.HeaderItem;
+import org.apache.wicket.markup.head.IHeaderResponse;
+import org.apache.wicket.markup.head.IWrappedHeaderItem;
+import org.apache.wicket.markup.head.ResourceAggregator;
+import org.apache.wicket.markup.html.DecoratingHeaderResponse;
+import org.apache.wicket.request.cycle.RequestCycle;
+
+/**
+ * Add a <em>Content Security Policy<em> (CSP) nonce to all {@link AbstractCspHeaderItem}s.
+ * <p>
+ * Note: please don't forget to wrap with {@link ResourceAggregator} when setting it up with
+ * {@link Application#setHeaderResponseDecorator}, otherwise dependencies will not be rendered.
+ *
+ * @see AbstractCspHeaderItem
+ */
+public class CspNonceHeaderResponseDecorator extends DecoratingHeaderResponse
+{
+ private CSPSettingRequestCycleListener listener;
+
+ public CspNonceHeaderResponseDecorator(IHeaderResponse real, CSPSettingRequestCycleListener listener)
+ {
+ super(real);
+
+ this.listener = listener;
+ }
+
+ @Override
+ public void render(HeaderItem item)
+ {
+ while (item instanceof IWrappedHeaderItem)
+ {
+ item = ((IWrappedHeaderItem) item).getWrapped();
+ }
+
+ if (item instanceof AbstractCspHeaderItem)
+ {
+ ((AbstractCspHeaderItem) item).setNonce(listener.getNonce(RequestCycle.get()));
+ }
+
+ super.render(item);
+ }
+}
diff --git a/wicket-core/src/test/java/org/apache/wicket/csp/CSPSettingRequestCycleListenerTest.java b/wicket-core/src/test/java/org/apache/wicket/csp/CSPSettingRequestCycleListenerTest.java
new file mode 100644
index 0000000..8e00759
--- /dev/null
+++ b/wicket-core/src/test/java/org/apache/wicket/csp/CSPSettingRequestCycleListenerTest.java
@@ -0,0 +1,448 @@
+package org.apache.wicket.csp;
+
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirective.CHILD_SRC;
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirective.DEFAULT_SRC;
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirective.FRAME_SRC;
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirective.REPORT_URI;
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirective.SANDBOX;
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirectiveSandboxValue.ALLOW_FORMS;
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirectiveSandboxValue.EMPTY;
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirectiveSrcValue.NONE;
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirectiveSrcValue.SELF;
+import static org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirectiveSrcValue.WILDCARD;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirective;
+import org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirectiveSandboxValue;
+import org.apache.wicket.csp.CSPSettingRequestCycleListener.CSPDirectiveSrcValue;
+import org.apache.wicket.mock.MockHomePage;
+import org.apache.wicket.util.tester.WicketTester;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("deprecation")
+public class CSPSettingRequestCycleListenerTest
+{
+ private static String HEADER_CSP = "Content-Security-Policy";
+
+ private static String HEADER_CSP_REPORT = "Content-Security-Policy-Report-Only";
+
+ private WicketTester wicketTester;
+
+ @BeforeEach
+ public void setUp()
+ {
+ wicketTester = new WicketTester(MockHomePage.class);
+ }
+
+ @Test
+ public void testNullSrcInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(DEFAULT_SRC, (String) null);
+ });
+ }
+
+ @Test
+ public void testEmptySrcInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(DEFAULT_SRC, "");
+ });
+ }
+
+ /**
+ * A value for any of the -src directives can be a number of predefined values (for most of them
+ * you can use {@link CSPDirectiveSrcValue}) or a correct URI.
+ */
+ @Test
+ public void testInvalidSrcInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(DEFAULT_SRC, "abc?^()-_\'xyz");
+ });
+ }
+
+ /**
+ * If {@code 'none'} is used for any of the -src directives, it must be the only value for that
+ * directive.
+ */
+ @Test
+ public void testMultipleSrcInputWithNoneIsRejected1()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(DEFAULT_SRC, SELF, NONE);
+ });
+ }
+
+ /**
+ * If {@code 'none'} is used for any of the -src directives, it must be the only value for that
+ * directive.
+ */
+ @Test
+ public void testMultipleSrcInputWithNoneIsRejected2()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(DEFAULT_SRC, NONE, SELF);
+ });
+ }
+
+ /**
+ * If {@code *} (asterisk) is used for any of the -src directives, it must be the only value for
+ * that directive.
+ */
+ @Test
+ public void testMultipleSrcInputWithStarIsRejected1()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ cspListener.addBlockingDirective(DEFAULT_SRC, SELF);
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(DEFAULT_SRC, WILDCARD);
+ });
+ }
+
+ /**
+ * If {@code *} (asterisk) is used for any of the -src directives, it must be the only value for
+ * that directive.
+ */
+ @Test
+ public void testMultipleSrcInputWithStarIsRejected2()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ cspListener.addBlockingDirective(DEFAULT_SRC, WILDCARD);
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(DEFAULT_SRC, SELF);
+ });
+ }
+
+ @Test
+ public void testWrongSrcInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(DEFAULT_SRC, ALLOW_FORMS);
+ });
+ }
+
+ @Test
+ public void testWrongSandboxInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(SANDBOX, SELF);
+ });
+ }
+
+ @Test
+ public void testNullSandboxInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(SANDBOX, (String) null);
+ });
+ }
+
+ @Test
+ public void testEmptySandboxInputIsAccepted()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ cspListener.addBlockingDirective(SANDBOX, CSPDirectiveSandboxValue.EMPTY);
+ }
+
+ @Test
+ public void testInvalidSandboxInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(SANDBOX, "abcxyz");
+ });
+ }
+
+ @Test
+ public void testMultipleSandboxInputWithEmptyStringIsRejected1()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ cspListener.addBlockingDirective(SANDBOX, ALLOW_FORMS);
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(SANDBOX, EMPTY);
+ });
+ }
+
+ @Test
+ public void testMultipleSandboxInputWithEmptyStringIsRejected2()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ cspListener.addBlockingDirective(SANDBOX, EMPTY);
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(SANDBOX, ALLOW_FORMS);
+ });
+ }
+
+ @Test
+ public void testNullReportUriInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(REPORT_URI, (String) null);
+ });
+ }
+
+ @Test
+ public void testEmptyReportUriInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(REPORT_URI, "");
+ });
+ }
+
+ @Test
+ public void testInvalidReportUriInputIsRejected()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ Assertions.assertThrows(IllegalArgumentException.class, () -> {
+ cspListener.addBlockingDirective(REPORT_URI, "abc?^()-_\'xyz");
+ });
+ }
+
+ @Test
+ public void testAllCSPSrcDefaultEnumsAreSetCorrectly() throws NoSuchAlgorithmException
+ {
+ SecureRandom random = SecureRandom.getInstanceStrong();
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener(length -> {
+ byte[] ret = new byte[length];
+ random.nextBytes(ret);
+ return ret;
+ });
+
+ final int cspDirectiveCount = CSPDirective.values().length;
+ final int cspDirectiveSrcValueCount = CSPDirectiveSrcValue.values().length;
+ for (int i = 0; i < Math.max(cspDirectiveCount, cspDirectiveSrcValueCount); i++)
+ {
+ final CSPDirective cspDirective = CSPDirective.values()[i % cspDirectiveCount];
+ // FRAME-SRC wordt al gezet door de aanroep voor CHILD-SRC
+ if (!FRAME_SRC.equals(cspDirective) && cspDirective.getValue().endsWith("-src"))
+ {
+ final CSPDirectiveSrcValue cspDirectiveValue =
+ CSPDirectiveSrcValue.values()[i % cspDirectiveSrcValueCount];
+ cspListener.addBlockingDirective(cspDirective, cspDirectiveValue);
+
+ cspListener.addReportingDirective(cspDirective, cspDirectiveValue);
+ }
+ }
+
+ StringBuffer headerErrors = checkHeaders(cspListener);
+
+ if (headerErrors.length() > 0)
+ {
+ Assertions.fail(headerErrors.toString());
+ }
+ }
+
+ @Test
+ public void testCSPReportUriDirectiveSetCorrectly()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ cspListener.addBlockingDirective(REPORT_URI, "http://report.example.com");
+ cspListener.addReportingDirective(REPORT_URI, "/example-report-uri");
+
+ StringBuffer headerErrors = checkHeaders(cspListener);
+
+ if (headerErrors.length() > 0)
+ {
+ Assertions.fail(headerErrors.toString());
+ }
+ }
+
+ @Test
+ public void testCSPSandboxDirectiveSetCorrectly()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ final int cspSandboxDirectiveValueCount = CSPDirectiveSandboxValue.values().length;
+ for (int i = 0; i < cspSandboxDirectiveValueCount; i++)
+ {
+ final CSPDirectiveSandboxValue cspDirectiveValue = CSPDirectiveSandboxValue.values()[i];
+ if (cspDirectiveValue.equals(CSPDirectiveSandboxValue.EMPTY))
+ continue;
+
+ cspListener.addBlockingDirective(SANDBOX, cspDirectiveValue);
+ cspListener.addReportingDirective(SANDBOX, cspDirectiveValue);
+ }
+
+ StringBuffer headerErrors = checkHeaders(cspListener);
+
+ if (headerErrors.length() > 0)
+ {
+ Assertions.fail(headerErrors.toString());
+ }
+ }
+
+ // FF 36+, IE (incl. Edge), Safari en Opera Mini hebben nog geen (volledige)
+ // support voor CSP, wat betekent dat ze CHILD-SRC niet kennen en FRAME-SRC
+ // verwachten. Daarom in de CSPSettingRCL een hack om alle CHILD-SRC's die geset
+ // worden ook als FRAME-SRC te setten.
+ // Zie http://caniuse.com/#feat=contentsecuritypolicy2
+ @Test
+ public void testChildSrcDirectiveAlsoSetsFrameSrcDirective()
+ {
+ CSPSettingRequestCycleListener cspListener = new CSPSettingRequestCycleListener();
+ cspListener.addBlockingDirective(CHILD_SRC, SELF);
+ cspListener.addReportingDirective(CHILD_SRC, SELF);
+ StringBuffer headerErrors = checkHeaders(cspListener);
+
+ if (headerErrors.length() > 0)
+ {
+ Assertions.fail(headerErrors.toString());
+ }
+ }
+
+ private StringBuffer checkHeaders(CSPSettingRequestCycleListener cspListener)
+ {
+ StringBuffer headerErrors = new StringBuffer();
+ wicketTester.getRequestCycle().getListeners().add(cspListener);
+ wicketTester.executeUrl("/");
+ String cspHeaderValue = wicketTester.getLastResponse().getHeader(HEADER_CSP);
+ String cspReportingHeaderValue =
+ wicketTester.getLastResponse().getHeader(HEADER_CSP_REPORT);
+
+ if (cspHeaderValue == null)
+ {
+ headerErrors.append(
+ String.format("Header %s expected but either not present or empty", HEADER_CSP));
+ }
+ if (cspReportingHeaderValue == null)
+ {
+ headerErrors.append(String.format("Header %s expected but either not present or empty",
+ HEADER_CSP_REPORT));
+ }
+
+ if (headerErrors.length() > 0)
+ {
+ return headerErrors;
+ }
+
+ StringBuffer headerValueErrors = new StringBuffer();
+ List<String> blockingHeaderValueErrors = checkCSPHeaderValues(cspHeaderValue);
+ List<String> reportingHeaderValueErrors = checkCSPHeaderValues(cspReportingHeaderValue);
+
+ if (!blockingHeaderValueErrors.isEmpty())
+ {
+ headerValueErrors.append("Blocking-mode CSP header value issues: ");
+ headerValueErrors
+ .append(blockingHeaderValueErrors.stream().collect(Collectors.joining("; ")));
+ headerValueErrors.append(". ");
+ }
+ if (!reportingHeaderValueErrors.isEmpty())
+ {
+ headerValueErrors.append("Reporting-mode CSP header value issues: ");
+ headerValueErrors
+ .append(reportingHeaderValueErrors.stream().collect(Collectors.joining("; ")));
+ headerValueErrors.append(". ");
+ }
+ return headerValueErrors;
+ }
+
+ private List<String> checkCSPHeaderValues(String cspHeaderValue)
+ {
+ Set<String> directiveValues = Stream.of(CSPDirective.values())
+ .map(CSPDirective::getValue)
+ .collect(Collectors.toSet());
+ Set<String> directiveSrcValues = Stream.of(CSPDirectiveSrcValue.values())
+ .map(CSPDirectiveSrcValue::getValue)
+ .collect(Collectors.toSet());
+ Set<String> directiveSandboxValues = Stream.of(CSPDirectiveSandboxValue.values())
+ .map(CSPDirectiveSandboxValue::getValue)
+ .collect(Collectors.toSet());
+
+ final List<String> errors = new ArrayList<>();
+ String[] directives = cspHeaderValue.split(";");
+ boolean hasChildSrc = false, hasFrameSrc = false;
+ for (String directive : directives)
+ {
+ directive = directive.trim();
+ String[] values = directive.split("\\s");
+ String directiveName = values[0];
+ if (!directiveValues.contains(directiveName))
+ {
+ errors.add(
+ String.format("Directive %s is not a valid directive name", directiveName));
+ }
+ else
+ {
+ if (CSPDirective.fromValue(directiveName).equals(FRAME_SRC))
+ {
+ hasFrameSrc = true;
+ }
+ if (CSPDirective.fromValue(directiveName).equals(CHILD_SRC))
+ {
+ hasChildSrc = true;
+ }
+ for (int i = 1; i < values.length; i++)
+ {
+ final String trimmedValue = values[i].trim();
+ final boolean isValidDefaultSrcValue =
+ directiveSrcValues.contains(trimmedValue);
+ final boolean isValidDefaultSandboxValue =
+ directiveSandboxValues.contains(trimmedValue);
+ if (!(isValidDefaultSrcValue || isValidDefaultSandboxValue
+ || isValidDirectiveValue(trimmedValue)))
+ {
+ errors.add(
+ String.format("Value %s is not a valid directive value", trimmedValue));
+ }
+ }
+ }
+ }
+
+ if (hasFrameSrc != hasChildSrc)
+ {
+ String presentDirective = hasFrameSrc ? FRAME_SRC.getValue() : CHILD_SRC.getValue();
+ String notPresentDirective = !hasFrameSrc ? FRAME_SRC.getValue() : CHILD_SRC.getValue();
+ errors.add(String.format("Directive %s present without directive %s for fallback",
+ presentDirective, notPresentDirective));
+ }
+
+ return errors;
+ }
+
+ // @see: http://content-security-policy.com/#source_list
+ private boolean isValidDirectiveValue(String directiveValue)
+ {
+ if ("*".equals(directiveValue))
+ return true;
+ else if ("data:".equals(directiveValue) || "https:".equals(directiveValue))
+ return true;
+
+ // strip off "*." for "*.example.com" so we can check "example.com" to be a valid
+ // URI.
+ if (directiveValue.startsWith("*."))
+ directiveValue = directiveValue.substring(2);
+ try
+ {
+ new URI(directiveValue);
+ return true;
+ }
+ catch (URISyntaxException ignored)
+ {
+ // fall through
+ }
+
+ return false;
+ }
+
+}