diff --git a/AGENTS.md b/AGENTS.md index f1ec225..8b2c3c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,11 @@ The implementation targets [W3C CSP Level 3](https://www.w3.org/TR/CSP3/). Key s ``` src/main/java/org/htmlunit/csp/ ├── Policy.java # Central class: parsing and high-level querying -├── PolicyList.java # List of policies (from comma-separated CSP headers) +├── PolicyList.java # List of policies (AND-query via CspQueries) +├── CspQueries.java # Shared enforcement query API (Policy + PolicyList) +├── CspQueriesInOrigin.java # Origin-bound sugar over CspQueries ├── PolicyInOrigin.java # Policy bound to a specific origin for querying +├── PolicyListInOrigin.java # PolicyList + origin (extends CspQueriesInOrigin) ├── Directive.java # Base class for all directive types ├── FetchDirectiveKind.java # Enum of fetch directives with fallback chains ├── Constants.java # Regex patterns, port constants diff --git a/README.md b/README.md index 87df8f8..1df5700 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,47 @@ Policy policy = Policy.parseSerializedCSP(policyText, (severity, message, direct }); ``` -For a policy from a `` element, pass `true` as the third argument so that `frame-ancestors`, `sandbox`, and `report-uri` produce warnings (those directives are ignored when delivered via meta): +For a policy from a [``](https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-content-security-policy) element, pass `true` as the third argument so that `frame-ancestors`, `sandbox`, and `report-uri` produce warnings (HTML requires that those directives must not appear in meta `content`, and browsers ignore them): ```java Policy metaPolicy = Policy.parseSerializedCSP(metaContent, consumer, true); ``` +Those three directives remain available via getters for analysis, but `allows*` methods ignore `frame-ancestors` and `sandbox` on meta-delivered policies (matching browser enforce after HTML strips them). See also [CSP3 meta element](https://w3c.github.io/webappsec-csp/#meta-element). + +### Multiple Policies + +Browsers do not conceptually merge multiple policies into a single policy. They independently evaluate each policy and allow an action only if every policy allows it ([CSP3 multiple policies](https://w3c.github.io/webappsec-csp/#multiple-policies)). Accordingly, this library preserves policies separately and evaluates queries across the collection (`PolicyList`, stopping at the first denying policy). An empty `PolicyList` is unrestricted: all `allows*` methods return `true`. + +`Policy` and `PolicyList` share the enforcement query surface via `CspQueries`. Origin-bound helpers (`PolicyInOrigin` / `PolicyListInOrigin`) extend `CspQueriesInOrigin`, so new query methods belong on the interface (and one origin wrapper), not four copy-pasted classes. + +```java +// Several Content-Security-Policy header values (each may itself be a comma-separated list) +PolicyList list = PolicyList.ofSerialized(List.of( + "img-src img.example.com", + "img-src more-images.example.com" +), Policy.PolicyListErrorConsumer.ignored); + +PolicyListInOrigin bound = new PolicyListInOrigin(list, URI.parseURI("https://example.com").orElseThrow()); + +boolean allowed = bound.allowsImageFromSource(URI.parseURI("https://img.example.com/a.png").orElseThrow()); +// allowed == false — second policy blocks even though first allows + +// Or a single comma-separated header value: +PolicyList fromOneHeader = Policy.parseSerializedCSPList( + "img-src 'self', script-src 'none'", Policy.PolicyListErrorConsumer.ignored); +``` + +Keep Report-Only policies in a separate `PolicyList`. Directive inspection and parse warnings stay per member policy via `getPolicies()`. + +`ofSerialized` always parses as header delivery (`deliveredViaMeta = false`). For mixed header + meta, parse each `Policy` with the correct flag, then build the list: + +```java +Policy headerPolicy = Policy.parseSerializedCSP(headerValue, consumer, false); +Policy metaPolicy = Policy.parseSerializedCSP(metaContent, consumer, true); +PolicyList enforce = new PolicyList(List.of(headerPolicy, metaPolicy)); +``` + ### Query a Policy The high-level querying methods allow you to specify whatever relevant information you have. The missing information will be assumed to be worst-case - that is, these methods will return `true` only if any object which matches the provided characteristics would be allowed, regardless of its other characteristics. diff --git a/src/main/java/org/htmlunit/csp/CspQueries.java b/src/main/java/org/htmlunit/csp/CspQueries.java new file mode 100644 index 0000000..7fbcbfd --- /dev/null +++ b/src/main/java/org/htmlunit/csp/CspQueries.java @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2023-2026 Ronald Brill. + * + * Licensed 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.htmlunit.csp; + +import java.util.Optional; + +import org.htmlunit.csp.url.URLWithScheme; +import org.htmlunit.csp.value.MediaType; + +/** + * Enforcement query API shared by {@link Policy} and {@link PolicyList}. + *

+ * For a single {@link Policy}, each method applies that policy's rules. + * For a {@link PolicyList}, each method returns {@code true} only if every + * member policy allows the resource (an empty list is unrestricted). + * Semantics of individual checks are documented on {@link Policy}. + *

+ * + * @author Ronald Brill + * @see Policy + * @see PolicyList + * @see CspQueriesInOrigin + * @since 5.4.0 + */ +public interface CspQueries { + + /** + * Returns whether an external script is allowed. + * + * @param nonce script nonce, if any + * @param integrity SRI metadata, if any + * @param scriptUrl script URL, if known + * @param parserInserted whether the script is parser-inserted + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsExternalScript + */ + boolean allowsExternalScript( + Optional nonce, + Optional integrity, + Optional scriptUrl, + Optional parserInserted, + Optional origin); + + /** + * Returns whether an inline script is allowed. + * + * @param nonce script nonce, if any + * @param source inline script text, if known + * @param parserInserted whether the script is parser-inserted + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsInlineScript + */ + boolean allowsInlineScript(Optional nonce, + Optional source, Optional parserInserted); + + /** + * Returns whether a script event-handler attribute is allowed. + * + * @param source attribute text, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsScriptAsAttribute + */ + boolean allowsScriptAsAttribute(Optional source); + + /** + * Returns whether eval is allowed. + * + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsEval + */ + boolean allowsEval(); + + /** + * Returns whether navigation is allowed. + * + * @param to navigation target URL, if known + * @param redirected whether the navigation is a redirect + * @param redirectedTo final URL after redirect, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsNavigation + */ + boolean allowsNavigation( + Optional to, + Optional redirected, + Optional redirectedTo, + Optional origin); + + /** + * Returns whether a form action is allowed. + * + * @param to form action URL, if known + * @param redirected whether the submission redirects + * @param redirectedTo final URL after redirect, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsFormAction + */ + boolean allowsFormAction( + Optional to, + Optional redirected, + Optional redirectedTo, + Optional origin); + + /** + * Returns whether a {@code javascript:} URL navigation is allowed. + * + * @param source JavaScript after the {@code javascript:} prefix, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsJavascriptUrlNavigation + */ + boolean allowsJavascriptUrlNavigation( + Optional source, + Optional origin); + + /** + * Returns whether an external stylesheet is allowed. + * + * @param nonce style nonce, if any + * @param styleUrl stylesheet URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsExternalStyle + */ + boolean allowsExternalStyle( + Optional nonce, + Optional styleUrl, + Optional origin); + + /** + * Returns whether an inline style is allowed. + * + * @param nonce style nonce, if any + * @param source inline style text, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsInlineStyle + */ + boolean allowsInlineStyle(Optional nonce, Optional source); + + /** + * Returns whether a style attribute is allowed. + * + * @param source attribute text, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsStyleAsAttribute + */ + boolean allowsStyleAsAttribute(Optional source); + + /** + * Returns whether a frame is allowed. + * + * @param source framed resource URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsFrame + */ + boolean allowsFrame(Optional source, + Optional origin); + + /** + * Returns whether a frame ancestor is allowed. + * + * @param source ancestor frame URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsFrameAncestor + */ + boolean allowsFrameAncestor(Optional source, + Optional origin); + + /** + * Returns whether a connection is allowed. + * + * @param source connection URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsConnection + */ + boolean allowsConnection(Optional source, + Optional origin); + + /** + * Returns whether a font is allowed. + * + * @param source font URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsFont + */ + boolean allowsFont(Optional source, + Optional origin); + + /** + * Returns whether an image is allowed. + * + * @param source image URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsImage + */ + boolean allowsImage(Optional source, + Optional origin); + + /** + * Returns whether an application manifest is allowed. + * + * @param source manifest URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsApplicationManifest + */ + boolean allowsApplicationManifest(Optional source, + Optional origin); + + /** + * Returns whether media is allowed. + * + * @param source media URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsMedia + */ + boolean allowsMedia(Optional source, + Optional origin); + + /** + * Returns whether an object is allowed. + * + * @param source object URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsObject + */ + boolean allowsObject(Optional source, + Optional origin); + + /** + * Returns whether a prefetch is allowed. + * + * @param source prefetch URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsPrefetch + */ + boolean allowsPrefetch(Optional source, + Optional origin); + + /** + * Returns whether a worker is allowed. + * + * @param source worker URL, if known + * @param origin protected-resource origin, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsWorker + */ + boolean allowsWorker(Optional source, + Optional origin); + + /** + * Returns whether a plugin type is allowed. + * + * @param mediaType plugin media type, if known + * @return {@code true} if allowed under this query target's rules + * @see Policy#allowsPlugin + */ + boolean allowsPlugin(Optional mediaType); +} diff --git a/src/main/java/org/htmlunit/csp/CspQueriesInOrigin.java b/src/main/java/org/htmlunit/csp/CspQueriesInOrigin.java new file mode 100644 index 0000000..ba81a33 --- /dev/null +++ b/src/main/java/org/htmlunit/csp/CspQueriesInOrigin.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2023-2026 Ronald Brill. + * + * Licensed 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.htmlunit.csp; + +import java.util.Objects; +import java.util.Optional; + +import org.htmlunit.csp.url.URLWithScheme; + +/** + * Convenience wrapper that pairs a {@link CspQueries} target with an + * {@link URLWithScheme origin}, supplying simplified query methods that + * fill in the origin and leave unused parameters empty. + *

+ * Use {@link PolicyInOrigin} or {@link PolicyListInOrigin} when a typed + * accessor for the underlying {@link Policy} / {@link PolicyList} is needed. + *

+ * + * @author Ronald Brill + * @see CspQueries + * @see PolicyInOrigin + * @see PolicyListInOrigin + * @since 5.4.0 + */ +public class CspQueriesInOrigin { + private final CspQueries queries_; + private final URLWithScheme origin_; + + /** + * Ctor. + * + * @param queries the query target ({@link Policy} or {@link PolicyList}) + * @param origin the origin of the protected resource + * @throws NullPointerException if {@code queries} or {@code origin} is {@code null} + */ + public CspQueriesInOrigin(final CspQueries queries, final URLWithScheme origin) { + queries_ = Objects.requireNonNull(queries, "queries"); + origin_ = Objects.requireNonNull(origin, "origin"); + } + + /** + * Returns the underlying query target. + * + * @return the {@link CspQueries} associated with this wrapper + */ + public CspQueries getQueries() { + return queries_; + } + + /** + * Returns the underlying origin. + * + * @return the origin of the protected resource + */ + public URLWithScheme getOrigin() { + return origin_; + } + + /** + * Returns whether an external script from the given URL is allowed. + * + * @param url the URL of the external script + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsExternalScript + */ + public boolean allowsScriptFromSource(final URLWithScheme url) { + return queries_.allowsExternalScript(Optional.empty(), + Optional.empty(), Optional.of(url), Optional.empty(), Optional.of(origin_)); + } + + /** + * Returns whether an external stylesheet from the given URL is allowed. + * + * @param url the URL of the external stylesheet + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsExternalStyle + */ + public boolean allowsStyleFromSource(final URLWithScheme url) { + return queries_.allowsExternalStyle(Optional.empty(), Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether an image from the given URL is allowed. + * + * @param url the URL of the image resource + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsImage + */ + public boolean allowsImageFromSource(final URLWithScheme url) { + return queries_.allowsImage(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether a frame from the given URL is allowed. + * + * @param url the URL of the framed resource + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsFrame + */ + public boolean allowsFrameFromSource(final URLWithScheme url) { + return queries_.allowsFrame(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether a worker from the given URL is allowed. + * + * @param url the URL of the worker script + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsWorker + */ + public boolean allowsWorkerFromSource(final URLWithScheme url) { + return queries_.allowsWorker(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether a font from the given URL is allowed. + * + * @param url the URL of the font resource + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsFont + */ + public boolean allowsFontFromSource(final URLWithScheme url) { + return queries_.allowsFont(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether an object from the given URL is allowed. + * + * @param url the URL of the object resource + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsObject + */ + public boolean allowsObjectFromSource(final URLWithScheme url) { + return queries_.allowsObject(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether media from the given URL is allowed. + * + * @param url the URL of the media resource + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsMedia + */ + public boolean allowsMediaFromSource(final URLWithScheme url) { + return queries_.allowsMedia(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether an application manifest from the given URL is allowed. + * + * @param url the URL of the manifest resource + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsApplicationManifest + */ + public boolean allowsManifestFromSource(final URLWithScheme url) { + return queries_.allowsApplicationManifest(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether a prefetch from the given URL is allowed. + * + * @param url the URL to prefetch + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsPrefetch + */ + public boolean allowsPrefetchFromSource(final URLWithScheme url) { + return queries_.allowsPrefetch(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether unsafe inline script is allowed. + * + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsInlineScript + */ + public boolean allowsUnsafeInlineScript() { + return queries_.allowsInlineScript(Optional.empty(), Optional.empty(), Optional.empty()); + } + + /** + * Returns whether unsafe inline style is allowed. + * + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsInlineStyle + */ + public boolean allowsUnsafeInlineStyle() { + return queries_.allowsInlineStyle(Optional.empty(), Optional.empty()); + } + + /** + * Returns whether eval is allowed. + * + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsEval + */ + public boolean allowsEval() { + return queries_.allowsEval(); + } + + /** + * Returns whether a connection to the given URL is allowed. + * + * @param url the URL to connect to + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsConnection + */ + public boolean allowsConnection(final URLWithScheme url) { + return queries_.allowsConnection(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether navigation to the given URL is allowed. + * + * @param url the navigation target URL + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsNavigation + */ + public boolean allowsNavigation(final URLWithScheme url) { + return queries_.allowsNavigation(Optional.of(url), + Optional.empty(), Optional.empty(), Optional.of(origin_)); + } + + /** + * Returns whether the given frame ancestor is allowed. + * + * @param url the URL of the ancestor frame + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsFrameAncestor + */ + public boolean allowsFrameAncestor(final URLWithScheme url) { + return queries_.allowsFrameAncestor(Optional.of(url), Optional.of(origin_)); + } + + /** + * Returns whether a form action to the given URL is allowed. + * + * @param url the form action target URL + * @return {@code true} if allowed under this query target's rules + * @see CspQueries#allowsFormAction + */ + public boolean allowsFormAction(final URLWithScheme url) { + return queries_.allowsFormAction(Optional.of(url), + Optional.empty(), Optional.empty(), Optional.of(origin_)); + } +} diff --git a/src/main/java/org/htmlunit/csp/Policy.java b/src/main/java/org/htmlunit/csp/Policy.java index 389c4a3..31abc73 100644 --- a/src/main/java/org/htmlunit/csp/Policy.java +++ b/src/main/java/org/htmlunit/csp/Policy.java @@ -63,9 +63,10 @@ * CSP specification. *

* + * @see CspQueries * @see W3C Content Security Policy Level 3 */ -public final class Policy { +public final class Policy implements CspQueries { // Things we don't preserve: // - Whitespace // - Empty directives or policies (as in `; ;` or `, ,`) @@ -544,6 +545,12 @@ public boolean blockAllMixedContent() { /** * Returns whether this policy was parsed as delivered via a {@code meta} element. + *

+ * When {@code true}, {@code frame-ancestors}, {@code sandbox}, and {@code report-uri} + * still appear in getters for analysis, but {@code allows*} methods ignore + * {@code frame-ancestors} and {@code sandbox} (matching browser enforce after the + * HTML meta algorithm strips those directives). + *

* * @return {@code true} if {@link #parseSerializedCSP(String, PolicyErrorConsumer, boolean)} * was called with {@code deliveredViaMeta} set to {@code true} @@ -554,6 +561,24 @@ public boolean deliveredViaMeta() { return deliveredViaMeta_; } + /** + * Sandbox effective for {@code allows*} queries. + * Meta-delivered {@code sandbox} is ignored for enforce simulation + * (HTML strips it before apply) but remains available via {@link #sandbox()}. + */ + private SandboxDirective sandboxForQuery() { + return deliveredViaMeta_ ? null : sandbox_; + } + + /** + * Frame-ancestors effective for {@code allows*} queries. + * Meta-delivered {@code frame-ancestors} is ignored for enforce simulation + * but remains available via {@link #frameAncestors()}. + */ + private FrameAncestorsDirective frameAncestorsForQuery() { + return deliveredViaMeta_ ? null : frameAncestors_; + } + /** * Returns the parsed {@code form-action} directive, if present. * @@ -721,6 +746,7 @@ public Optional getFetchDirective(final FetchDirectiv * script-pre-request check * @see * script-post-request check + * @see #deliveredViaMeta() */ public boolean allowsExternalScript( final Optional nonce, @@ -728,7 +754,8 @@ public boolean allowsExternalScript( final Optional scriptUrl, final Optional parserInserted, final Optional origin) { - if (sandbox_ != null && !sandbox_.allowScripts()) { + final SandboxDirective sandbox = sandboxForQuery(); + if (sandbox != null && !sandbox.allowScripts()) { return false; } @@ -784,10 +811,12 @@ public boolean allowsExternalScript( * @return {@code true} if this policy allows the inline script * @see * should block inline check + * @see #deliveredViaMeta() */ public boolean allowsInlineScript(final Optional nonce, final Optional source, final Optional parserInserted) { - if (sandbox_ != null && !sandbox_.allowScripts()) { + final SandboxDirective sandbox = sandboxForQuery(); + if (sandbox != null && !sandbox.allowScripts()) { return false; } return doesElementMatchSourceListForTypeAndSource(InlineType.Script, nonce, source, parserInserted); @@ -802,9 +831,11 @@ public boolean allowsInlineScript(final Optional nonce, * @return {@code true} if this policy allows the script attribute * @see * should block inline check (script-src-attr) + * @see #deliveredViaMeta() */ public boolean allowsScriptAsAttribute(final Optional source) { - if (sandbox_ != null && !sandbox_.allowScripts()) { + final SandboxDirective sandbox = sandboxForQuery(); + if (sandbox != null && !sandbox.allowScripts()) { return false; } return doesElementMatchSourceListForTypeAndSource( @@ -911,13 +942,15 @@ public boolean allowsNavigation( * @return {@code true} if this policy allows the form action * @see * navigate-to pre-navigate check + * @see #deliveredViaMeta() */ public boolean allowsFormAction( final Optional to, final Optional redirected, final Optional redirectedTo, final Optional origin) { - if (sandbox_ != null && !sandbox_.allowForms()) { + final SandboxDirective sandbox = sandboxForQuery(); + if (sandbox != null && !sandbox.allowForms()) { return false; } if (formAction_ != null) { @@ -1043,7 +1076,9 @@ public boolean allowsFrame(final Optional source, /** * Determines whether this policy allows being framed by the given ancestor origin. *

- * Checks the {@code frame-ancestors} directive. If not present, all ancestors are allowed. + * Checks the {@code frame-ancestors} directive. If not present, or if this policy + * was delivered via meta (where {@code frame-ancestors} is ignored), all ancestors + * are allowed. *

* * @param source the URL of the ancestor frame, if known @@ -1051,14 +1086,16 @@ public boolean allowsFrame(final Optional source, * @return {@code true} if this policy allows the frame ancestor * @see * frame-ancestors directive + * @see #deliveredViaMeta() */ public boolean allowsFrameAncestor(final Optional source, final Optional origin) { - if (frameAncestors_ == null) { + final FrameAncestorsDirective frameAncestors = frameAncestorsForQuery(); + if (frameAncestors == null) { return true; } return source.filter(urlWithScheme -> - doesUrlMatchSourceListInOrigin(urlWithScheme, frameAncestors_, origin)).isPresent(); + doesUrlMatchSourceListInOrigin(urlWithScheme, frameAncestors, origin)).isPresent(); } /** diff --git a/src/main/java/org/htmlunit/csp/PolicyInOrigin.java b/src/main/java/org/htmlunit/csp/PolicyInOrigin.java index 2fe2d50..a21b141 100644 --- a/src/main/java/org/htmlunit/csp/PolicyInOrigin.java +++ b/src/main/java/org/htmlunit/csp/PolicyInOrigin.java @@ -14,8 +14,6 @@ */ package org.htmlunit.csp; -import java.util.Optional; - import org.htmlunit.csp.url.URLWithScheme; /** @@ -23,16 +21,17 @@ * {@link URLWithScheme origin}, providing simplified query methods that * automatically supply the origin to the underlying policy checks. *

- * Each {@code allows*} method delegates to the corresponding method on - * {@link Policy}, filling in {@code Optional.of(origin_)} for the origin - * parameter and {@code Optional.empty()} for any parameters that are not - * applicable to the simplified query (such as nonce, integrity, or - * redirect information). + * Each {@code allows*} method is defined on {@link CspQueriesInOrigin} and + * delegates to {@link Policy} ({@link CspQueries}), filling in + * {@code Optional.of(origin)} for the origin parameter and + * {@code Optional.empty()} for unused parameters (nonce, integrity, redirects). *

+ * + * @author Ronald Brill + * @see CspQueriesInOrigin + * @see PolicyListInOrigin */ -public class PolicyInOrigin { - private final Policy policy_; - private final URLWithScheme origin_; +public class PolicyInOrigin extends CspQueriesInOrigin { /** * Ctor. @@ -41,8 +40,7 @@ public class PolicyInOrigin { * @param origin the origin of the protected resource */ public PolicyInOrigin(final Policy policy, final URLWithScheme origin) { - policy_ = policy; - origin_ = origin; + super(policy, origin); } /** @@ -51,233 +49,6 @@ public PolicyInOrigin(final Policy policy, final URLWithScheme origin) { * @return the policy associated with this origin-bound wrapper */ public Policy getPolicy() { - return policy_; - } - - /** - * Returns the underlying origin. - * - * @return the policy associated with this origin-bound wrapper - */ - public URLWithScheme getOrigin() { - return origin_; - } - - // Low-level querying - - /** - * Determines whether the policy allows loading an external script from the given URL. - *

- * Delegates to {@link Policy#allowsExternalScript} with no nonce, no integrity, - * unknown parser-inserted status, and this wrapper's origin. - *

- * - * @param url the URL of the external script - * @return {@code true} if the policy allows the script from the given source - */ - public boolean allowsScriptFromSource(final URLWithScheme url) { - return policy_.allowsExternalScript(Optional.empty(), - Optional.empty(), Optional.of(url), Optional.empty(), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows loading an external stylesheet from the given URL. - *

- * Delegates to {@link Policy#allowsExternalStyle} with no nonce and this wrapper's origin. - *

- * - * @param url the URL of the external stylesheet - * @return {@code true} if the policy allows the style from the given source - */ - public boolean allowsStyleFromSource(final URLWithScheme url) { - return policy_.allowsExternalStyle(Optional.empty(), Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows loading an image from the given URL. - *

- * Delegates to {@link Policy#allowsImage} with this wrapper's origin. - *

- * - * @param url the URL of the image resource - * @return {@code true} if the policy allows the image from the given source - */ - public boolean allowsImageFromSource(final URLWithScheme url) { - return policy_.allowsImage(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows loading a frame from the given URL. - *

- * Delegates to {@link Policy#allowsFrame} with this wrapper's origin. - *

- * - * @param url the URL of the framed resource - * @return {@code true} if the policy allows the frame from the given source - */ - public boolean allowsFrameFromSource(final URLWithScheme url) { - return policy_.allowsFrame(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows loading a worker from the given URL. - *

- * Delegates to {@link Policy#allowsWorker} with this wrapper's origin. - *

- * - * @param url the URL of the worker script - * @return {@code true} if the policy allows the worker from the given source - */ - public boolean allowsWorkerFromSource(final URLWithScheme url) { - return policy_.allowsWorker(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows loading a font from the given URL. - *

- * Delegates to {@link Policy#allowsFont} with this wrapper's origin. - *

- * - * @param url the URL of the font resource - * @return {@code true} if the policy allows the font from the given source - */ - public boolean allowsFontFromSource(final URLWithScheme url) { - return policy_.allowsFont(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows loading an object/embed/applet from the given URL. - *

- * Delegates to {@link Policy#allowsObject} with this wrapper's origin. - *

- * - * @param url the URL of the object resource - * @return {@code true} if the policy allows the object from the given source - */ - public boolean allowsObjectFromSource(final URLWithScheme url) { - return policy_.allowsObject(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows loading media (audio/video) from the given URL. - *

- * Delegates to {@link Policy#allowsMedia} with this wrapper's origin. - *

- * - * @param url the URL of the media resource - * @return {@code true} if the policy allows the media from the given source - */ - public boolean allowsMediaFromSource(final URLWithScheme url) { - return policy_.allowsMedia(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows loading an application manifest from the given URL. - *

- * Delegates to {@link Policy#allowsApplicationManifest} with this wrapper's origin. - *

- * - * @param url the URL of the manifest resource - * @return {@code true} if the policy allows the manifest from the given source - */ - public boolean allowsManifestFromSource(final URLWithScheme url) { - return policy_.allowsApplicationManifest(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows a prefetch from the given URL. - *

- * Delegates to {@link Policy#allowsPrefetch} with this wrapper's origin. - *

- * - * @param url the URL to prefetch - * @return {@code true} if the policy allows the prefetch from the given source - */ - public boolean allowsPrefetchFromSource(final URLWithScheme url) { - return policy_.allowsPrefetch(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows any inline script without a nonce or hash. - *

- * Delegates to {@link Policy#allowsInlineScript} with no nonce, no source text, - * and unknown parser-inserted status. This effectively checks whether - * {@code 'unsafe-inline'} is active for scripts. - *

- * - * @return {@code true} if the policy allows inline scripts without specific credentials - */ - public boolean allowsUnsafeInlineScript() { - return policy_.allowsInlineScript(Optional.empty(), Optional.empty(), Optional.empty()); - } - - /** - * Determines whether the policy allows any inline style without a nonce or hash. - *

- * Delegates to {@link Policy#allowsInlineStyle} with no nonce and no source text. - * This effectively checks whether {@code 'unsafe-inline'} is active for styles. - *

- * - * @return {@code true} if the policy allows inline styles without specific credentials - */ - public boolean allowsUnsafeInlineStyle() { - return policy_.allowsInlineStyle(Optional.empty(), Optional.empty()); - } - - /** - * Determines whether the policy allows a connection (e.g. {@code fetch()}, - * {@code XMLHttpRequest}, {@code WebSocket}) to the given URL. - *

- * Delegates to {@link Policy#allowsConnection} with this wrapper's origin. - *

- * - * @param url the URL to connect to - * @return {@code true} if the policy allows the connection to the given source - */ - public boolean allowsConnection(final URLWithScheme url) { - return policy_.allowsConnection(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows navigation to the given URL. - *

- * Delegates to {@link Policy#allowsNavigation} with unknown redirect status, - * no redirect target, and this wrapper's origin. - *

- * - * @param url the navigation target URL - * @return {@code true} if the policy allows navigation to the given URL - */ - public boolean allowsNavigation(final URLWithScheme url) { - return policy_.allowsNavigation(Optional.of(url), - Optional.empty(), Optional.empty(), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows being framed by the given ancestor URL. - *

- * Delegates to {@link Policy#allowsFrameAncestor} with this wrapper's origin. - *

- * - * @param url the URL of the ancestor frame - * @return {@code true} if the policy allows the frame ancestor - */ - public boolean allowsFrameAncestor(final URLWithScheme url) { - return policy_.allowsFrameAncestor(Optional.of(url), Optional.of(origin_)); - } - - /** - * Determines whether the policy allows a form submission to the given URL. - *

- * Delegates to {@link Policy#allowsFormAction} with unknown redirect status, - * no redirect target, and this wrapper's origin. - *

- * - * @param url the form action target URL - * @return {@code true} if the policy allows the form action to the given URL - */ - public boolean allowsFormAction(final URLWithScheme url) { - return policy_.allowsFormAction(Optional.of(url), - Optional.empty(), Optional.empty(), Optional.of(origin_)); + return (Policy) getQueries(); } } diff --git a/src/main/java/org/htmlunit/csp/PolicyList.java b/src/main/java/org/htmlunit/csp/PolicyList.java index 2e441e5..8d2b65d 100644 --- a/src/main/java/org/htmlunit/csp/PolicyList.java +++ b/src/main/java/org/htmlunit/csp/PolicyList.java @@ -14,56 +14,101 @@ */ package org.htmlunit.csp; -import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; + +import org.htmlunit.csp.Policy.PolicyListErrorConsumer; +import org.htmlunit.csp.url.URLWithScheme; +import org.htmlunit.csp.value.MediaType; /** * Represents a list of Content Security Policies parsed from a comma-separated - * serialized CSP list. + * serialized CSP list, or combined from multiple header / meta deliveries. *

* A serialized CSP list (e.g. the value of a {@code Content-Security-Policy} * HTTP header) may contain multiple policies separated by commas. Each policy * is parsed independently, and a resource is blocked if any policy - * in the list blocks it. + * in the list blocks it — equivalently, {@code allows*} methods return + * {@code true} only when every member policy allows the resource. + * See + * CSP3 + * multiple policies. + *

+ *

+ * Implements {@link CspQueries}: each query method AND-delegates to member + * {@link Policy} instances (short-circuit on the first deny). An empty list + * is unrestricted (all {@code allows*} return {@code true}). *

*

* Instances are created by - * {@link Policy#parseSerializedCSPList(String, Policy.PolicyListErrorConsumer)}. + * {@link Policy#parseSerializedCSPList(String, PolicyListErrorConsumer)} + * or {@link #ofSerialized(List, PolicyListErrorConsumer)}. * Empty policies (those with no directives) are omitted during parsing. *

* - * @see Policy#parseSerializedCSPList(String, Policy.PolicyListErrorConsumer) + * @author Ronald Brill + * @see CspQueries + * @see Policy#parseSerializedCSPList(String, PolicyListErrorConsumer) */ -public class PolicyList { +public class PolicyList implements CspQueries { private final List policies_; /** * Ctor. * * @param policies the list of parsed {@link Policy} instances (empty policies excluded) + * @throws NullPointerException if {@code policies} is {@code null} or contains {@code null} */ public PolicyList(final List policies) { - policies_ = policies; + policies_ = List.copyOf(policies); } /** - * Returns a copy of the policies associated with this object. + * Parses and combines multiple serialized CSP strings into one policy list. *

- * The returned list is a defensive copy, so modifications to it do not - * affect the internal collection of policies. + * Each element may itself be a comma-separated CSP list (as with a single + * header field value). Values are joined with {@code ","} and parsed via + * {@link Policy#parseSerializedCSPList} (always as header delivery — + * {@code deliveredViaMeta = false}). For meta policies, parse with + * {@link Policy#parseSerializedCSP(String, Policy.PolicyErrorConsumer, boolean)} + * and pass the results to {@link PolicyList#PolicyList(List)}. Report-Only policies + * should be kept in a separate {@link PolicyList}. *

* - * @return a new {@code List} containing all policies + * @param serializedPoliciesOrLists header field values (not meta {@code content} + * strings that need {@code deliveredViaMeta = true}) + * @param policyListErrorConsumer consumer for parse errors/warnings + * @return the combined {@link PolicyList} + * @throws NullPointerException if {@code serializedPoliciesOrLists} or + * {@code policyListErrorConsumer} is {@code null}, or if any element + * of {@code serializedPoliciesOrLists} is {@code null} + * @since 5.4.0 + */ + public static PolicyList ofSerialized(final List serializedPoliciesOrLists, + final PolicyListErrorConsumer policyListErrorConsumer) { + Objects.requireNonNull(serializedPoliciesOrLists, "serializedPoliciesOrLists"); + Objects.requireNonNull(policyListErrorConsumer, "policyListErrorConsumer"); + return Policy.parseSerializedCSPList( + String.join(",", serializedPoliciesOrLists), policyListErrorConsumer); + } + + /** + * Returns the member policies (unmodifiable). + * + * @return an unmodifiable list of all policies */ public List getPolicies() { - return new ArrayList(policies_); + return policies_; } /** * Serializes this policy list back to its string representation. *

* Individual policies are separated by {@code ", "}. Each policy is - * serialized using {@link Policy#toString()}. + * serialized using {@link Policy#toString()}. This is not an + * intersected / merged policy. *

* * @return the comma-separated serialized CSP list string @@ -81,4 +126,154 @@ public String toString() { } return out.toString(); } + + /** + * Returns {@code true} only if every policy in the list satisfies {@code check}. + * Stops at the first {@code false} (short-circuit). An empty list returns {@code true}. + */ + private boolean allAllow(final Predicate check) { + for (final Policy policy : policies_) { + if (!check.test(policy)) { + return false; + } + } + return true; + } + + // High-level querying: AND across all member policies + + @Override + public boolean allowsExternalScript( + final Optional nonce, + final Optional integrity, + final Optional scriptUrl, + final Optional parserInserted, + final Optional origin) { + return allAllow(p -> + p.allowsExternalScript(nonce, integrity, scriptUrl, parserInserted, origin)); + } + + @Override + public boolean allowsInlineScript(final Optional nonce, + final Optional source, final Optional parserInserted) { + return allAllow(p -> p.allowsInlineScript(nonce, source, parserInserted)); + } + + @Override + public boolean allowsScriptAsAttribute(final Optional source) { + return allAllow(p -> p.allowsScriptAsAttribute(source)); + } + + @Override + public boolean allowsEval() { + return allAllow(Policy::allowsEval); + } + + @Override + public boolean allowsNavigation( + final Optional to, + final Optional redirected, + final Optional redirectedTo, + final Optional origin) { + return allAllow(p -> p.allowsNavigation(to, redirected, redirectedTo, origin)); + } + + @Override + public boolean allowsFormAction( + final Optional to, + final Optional redirected, + final Optional redirectedTo, + final Optional origin) { + return allAllow(p -> p.allowsFormAction(to, redirected, redirectedTo, origin)); + } + + @Override + public boolean allowsJavascriptUrlNavigation( + final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsJavascriptUrlNavigation(source, origin)); + } + + @Override + public boolean allowsExternalStyle( + final Optional nonce, + final Optional styleUrl, + final Optional origin) { + return allAllow(p -> p.allowsExternalStyle(nonce, styleUrl, origin)); + } + + @Override + public boolean allowsInlineStyle(final Optional nonce, final Optional source) { + return allAllow(p -> p.allowsInlineStyle(nonce, source)); + } + + @Override + public boolean allowsStyleAsAttribute(final Optional source) { + return allAllow(p -> p.allowsStyleAsAttribute(source)); + } + + @Override + public boolean allowsFrame(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsFrame(source, origin)); + } + + @Override + public boolean allowsFrameAncestor(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsFrameAncestor(source, origin)); + } + + @Override + public boolean allowsConnection(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsConnection(source, origin)); + } + + @Override + public boolean allowsFont(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsFont(source, origin)); + } + + @Override + public boolean allowsImage(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsImage(source, origin)); + } + + @Override + public boolean allowsApplicationManifest(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsApplicationManifest(source, origin)); + } + + @Override + public boolean allowsMedia(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsMedia(source, origin)); + } + + @Override + public boolean allowsObject(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsObject(source, origin)); + } + + @Override + public boolean allowsPrefetch(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsPrefetch(source, origin)); + } + + @Override + public boolean allowsWorker(final Optional source, + final Optional origin) { + return allAllow(p -> p.allowsWorker(source, origin)); + } + + @Override + public boolean allowsPlugin(final Optional mediaType) { + return allAllow(p -> p.allowsPlugin(mediaType)); + } } diff --git a/src/main/java/org/htmlunit/csp/PolicyListInOrigin.java b/src/main/java/org/htmlunit/csp/PolicyListInOrigin.java new file mode 100644 index 0000000..134da16 --- /dev/null +++ b/src/main/java/org/htmlunit/csp/PolicyListInOrigin.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023-2026 Ronald Brill. + * + * Licensed 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.htmlunit.csp; + +import org.htmlunit.csp.url.URLWithScheme; + +/** + * A convenience wrapper that pairs a {@link PolicyList} with its + * {@link URLWithScheme origin}. Query methods are defined on + * {@link CspQueriesInOrigin}; AND semantics come from {@link PolicyList}. + * + * @author Ronald Brill + * @see CspQueriesInOrigin + * @see PolicyInOrigin + * @see PolicyList + * @since 5.4.0 + */ +public class PolicyListInOrigin extends CspQueriesInOrigin { + + /** + * Ctor. + * + * @param policyList the Content Security Policy list to query against + * @param origin the origin of the protected resource + */ + public PolicyListInOrigin(final PolicyList policyList, final URLWithScheme origin) { + super(policyList, origin); + } + + /** + * Returns the underlying {@link PolicyList}. + * + * @return the policy list associated with this origin-bound wrapper + */ + public PolicyList getPolicyList() { + return (PolicyList) getQueries(); + } +} diff --git a/src/test/java/org/htmlunit/csp/PolicyListQueryingTest.java b/src/test/java/org/htmlunit/csp/PolicyListQueryingTest.java new file mode 100644 index 0000000..e1b498d --- /dev/null +++ b/src/test/java/org/htmlunit/csp/PolicyListQueryingTest.java @@ -0,0 +1,634 @@ +/* + * Copyright (c) 2023-2026 Ronald Brill. + * + * Licensed 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.htmlunit.csp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.htmlunit.csp.Policy.PolicyListErrorConsumer; +import org.htmlunit.csp.url.URI; +import org.htmlunit.csp.url.URLWithScheme; +import org.junit.jupiter.api.Test; + +/** + * Multi-policy query-time AND behavior on {@link PolicyList} / + * {@link PolicyListInOrigin}. + * + * @see CSP3 multiple policies + * @see Foundeo examples + */ +public class PolicyListQueryingTest extends TestBase { + + private static final URLWithScheme ORIGIN = URI.parseURI("https://example.com").orElseThrow(); + + @Test + public void ambiguousImgSrcHostsEffectivelyNone() { + // Content-Security-Policy: img-src img.example.com; + // Content-Security-Policy: img-src more-images.example.com; + final PolicyListInOrigin list = ofSerialized( + List.of("img-src img.example.com", "img-src more-images.example.com")); + + assertFalse(list.allowsImageFromSource(uri("https://img.example.com/a.png"))); + assertFalse(list.allowsImageFromSource(uri("https://more-images.example.com/b.png"))); + assertFalse(list.allowsImageFromSource(uri("https://other.example.com/c.png"))); + assertEquals(2, list.getPolicyList().getPolicies().size()); + } + + @Test + public void nestedRestrictivenessBlocksExtraHost() { + // img-src 'self' AND img-src 'self' img.example.com → only 'self' survives + final PolicyListInOrigin list = ofSerialized( + List.of("img-src 'self'", "img-src 'self' img.example.com")); + + assertTrue(list.allowsImageFromSource(uri("https://example.com/logo.png"))); + assertFalse(list.allowsImageFromSource(uri("https://img.example.com/a.png"))); + } + + @Test + public void defaultSrcFallbackAcrossPolicies() { + // default-src 'self' AND img-src 'self' img.example.com + final PolicyListInOrigin list = ofSerialized( + List.of("default-src 'self'", "img-src 'self' img.example.com")); + + assertTrue(list.allowsImageFromSource(uri("https://example.com/logo.png"))); + assertFalse(list.allowsImageFromSource(uri("https://img.example.com/a.png"))); + } + + /** + * Both policies omit the fetch directive, so each falls back to its own + * {@code default-src}. AND is the overlap of those default-src lists. + */ + @Test + public void defaultSrcOverlapWhenBothFallBack() { + // default-src a.com ∩ default-src a.com b.com → only a.com for images + final PolicyListInOrigin hostOverlap = ofSerialized(List.of( + "default-src https://a.example.com", + "default-src https://a.example.com https://b.example.com")); + + assertTrue(hostOverlap.allowsImageFromSource(uri("https://a.example.com/i.png"))); + assertFalse(hostOverlap.allowsImageFromSource(uri("https://b.example.com/i.png"))); + assertTrue(hostOverlap.allowsScriptFromSource(uri("https://a.example.com/app.js"))); + assertFalse(hostOverlap.allowsScriptFromSource(uri("https://b.example.com/app.js"))); + + // Asymmetric: one side only 'self', other allows a third-party via default-src + final PolicyListInOrigin asymmetric = ofSerialized(List.of( + "default-src 'self'", + "default-src 'self' https://cdn.example.com")); + + assertTrue(asymmetric.allowsFontFromSource(uri("https://example.com/f.woff2"))); + assertFalse(asymmetric.allowsFontFromSource(uri("https://cdn.example.com/f.woff2"))); + + // Both default-src 'none' → nothing allowed for fetch types that fall back + final PolicyListInOrigin bothNone = ofSerialized(List.of( + "default-src 'none'", + "default-src 'none'")); + + assertFalse(bothNone.allowsImageFromSource(uri("https://example.com/i.png"))); + assertFalse(bothNone.allowsScriptFromSource(uri("https://example.com/app.js"))); + assertFalse(bothNone.allowsConnection(uri("https://example.com/api"))); + } + + @Test + public void orderDoesNotMatter() { + final PolicyListInOrigin aThenB = ofSerialized( + List.of("img-src img.example.com", "img-src more-images.example.com")); + final PolicyListInOrigin bThenA = ofSerialized( + List.of("img-src more-images.example.com", "img-src img.example.com")); + + final URLWithScheme img = uri("https://img.example.com/a.png"); + final URLWithScheme more = uri("https://more-images.example.com/b.png"); + assertEquals(aThenB.allowsImageFromSource(img), bThenA.allowsImageFromSource(img)); + assertEquals(aThenB.allowsImageFromSource(more), bThenA.allowsImageFromSource(more)); + } + + @Test + public void overlappingHostsAllowed() { + final PolicyListInOrigin list = ofSerialized( + List.of("img-src https://cdn.example.com", "img-src https://cdn.example.com https://other.example.com")); + + assertTrue(list.allowsImageFromSource(uri("https://cdn.example.com/a.png"))); + assertFalse(list.allowsImageFromSource(uri("https://other.example.com/a.png"))); + } + + @Test + public void emptyListAllowsEverything() { + final PolicyList empty = new PolicyList(Collections.emptyList()); + assertTrue(empty.allowsImage(Optional.of(uri("https://anywhere.example/x")), Optional.of(ORIGIN))); + assertTrue(empty.allowsEval()); + assertTrue(new PolicyListInOrigin(empty, ORIGIN).allowsUnsafeInlineScript()); + assertThrows(UnsupportedOperationException.class, + () -> empty.getPolicies().add(Policy.parseSerializedCSP("default-src 'none'", + Policy.PolicyErrorConsumer.ignored))); + } + + @Test + public void originWrappersExposeTypedQueries() { + final PolicyList list = PolicyList.ofSerialized( + List.of("img-src 'self'"), PolicyListErrorConsumer.ignored); + final PolicyListInOrigin bound = new PolicyListInOrigin(list, ORIGIN); + assertTrue(bound.getQueries() instanceof PolicyList); + assertEquals(list, bound.getPolicyList()); + assertEquals(ORIGIN, bound.getOrigin()); + + final Policy policy = list.getPolicies().get(0); + final PolicyInOrigin single = new PolicyInOrigin(policy, ORIGIN); + assertTrue(single.getQueries() instanceof Policy); + assertEquals(policy, single.getPolicy()); + } + + @Test + public void ofSerializedParsesCommaSeparatedHeaderValues() { + // One header value that is itself a CSP list, plus another header + final PolicyList list = PolicyList.ofSerialized( + List.of("img-src a.example, script-src 'none'", "connect-src 'self'"), + PolicyListErrorConsumer.ignored); + + assertEquals(3, list.getPolicies().size()); + } + + @Test + public void unsafeInlineRequiresAllPolicies() { + final PolicyListInOrigin bothAllow = ofSerialized( + List.of("script-src 'unsafe-inline'", "default-src 'unsafe-inline'")); + assertTrue(bothAllow.allowsUnsafeInlineScript()); + + final PolicyListInOrigin oneBlocks = ofSerialized( + List.of("script-src 'unsafe-inline'", "script-src 'none'")); + assertFalse(oneBlocks.allowsUnsafeInlineScript()); + } + + @Test + public void allowsEvalRequiresAllPolicies() { + final PolicyList bothAllow = PolicyList.ofSerialized( + List.of("script-src 'unsafe-eval'", "default-src 'unsafe-eval'"), + PolicyListErrorConsumer.ignored); + assertTrue(bothAllow.allowsEval()); + + final PolicyList oneBlocks = PolicyList.ofSerialized( + List.of("script-src 'unsafe-eval'", "script-src 'none'"), + PolicyListErrorConsumer.ignored); + assertFalse(oneBlocks.allowsEval()); + } + + @Test + public void parseSerializedCSPListAlsoAnds() { + final PolicyList list = Policy.parseSerializedCSPList( + "img-src img.example.com, img-src more-images.example.com", + PolicyListErrorConsumer.ignored); + final PolicyListInOrigin bound = new PolicyListInOrigin(list, ORIGIN); + + assertFalse(bound.allowsImageFromSource(uri("https://img.example.com/a.png"))); + assertFalse(bound.allowsImageFromSource(uri("https://more-images.example.com/b.png"))); + } + + /** + * Typical SaaS baseline (self + CDNs + analytics) plus a second edge/WAF header that + * drops third-party scripts and connections. + */ + @Test + public void saasBaselineTightenedByEdgeHeader() { + final String appPolicy = String.join(" ", + "default-src 'self';", + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://www.googletagmanager.com;", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;", + "font-src 'self' https://fonts.gstatic.com data:;", + "img-src 'self' data: https: blob:;", + "connect-src 'self' https://api.example.com https://www.google-analytics.com;", + "frame-src 'self' https://js.stripe.com;", + "object-src 'none';", + "base-uri 'self';", + "form-action 'self';", + "frame-ancestors 'self';", + "upgrade-insecure-requests"); + + final String edgeTighten = String.join(" ", + "default-src 'self';", + "script-src 'self';", + "style-src 'self' 'unsafe-inline';", + "font-src 'self' data:;", + "img-src 'self' data: https:;", + "connect-src 'self' https://api.example.com;", + "frame-src 'self';", + "object-src 'none';", + "base-uri 'self';", + "form-action 'self';", + "frame-ancestors 'self'"); + + final PolicyListInOrigin list = ofSerializedIgnored(List.of(appPolicy, edgeTighten)); + + assertTrue(list.allowsScriptFromSource(uri("https://example.com/app.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://www.googletagmanager.com/gtm.js"))); + assertTrue(list.allowsConnection(uri("https://api.example.com/v1/users"))); + assertFalse(list.allowsConnection(uri("https://www.google-analytics.com/g/collect"))); + assertFalse(list.allowsFrameFromSource(uri("https://js.stripe.com/v3/"))); + assertTrue(list.allowsImageFromSource(uri("https://cdn.example.net/hero.webp"))); + assertFalse(list.allowsUnsafeInlineScript()); + assertTrue(list.allowsUnsafeInlineStyle()); + assertFalse(list.allowsEval()); + } + + /** + * Marketing stack (GTM / GA / fonts) intersected with a stricter product policy that + * only permits first-party scripts — common when product and marketing CSPs both ship. + */ + @Test + public void marketingStackIntersectedWithProductLockdown() { + final String marketing = String.join(" ", + "default-src 'none';", + "script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com" + + " https://tagmanager.google.com 'unsafe-inline';", + "style-src 'self' https://fonts.googleapis.com https://tagmanager.google.com 'unsafe-inline';", + "font-src 'self' https://fonts.gstatic.com data:;", + "img-src 'self' data: https://www.google-analytics.com https://www.googletagmanager.com" + + " https://*.googleusercontent.com;", + "connect-src 'self' https://www.google-analytics.com https://analytics.google.com" + + " https://stats.g.doubleclick.net;", + "frame-src https://www.googletagmanager.com;"); + + final String product = String.join(" ", + "default-src 'self';", + "script-src 'self';", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;", + "font-src 'self' https://fonts.gstatic.com;", + "img-src 'self' data: https:;", + "connect-src 'self';", + "frame-src 'none';", + "object-src 'none'"); + + final PolicyListInOrigin list = ofSerializedIgnored(List.of(marketing, product)); + + assertTrue(list.allowsScriptFromSource(uri("https://example.com/bundle.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://www.googletagmanager.com/gtm.js"))); + assertTrue(list.allowsStyleFromSource(uri("https://fonts.googleapis.com/css2?family=Inter"))); + assertTrue(list.allowsFontFromSource(uri("https://fonts.gstatic.com/s/inter/v12/font.woff2"))); + assertFalse(list.allowsConnection(uri("https://www.google-analytics.com/g/collect"))); + assertFalse(list.allowsFrameFromSource(uri("https://www.googletagmanager.com/ns.html"))); + assertFalse(list.allowsUnsafeInlineScript()); + assertTrue(list.allowsUnsafeInlineStyle()); + } + + /** + * Enforce header + page meta policy (meta often used for app-generated directives). + * Intersection still applies; {@code report-uri} / {@code frame-ancestors} only on + * the header policy (and meta is parsed with {@code deliveredViaMeta = true}). + */ + @Test + public void headerPlusMetaDualDelivery() { + final String headerText = String.join(" ", + "default-src 'self';", + "script-src 'self' https://cdn.example.com;", + "img-src 'self' https://images.example.com;", + "connect-src 'self';", + "frame-ancestors 'none';", + "report-uri /csp-report"); + + final String metaText = String.join(" ", + "default-src 'self';", + "script-src 'self';", + "img-src 'self' https://images.example.com https://cdn.example.com;", + "connect-src 'self' https://api.example.com"); + + final Policy header = Policy.parseSerializedCSP( + headerText, Policy.PolicyErrorConsumer.ignored, false); + final Policy meta = Policy.parseSerializedCSP( + metaText, Policy.PolicyErrorConsumer.ignored, true); + assertTrue(meta.deliveredViaMeta()); + assertFalse(header.deliveredViaMeta()); + + final PolicyListInOrigin list = new PolicyListInOrigin( + new PolicyList(List.of(header, meta)), ORIGIN); + + assertTrue(list.allowsScriptFromSource(uri("https://example.com/main.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://cdn.example.com/vendor.js"))); + assertTrue(list.allowsImageFromSource(uri("https://images.example.com/a.png"))); + assertFalse(list.allowsImageFromSource(uri("https://cdn.example.com/b.png"))); + assertFalse(list.allowsConnection(uri("https://api.example.com/x"))); + assertFalse(list.allowsFrameAncestor(uri("https://evil.example/"))); + assertFalse(list.allowsFrameAncestor(uri("https://example.com/"))); + } + + /** + * {@code navigate-to} / {@code javascript:} URL navigation must pass every policy. + */ + @Test + public void navigationAndJavascriptUrlRequireAllPolicies() { + final PolicyListInOrigin nav = ofSerialized(List.of( + "navigate-to https://a.example.com", + "navigate-to https://a.example.com https://b.example.com")); + + assertTrue(nav.allowsNavigation(uri("https://a.example.com/page"))); + assertFalse(nav.allowsNavigation(uri("https://b.example.com/page"))); + assertFalse(nav.allowsNavigation(uri("https://c.example.com/page"))); + + final PolicyList bothAllowJs = PolicyList.ofSerialized( + List.of( + "script-src 'unsafe-inline'; navigate-to javascript:", + "default-src 'unsafe-inline'; navigate-to javascript:"), + PolicyListErrorConsumer.ignored); + assertTrue(bothAllowJs.allowsJavascriptUrlNavigation( + Optional.of("alert(1)"), Optional.of(ORIGIN))); + + final PolicyList oneBlocksJs = PolicyList.ofSerialized( + List.of( + "script-src 'unsafe-inline'; navigate-to javascript:", + "navigate-to 'none'"), + PolicyListErrorConsumer.ignored); + assertFalse(oneBlocksJs.allowsJavascriptUrlNavigation( + Optional.of("alert(1)"), Optional.of(ORIGIN))); + } + + /** + * Meta {@code frame-ancestors} / {@code sandbox} are ignored for query; header + * copies still AND. Fetch directives on the meta policy still participate. + */ + @Test + public void headerPlusMetaIgnoresMetaInvalidDirectivesForQuery() { + final Policy header = Policy.parseSerializedCSP( + "default-src 'self'; frame-ancestors 'none'", Policy.PolicyErrorConsumer.ignored, false); + final Policy meta = Policy.parseSerializedCSP( + "script-src 'self'; frame-ancestors 'none'; sandbox", + Policy.PolicyErrorConsumer.ignored, true); + + final PolicyListInOrigin list = new PolicyListInOrigin( + new PolicyList(List.of(header, meta)), ORIGIN); + + // Meta sandbox does not block scripts; both policies allow 'self' scripts + assertTrue(list.allowsScriptFromSource(uri("https://example.com/app.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://cdn.example.com/x.js"))); + + // Header frame-ancestors 'none' still blocks; meta's copy is ignored for query + assertFalse(list.allowsFrameAncestor(uri("https://evil.example/"))); + assertFalse(list.allowsFrameAncestor(uri("https://example.com/"))); + + // Meta-only invalid directives: no header frame-ancestors → allow ancestors; + // meta sandbox does not block forms/scripts by itself + final Policy headerFetchOnly = Policy.parseSerializedCSP( + "script-src 'self'", Policy.PolicyErrorConsumer.ignored, false); + final PolicyListInOrigin metaFaOnly = new PolicyListInOrigin( + new PolicyList(List.of(headerFetchOnly, meta)), ORIGIN); + assertTrue(metaFaOnly.allowsFrameAncestor(uri("https://evil.example/"))); + assertTrue(metaFaOnly.allowsScriptFromSource(uri("https://example.com/app.js"))); + assertTrue(metaFaOnly.allowsFormAction(uri("https://example.com/submit"))); + } + + /** + * Busy multi-CDN allowlists with only partial host overlap across two headers. + */ + @Test + public void multiCdnAllowlistsPartialOverlap() { + final String policyA = String.join(" ", + "default-src 'none';", + "script-src 'self' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com;", + "style-src 'self' https://cdn.jsdelivr.net https://fonts.googleapis.com;", + "font-src https://fonts.gstatic.com https://cdn.jsdelivr.net;", + "img-src 'self' https://cdn.jsdelivr.net data: blob:;", + "worker-src 'self' blob:;"); + + final String policyB = String.join(" ", + "default-src 'none';", + "script-src 'self' https://cdn.jsdelivr.net https://static.cloudflareinsights.com;", + "style-src 'self' https://fonts.googleapis.com;", + "font-src https://fonts.gstatic.com;", + "img-src 'self' data:;", + "worker-src 'self';"); + + final PolicyListInOrigin list = ofSerializedIgnored(List.of(policyA, policyB)); + + assertTrue(list.allowsScriptFromSource(uri("https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://unpkg.com/react@18/umd/react.production.min.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://static.cloudflareinsights.com/beacon.min.js"))); + assertTrue(list.allowsStyleFromSource(uri("https://fonts.googleapis.com/css?family=Roboto"))); + assertFalse(list.allowsStyleFromSource(uri("https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css"))); + assertTrue(list.allowsFontFromSource(uri("https://fonts.gstatic.com/s/roboto/v30/font.woff2"))); + assertFalse(list.allowsImageFromSource(uri("https://cdn.jsdelivr.net/gh/foo/bar/logo.png"))); + assertTrue(list.allowsWorkerFromSource(uri("https://example.com/sw.js"))); + assertFalse(list.allowsWorkerFromSource(uri("https://cdn.jsdelivr.net/worker.js"))); + } + + /** + * Wildcard host in one policy, concrete host in the other — intersection is hosts that + * match the wildcard and appear (or are covered) in the second policy. + */ + @Test + public void wildcardHostIntersectConcreteHosts() { + final PolicyListInOrigin list = ofSerializedIgnored(List.of( + "script-src https://*.example.net https://cdn.partner.com", + "script-src https://assets.example.net https://cdn.partner.com https://extra.example.net")); + + assertTrue(list.allowsScriptFromSource(uri("https://assets.example.net/app.js"))); + assertTrue(list.allowsScriptFromSource(uri("https://cdn.partner.com/lib.js"))); + assertTrue(list.allowsScriptFromSource(uri("https://extra.example.net/x.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://other.example.net/x.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://cdn.other.com/x.js"))); + } + + /** + * Two host-source wildcards AND'd: a URL must match both patterns. + * Nested wildcards share hosts in the overlap; disjoint wildcards allow nothing. + */ + @Test + public void wildcardHostIntersectWildcardHost() { + // Nested: *.cdn.example.com ⊂ *.example.com (for matching purposes) + final PolicyListInOrigin nested = ofSerializedIgnored(List.of( + "script-src https://*.example.com", + "script-src https://*.cdn.example.com")); + + assertTrue(nested.allowsScriptFromSource(uri("https://assets.cdn.example.com/app.js"))); + assertTrue(nested.allowsScriptFromSource(uri("https://a.b.cdn.example.com/x.js"))); + assertFalse(nested.allowsScriptFromSource(uri("https://www.example.com/app.js"))); + assertFalse(nested.allowsScriptFromSource(uri("https://cdn.example.com/app.js"))); + assertFalse(nested.allowsScriptFromSource(uri("https://other.example.net/app.js"))); + + // Disjoint registrable domains → no host satisfies both + final PolicyListInOrigin disjoint = ofSerializedIgnored(List.of( + "img-src https://*.example.com", + "img-src https://*.example.net")); + + assertFalse(disjoint.allowsImageFromSource(uri("https://a.example.com/i.png"))); + assertFalse(disjoint.allowsImageFromSource(uri("https://a.example.net/i.png"))); + assertFalse(disjoint.allowsImageFromSource(uri("https://a.example.org/i.png"))); + + // Scheme-part-match: http source allows https URL; https source does not allow http URL + final PolicyListInOrigin schemeAndHost = ofSerializedIgnored(List.of( + "script-src https://*.assets.example.com", + "script-src http://*.assets.example.com")); + + assertTrue(schemeAndHost.allowsScriptFromSource(uri("https://cdn.assets.example.com/lib.js"))); + assertFalse(schemeAndHost.allowsScriptFromSource(uri("http://cdn.assets.example.com/lib.js"))); + + // https://* ∩ https://*.partner.com + final PolicyListInOrigin starScheme = ofSerializedIgnored(List.of( + "script-src https://*", + "script-src https://*.partner.com")); + + assertTrue(starScheme.allowsScriptFromSource(uri("https://cdn.partner.com/a.js"))); + assertFalse(starScheme.allowsScriptFromSource(uri("https://cdn.other.com/a.js"))); + assertFalse(starScheme.allowsScriptFromSource(uri("http://cdn.partner.com/a.js"))); + } + + /** + * Three policies (primary app, payment iframe vendor allowlist, reporting/monitoring + * lockdown) — resource must pass all three. + */ + @Test + public void threeLayerCorporatePolicies() { + final String app = String.join(" ", + "default-src 'self';", + "script-src 'self' https://js.stripe.com https://checkout.paypal.com;", + "frame-src 'self' https://js.stripe.com https://hooks.stripe.com https://www.paypal.com;", + "connect-src 'self' https://api.stripe.com https://api.paypal.com;", + "img-src 'self' data: https:;", + "style-src 'self' 'unsafe-inline';", + "object-src 'none'"); + + final String paymentsOnly = String.join(" ", + "default-src 'none';", + "script-src 'self' https://js.stripe.com;", + "frame-src https://js.stripe.com https://hooks.stripe.com;", + "connect-src 'self' https://api.stripe.com;", + "img-src 'self' data:;", + "style-src 'self' 'unsafe-inline';", + "object-src 'none'"); + + final String monitor = "default-src 'self'; script-src 'self'; connect-src 'self'; frame-src 'self'; object-src 'none'"; + + final PolicyListInOrigin list = ofSerializedIgnored(List.of(app, paymentsOnly, monitor)); + + assertTrue(list.allowsScriptFromSource(uri("https://example.com/checkout.js"))); + assertFalse(list.allowsScriptFromSource(uri("https://js.stripe.com/v3/"))); + assertFalse(list.allowsScriptFromSource(uri("https://checkout.paypal.com/js"))); + assertFalse(list.allowsFrameFromSource(uri("https://js.stripe.com/v3/"))); + assertFalse(list.allowsConnection(uri("https://api.stripe.com/v1/tokens"))); + assertTrue(list.allowsConnection(uri("https://example.com/api/order"))); + // monitor falls back to default-src 'self' for style → no 'unsafe-inline' + assertFalse(list.allowsUnsafeInlineStyle()); + assertFalse(list.allowsUnsafeInlineScript()); + } + + /** + * Host / scheme matching is ASCII case-insensitive across separately parsed policies + * (CSP3 host-part / scheme-part match). Differently cased hosts in two policies still + * share an intersection — Path A never merges strings, so Salvation #120-style case + * loss on intersect does not apply. + */ + @Test + public void hostCaseInsensitiveAcrossPolicies() { + final PolicyListInOrigin list = ofSerializedIgnored(List.of( + "img-src https://CDN.Example.COM", + "img-src HTTPS://cdn.example.com")); + + assertTrue(list.allowsImageFromSource(uri("https://cdn.example.com/a.png"))); + assertTrue(list.allowsImageFromSource(uri("https://CDN.EXAMPLE.COM/a.png"))); + assertTrue(list.allowsImageFromSource(uri("HTTPS://CdN.ExAmPlE.cOm/a.png"))); + assertFalse(list.allowsImageFromSource(uri("https://other.example.com/a.png"))); + } + + /** + * Path matching is case-sensitive. Same path casing in both policies allows that path; + * mismatched path casing across policies yields empty intersection for those URLs. + */ + @Test + public void pathCaseSensitiveAcrossPolicies() { + final PolicyListInOrigin sameCase = ofSerializedIgnored(List.of( + "script-src https://cdn.example.com/App/", + "script-src https://cdn.example.com/App/ https://cdn.example.com/other/")); + + assertTrue(sameCase.allowsScriptFromSource(uri("https://cdn.example.com/App/x.js"))); + assertFalse(sameCase.allowsScriptFromSource(uri("https://cdn.example.com/app/x.js"))); + assertFalse(sameCase.allowsScriptFromSource(uri("https://cdn.example.com/other/x.js"))); + + // /App/ ∩ /app/ — no URL path satisfies both (case-sensitive) + final PolicyListInOrigin mismatchedCase = ofSerializedIgnored(List.of( + "script-src https://cdn.example.com/App/", + "script-src https://cdn.example.com/app/")); + + assertFalse(mismatchedCase.allowsScriptFromSource(uri("https://cdn.example.com/App/x.js"))); + assertFalse(mismatchedCase.allowsScriptFromSource(uri("https://cdn.example.com/app/x.js"))); + } + + /** + * Invalid {@code 'none' host} in one directive: parse reports an error, but the host + * expression still participates in matching (same as single-policy query behavior). + * Under AND, a pure {@code 'none'} sibling policy still blocks. + */ + @Test + public void noneCombinedWithHostUnderAnd() { + // Garbage directive alone (via PolicyList of one) still allows the host + final PolicyListInOrigin garbageAlone = ofSerializedIgnored(List.of( + "script-src 'none' https://cdn.example.com")); + assertTrue(garbageAlone.allowsScriptFromSource(uri("https://cdn.example.com/a.js"))); + assertFalse(garbageAlone.allowsScriptFromSource(uri("https://other.example.com/a.js"))); + + // Overlap with a second policy that also lists the host → allow + final PolicyListInOrigin overlap = ofSerializedIgnored(List.of( + "script-src 'none' https://cdn.example.com", + "script-src https://cdn.example.com https://other.example.com")); + assertTrue(overlap.allowsScriptFromSource(uri("https://cdn.example.com/a.js"))); + assertFalse(overlap.allowsScriptFromSource(uri("https://other.example.com/a.js"))); + + // Pure 'none' in the other policy → block everything + final PolicyListInOrigin noneSibling = ofSerializedIgnored(List.of( + "script-src 'none' https://cdn.example.com", + "script-src 'none'")); + assertFalse(noneSibling.allowsScriptFromSource(uri("https://cdn.example.com/a.js"))); + } + + /** + * Nonce present in both policies allows inline; second policy without the nonce blocks + * even if the first would allow via nonce. + */ + @Test + public void nonceMustSatisfyEveryPolicy() { + final PolicyList both = PolicyList.ofSerialized( + List.of( + "script-src 'nonce-abc123' 'strict-dynamic'", + "script-src 'nonce-abc123' https://cdn.example.com"), + PolicyListErrorConsumer.ignored); + assertTrue(both.allowsInlineScript( + Optional.of("abc123"), Optional.of("console.log(1)"), Optional.of(false))); + + final PolicyList mismatched = PolicyList.ofSerialized( + List.of( + "script-src 'nonce-abc123'", + "script-src 'nonce-other'"), + PolicyListErrorConsumer.ignored); + assertFalse(mismatched.allowsInlineScript( + Optional.of("abc123"), Optional.of("console.log(1)"), Optional.of(false))); + assertFalse(mismatched.allowsInlineScript( + Optional.of("other"), Optional.of("console.log(1)"), Optional.of(false))); + } + + private static PolicyListInOrigin ofSerialized(final List values) { + return new PolicyListInOrigin( + PolicyList.ofSerialized(values, ThrowIfPolicyListError), ORIGIN); + } + + private static PolicyListInOrigin ofSerializedIgnored(final List values) { + return new PolicyListInOrigin( + PolicyList.ofSerialized(values, PolicyListErrorConsumer.ignored), ORIGIN); + } + + private static URLWithScheme uri(final String url) { + return URI.parseURI(url).orElseThrow(); + } +} diff --git a/src/test/java/org/htmlunit/csp/QueryingTest.java b/src/test/java/org/htmlunit/csp/QueryingTest.java index dd4b8e2..fa4deb5 100644 --- a/src/test/java/org/htmlunit/csp/QueryingTest.java +++ b/src/test/java/org/htmlunit/csp/QueryingTest.java @@ -1473,6 +1473,39 @@ public void sandbox() { assertTrue(p.allowsExternalScript(Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); } + /** + * Meta-delivered {@code sandbox} / {@code frame-ancestors} stay in the model for + * analysis but do not affect {@code allows*} (HTML strips them before enforce). + */ + @Test + public void metaInvalidDirectivesIgnoredForQuery() { + final Policy sandboxMeta = Policy.parseSerializedCSP( + "sandbox", PolicyErrorConsumer.ignored, true); + assertTrue(sandboxMeta.deliveredViaMeta()); + assertTrue(sandboxMeta.sandbox().isPresent()); + assertTrue(sandboxMeta.allowsScriptAsAttribute(Optional.empty())); + assertTrue(sandboxMeta.allowsInlineScript(Optional.empty(), Optional.empty(), Optional.empty())); + assertTrue(sandboxMeta.allowsExternalScript( + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); + assertTrue(sandboxMeta.allowsFormAction( + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); + + final Policy faMeta = Policy.parseSerializedCSP( + "frame-ancestors 'none'", PolicyErrorConsumer.ignored, true); + assertTrue(faMeta.frameAncestors().isPresent()); + assertTrue(faMeta.allowsFrameAncestor( + Optional.of(URI.parseURI("https://evil.example/").orElseThrow()), Optional.empty())); + + // Header delivery still enforces + final Policy sandboxHeader = Policy.parseSerializedCSP( + "sandbox", PolicyErrorConsumer.ignored, false); + assertFalse(sandboxHeader.allowsInlineScript(Optional.empty(), Optional.empty(), Optional.empty())); + final Policy faHeader = Policy.parseSerializedCSP( + "frame-ancestors 'none'", PolicyErrorConsumer.ignored, false); + assertFalse(faHeader.allowsFrameAncestor( + Optional.of(URI.parseURI("https://evil.example/").orElseThrow()), Optional.empty())); + } + private Policy parse(final String policy) { return Policy.parseSerializedCSP(policy, ThrowIfPolicyError); }