All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.htmlunit.csp.Policy Maven / Gradle / Ivy

There is a newer version: 4.7.0
Show newest version
/*
 * Copyright (c) 2023-2024 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
 * 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.htmlunit.csp;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.EnumMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.htmlunit.csp.directive.FrameAncestorsDirective;
import org.htmlunit.csp.directive.HostSourceDirective;
import org.htmlunit.csp.directive.PluginTypesDirective;
import org.htmlunit.csp.directive.ReportUriDirective;
import org.htmlunit.csp.directive.SandboxDirective;
import org.htmlunit.csp.directive.SourceExpressionDirective;
import org.htmlunit.csp.url.GUID;
import org.htmlunit.csp.url.URI;
import org.htmlunit.csp.url.URLWithScheme;
import org.htmlunit.csp.value.Hash;
import org.htmlunit.csp.value.Host;
import org.htmlunit.csp.value.MediaType;
import org.htmlunit.csp.value.RFC7230Token;
import org.htmlunit.csp.value.Scheme;

public final class Policy {
    // Things we don't preserve:
    // - Whitespace
    // - Empty directives or policies (as in `; ;` or `, ,`)
    // Things we do preserve:
    // - Source-expression lists being genuinely empty vs consisting of 'none'
    // - Case (as in lowercase vs uppercase)
    // - Order
    // - Duplicate directives
    // - Unrecognized directives
    // - Values in directives which forbid them
    // - Duplicate values
    // - Unrecognized values

    private final List directives_ = new ArrayList<>();

    private SourceExpressionDirective baseUri_;
    private boolean blockAllMixedContent_;
    private SourceExpressionDirective formAction_;
    private FrameAncestorsDirective frameAncestors_;
    private SourceExpressionDirective navigateTo_;
    private PluginTypesDirective pluginTypes_;
    private FetchDirectiveKind prefetchSrc_;
    private RFC7230Token reportTo_;
    private ReportUriDirective reportUri_;
    private SandboxDirective sandbox_;
    private boolean upgradeInsecureRequests_;

    private final EnumMap fetchDirectives_
                    = new EnumMap<>(FetchDirectiveKind.class);

    private Policy() {
        // pass
    }

    // https://w3c.github.io/webappsec-csp/#parse-serialized-policy-list
    public static PolicyList parseSerializedCSPList(final String serialized,
                        final PolicyListErrorConsumer policyListErrorConsumer) {
        // "A serialized CSP list is an ASCII string"
        enforceAscii(serialized);

        final List policies = new ArrayList<>();

        // java's lambdas are dumb
        final int[] index = {0};
        final PolicyErrorConsumer policyErrorConsumer =
                (Severity severity, String message, int directiveIndex, int valueIndex) -> {
                    policyListErrorConsumer.add(severity, message, index[0], directiveIndex, valueIndex);
                };

        // https://infra.spec.whatwg.org/#split-on-commas
        for (final String token : serialized.split(",")) {
            final Policy policy = parseSerializedCSP(token, policyErrorConsumer);
            if (policy.directives_.isEmpty()) {
                ++index[0];
                continue;
            }

            policies.add(policy);

            ++index[0];
        }
        return new PolicyList(policies);
    }

    // https://w3c.github.io/webappsec-csp/#parse-serialized-policy
    public static Policy parseSerializedCSP(final String serialized, final PolicyErrorConsumer policyErrorConsumer) {
        // "A serialized CSP is an ASCII string", and browsers do in fact reject CSPs which contain non-ASCII characters
        enforceAscii(serialized);
        if (serialized.contains(",")) {
            // This is not quite per spec, but
            throw new IllegalArgumentException(
                    "Serialized CSPs cannot contain commas - you may have wanted parseSerializedCSPList");
        }

        // java's lambdas are dumb
        final int[] index = {0};
        final Directive.DirectiveErrorConsumer directiveErrorConsumer =
                (Severity severity, String message, int valueIndex) -> {
                    policyErrorConsumer.add(severity, message, index[0], valueIndex);
                };

        final Policy policy = new Policy();

        // https://infra.spec.whatwg.org/#strictly-split
        for (final String token : serialized.split(";")) {
            final String strippedLeadingAndTrailingWhitespace = stripTrailingWhitespace(stripLeadingWhitespace(token));
            if (strippedLeadingAndTrailingWhitespace.isEmpty()) {
                ++index[0];
                continue;
            }
            final String directiveName =
                            collect(strippedLeadingAndTrailingWhitespace, "[^" + Constants.WHITESPACE_CHARS + "]+");

            // Note: we do not lowercase directive names or
            // skip duplicates during parsing, to allow round-tripping even invalid policies

            final String remainingToken = strippedLeadingAndTrailingWhitespace.substring(directiveName.length());

            final List directiveValues = Utils.splitOnAsciiWhitespace(remainingToken);

            policy.add(directiveName, directiveValues, directiveErrorConsumer);

            ++index[0];
        }

        return policy;
    }

    // We do not provide a generic method for updating an existing directive in-place.
    // Just remove the existing one and add it back.
    private Directive add(final String name, final List values,
                            final Directive.DirectiveErrorConsumer directiveErrorConsumer) {
        enforceAscii(name);

        // the parser will never hit these errors by construction, but use of the manipulation APIs can
        if (Directive.containsNonDirectiveCharacter.test(name)) {
            throw new IllegalArgumentException("directive names must not contain whitespace, ',', or ';'");
        }
        if (name.isEmpty()) {
            throw new IllegalArgumentException("directive names must not be empty");
        }

        boolean wasDupe = false;
        final Directive newDirective;
        final String lowcaseDirectiveName = name.toLowerCase(Locale.ROOT);
        switch (lowcaseDirectiveName) {
            case "base-uri":
                // https://w3c.github.io/webappsec-csp/#directive-base-uri
                final SourceExpressionDirective baseUriDirective
                        = new SourceExpressionDirective(values, directiveErrorConsumer);
                if (baseUri_ == null) {
                    baseUri_ = baseUriDirective;
                }
                else {
                    wasDupe = true;
                }
                newDirective = baseUriDirective;
                break;

            case "block-all-mixed-content":
                // https://www.w3.org/TR/mixed-content/#strict-opt-in
                if (blockAllMixedContent_) {
                    wasDupe = true;
                }
                else {
                    if (!values.isEmpty()) {
                        directiveErrorConsumer.add(Severity.Error,
                                        "The block-all-mixed-content directive does not support values", 0);
                    }
                    blockAllMixedContent_ = true;
                }
                newDirective = new Directive(values);
                break;

            case "form-action":
                // https://w3c.github.io/webappsec-csp/#directive-form-action
                final SourceExpressionDirective formActionDirective
                        = new SourceExpressionDirective(values, directiveErrorConsumer);
                if (formAction_ == null) {
                    formAction_ = formActionDirective;
                }
                else {
                    wasDupe = true;
                }
                newDirective = formActionDirective;
                break;

            case "frame-ancestors":
                // https://w3c.github.io/webappsec-csp/#directive-frame-ancestors
                // TODO contemplate warning for paths, which are always ignored: frame-ancestors only matches
                // against origins: https://w3c.github.io/webappsec-csp/#frame-ancestors-navigation-response
                final FrameAncestorsDirective frameAncestorsDirective
                        = new FrameAncestorsDirective(values, directiveErrorConsumer);
                if (frameAncestors_ == null) {
                    frameAncestors_ = frameAncestorsDirective;
                }
                else {
                    wasDupe = true;
                }
                newDirective = frameAncestorsDirective;
                break;

            case "navigate-to":
                // https://w3c.github.io/webappsec-csp/#directive-navigate-to
                // For some ungodly reason "navigate-to" is a list of source expressions while "frame-ancestors" is not
                // There is no logic here
                final SourceExpressionDirective navigateToDirective
                        = new SourceExpressionDirective(values, directiveErrorConsumer);
                if (navigateTo_ == null) {
                    navigateTo_ = navigateToDirective;
                }
                else {
                    wasDupe = true;
                }
                newDirective = navigateToDirective;
                break;

            case "plugin-types":
                // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/plugin-types
                directiveErrorConsumer.add(Severity.Warning, "The plugin-types directive has been deprecated", -1);
                final PluginTypesDirective pluginTypesDirective
                        = new PluginTypesDirective(values, directiveErrorConsumer);
                if (pluginTypes_ == null) {
                    pluginTypes_ = pluginTypesDirective;
                }
                else {
                    wasDupe = true;
                }
                newDirective = pluginTypesDirective;
                break;

            case "report-to":
                // https://w3c.github.io/webappsec-csp/#directive-report-to
                if (reportTo_ == null) {
                    if (values.isEmpty()) {
                        directiveErrorConsumer.add(Severity.Error, "The report-to directive requires a value", -1);
                    }
                    else if (values.size() == 1) {
                        final String token = values.get(0);
                        final Optional matched = RFC7230Token.parseRFC7230Token(token);
                        if (matched.isPresent()) {
                            reportTo_ = matched.get();
                        }
                        else {
                            directiveErrorConsumer.add(Severity.Error,
                                                        "Expecting RFC 7230 token but found \"" + token + "\"", 0);
                        }
                    }
                    else {
                        directiveErrorConsumer.add(Severity.Error,
                                "The report-to directive requires exactly one value (found " + values.size() + ")", 1);
                    }
                }
                else {
                    wasDupe = true;
                }
                newDirective = new Directive(values);
                break;

            case "referrer":
                // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/referrer
                directiveErrorConsumer.add(Severity.Warning,
                        "The referrer directive has been deprecated in favor of the Referrer-Policy header",
                        -1);
                // We don't currently handle it further than this.
                newDirective = new Directive(Collections.emptyList());
                break;

            case "report-uri":
                // https://w3c.github.io/webappsec-csp/#directive-report-uri
                directiveErrorConsumer.add(Severity.Warning,
                        "The report-uri directive has been deprecated in favor of the new report-to directive", -1);

                final ReportUriDirective reportUriDirective = new ReportUriDirective(values, directiveErrorConsumer);
                if (reportUri_ == null) {
                    reportUri_ = reportUriDirective;
                }
                else {
                    wasDupe = true;
                }
                newDirective = reportUriDirective;
                break;

            case "sandbox":
                // https://w3c.github.io/webappsec-csp/#directive-sandbox
                final SandboxDirective sandboxDirective = new SandboxDirective(values, directiveErrorConsumer);
                if (sandbox_ == null) {
                    sandbox_ = sandboxDirective;
                }
                else {
                    wasDupe = true;
                }
                newDirective = sandboxDirective;
                break;

            case "upgrade-insecure-requests":
                // https://www.w3.org/TR/upgrade-insecure-requests/#delivery
                if (upgradeInsecureRequests_) {
                    wasDupe = true;
                }
                else {
                    if (!values.isEmpty()) {
                        directiveErrorConsumer.add(Severity.Error,
                                "The upgrade-insecure-requests directive does not support values", 0);
                    }
                    upgradeInsecureRequests_ = true;
                }
                newDirective = new Directive(values);
                break;

            default:
                if (!Directive.IS_DIRECTIVE_NAME.test(name)) {
                    directiveErrorConsumer.add(Severity.Error,
                                    "Directive name " + name
                                        + " contains characters outside the range ALPHA / DIGIT / \"-\"", -1);
                    newDirective = new Directive(values);
                    break;
                }
                final FetchDirectiveKind fetchDirectiveKind = FetchDirectiveKind.fromString(lowcaseDirectiveName);
                if (fetchDirectiveKind != null) {
                    if (FetchDirectiveKind.PrefetchSrc == fetchDirectiveKind) {
                        directiveErrorConsumer.add(Severity.Warning,
                                                    "The prefetch-src directive has been deprecated", -1);
                    }
                    final SourceExpressionDirective thisDirective
                                = new SourceExpressionDirective(values, directiveErrorConsumer);
                    if (fetchDirectives_.containsKey(fetchDirectiveKind)) {
                        wasDupe = true;
                    }
                    else {
                        fetchDirectives_.put(fetchDirectiveKind, thisDirective);
                    }
                    newDirective = thisDirective;
                    break;
                }
                directiveErrorConsumer.add(Severity.Warning, "Unrecognized directive " + lowcaseDirectiveName, -1);
                newDirective = new Directive(values);
                break;
        }

        directives_.add(new NamedDirective(name, newDirective));
        if (wasDupe) {
            directiveErrorConsumer.add(Severity.Warning, "Duplicate directive " + lowcaseDirectiveName, -1);
        }
        return newDirective;
    }

    @Override
    public String toString() {
        final StringBuilder out = new StringBuilder();
        boolean first = true;
        for (final NamedDirective directive : directives_) {
            if (!first) {
                out.append("; "); // The whitespace is not strictly necessary but is probably valuable
            }
            first = false;
            out.append(directive.name_);
            for (final String value : directive.directive_.getValues()) {
                out.append(' ').append(value);
            }
        }
        return out.toString();
    }

    // Accessors

    public Optional baseUri() {
        return Optional.ofNullable(baseUri_);
    }

    public boolean blockAllMixedContent() {
        return blockAllMixedContent_;
    }

    public Optional formAction() {
        return Optional.ofNullable(formAction_);
    }

    public Optional frameAncestors() {
        return Optional.ofNullable(frameAncestors_);
    }

    public Optional navigateTo() {
        return Optional.ofNullable(navigateTo_);
    }

    public Optional pluginTypes() {
        return Optional.ofNullable(pluginTypes_);
    }

    public Optional prefetchSrc() {
        return Optional.ofNullable(prefetchSrc_);
    }

    public Optional reportTo() {
        return Optional.ofNullable(reportTo_);
    }

    public Optional reportUri() {
        return Optional.ofNullable(reportUri_);
    }

    public Optional sandbox() {
        return Optional.ofNullable(sandbox_);
    }

    public boolean upgradeInsecureRequests() {
        return upgradeInsecureRequests_;
    }

    public Optional getFetchDirective(final FetchDirectiveKind kind) {
        return Optional.ofNullable(fetchDirectives_.get(kind));
    }

    // High-level querying

    /*
    For each of these arguments, if the value provided is Optional.empty(), this method will return `true`
    only if there is no value for the Optional.of() case of that parameter which would cause it to return `false`.
    Take care with `integrity`; your script can be allowed by CSP but blocked by SRI if its integrity is wrong.
    See https://www.w3.org/TR/SRI/
    Also note that the notion of "the URL" is a little fuzzy because there can be redirects.
    https://w3c.github.io/webappsec-csp/#script-pre-request
    https://w3c.github.io/webappsec-csp/#script-post-request
     */
    public boolean allowsExternalScript(final Optional nonce, final Optional integrity,
            final Optional scriptUrl, final Optional parserInserted,
            final Optional origin) {
        if (sandbox_ != null && !sandbox_.allowScripts()) {
            return false;
        }

        // Effective directive is "script-src-elem" per
        // https://w3c.github.io/webappsec-csp/#effective-directive-for-a-request
        final SourceExpressionDirective directive =
                getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.ScriptSrcElem).orElse(null);
        if (directive == null) {
            return true;
        }
        if (nonce.isPresent()) {
            final String actualNonce = nonce.get();
            if (actualNonce.length() > 0
                    && directive.getNonces().stream().anyMatch(n -> n.getBase64ValuePart().equals(actualNonce))) {
                return true;
            }
        }
        if (integrity.isPresent() && !directive.getHashes().isEmpty()) {
            final String integritySources = integrity.get();
            boolean bypassDueToIntegrityMatch = true;
            boolean atLeastOneValidIntegrity = false;
            // https://www.w3.org/TR/SRI/#parse-metadata
            for (final String source : Utils.splitOnAsciiWhitespace(integritySources)) {
                final Optional parsedIntegritySource = Hash.parseHash("'" + source + "'");
                if (!parsedIntegritySource.isPresent()) {
                    continue;
                }
                if (!directive.getHashes().contains(parsedIntegritySource.get())) {
                    bypassDueToIntegrityMatch = false;
                    break;
                }
                atLeastOneValidIntegrity = true;
            }
            if (atLeastOneValidIntegrity && bypassDueToIntegrityMatch) {
                return true;
            }
        }
        if (directive.strictDynamic()) {
            // if not the parameter is not supplied, we have to assume the worst case
            return !parserInserted.orElse(true);
        }
        if (scriptUrl.isPresent()) {
            return doesUrlMatchSourceListInOrigin(scriptUrl.get(), directive, origin);
        }
        return false;
    }

    // https://w3c.github.io/webappsec-csp/#script-src-elem-inline
    public boolean allowsInlineScript(final Optional nonce,
            final Optional source, final Optional parserInserted) {
        if (sandbox_ != null && !sandbox_.allowScripts()) {
            return false;
        }
        return doesElementMatchSourceListForTypeAndSource(InlineType.Script, nonce, source, parserInserted);
    }

    // https://w3c.github.io/webappsec-csp/#script-src-attr-inline
    public boolean allowsScriptAsAttribute(final Optional source) {
        if (sandbox_ != null && !sandbox_.allowScripts()) {
            return false;
        }
        return doesElementMatchSourceListForTypeAndSource(
                InlineType.ScriptAttribute, Optional.empty(), source, Optional.empty());
    }

    // https://w3c.github.io/webappsec-csp/#can-compile-strings
    public boolean allowsEval() {
        // This is done in prose, not in a table
        final FetchDirectiveKind governingDirective =
                fetchDirectives_
                    .containsKey(FetchDirectiveKind.ScriptSrc)
                        ? FetchDirectiveKind.ScriptSrc : FetchDirectiveKind.DefaultSrc;
        final SourceExpressionDirective sourceList = fetchDirectives_.get(governingDirective);
        return sourceList == null || sourceList.unsafeEval();
    }

    // https://w3c.github.io/webappsec-csp/#navigate-to-pre-navigate
    // https://w3c.github.io/webappsec-csp/#navigate-to-navigation-response
    // Strictly speaking this requires the _response_'s CSP as well, because of frame-ancestors.
    // But we are maybe not going to worry about that.
    // Note: it is nonsensical to provide redirectedTo if redirected is Optional.of(false)
    // Note: this also does not handle `javascript:` navigation; there's an explicit API for that
    public boolean allowsNavigation(final Optional to, final Optional redirected,
            final Optional redirectedTo, final Optional origin) {
        if (navigateTo_ == null) {
            return true;
        }
        if (navigateTo_.unsafeAllowRedirects()) {
            // if unsafe-allow-redirects is present, check `to` in non-redirect or maybe-non-redirect cases
            if (!redirected.orElse(false)) {
                if (!to.isPresent()) {
                    return false;
                }
                if (!doesUrlMatchSourceListInOrigin(to.get(), navigateTo_, origin)) {
                    return false;
                }
            }
            // if unsafe-allow-redirects is present, check `redirectedTo` in redirect or maybe-redirect cases
            if (redirected.orElse(true)) {
                if (!redirectedTo.isPresent()) {
                    return false;
                }
                if (!doesUrlMatchSourceListInOrigin(redirectedTo.get(), navigateTo_, origin)) {
                    return false;
                }
            }
        }
        else {
            // if unsafe-allow-redirects is absent, always and only check `to`
            if (!to.isPresent()) {
                return false;
            }
            if (!doesUrlMatchSourceListInOrigin(to.get(), navigateTo_, origin)) {
                return false;
            }
        }
        return true;
    }

    // https://w3c.github.io/webappsec-csp/#navigate-to-pre-navigate
    // https://w3c.github.io/webappsec-csp/#navigate-to-navigation-response
    // Note: it is nonsensical to provide redirectedTo if redirected is Optional.of(false)
    public boolean allowsFormAction(final Optional to, final Optional redirected,
            final Optional redirectedTo, final Optional origin) {
        if (sandbox_ != null && !sandbox_.allowForms()) {
            return false;
        }
        if (formAction_ != null) {
            if (!to.isPresent()) {
                return false;
            }
            if (!doesUrlMatchSourceListInOrigin(to.get(), formAction_, origin)) {
                return false;
            }
            return true;
        }
        // this isn't implemented like other fallbacks because
        // it isn't one: form-action does not respect unsafe-allow-redirects
        return allowsNavigation(to, redirected, redirectedTo, origin);
    }

    // NB: the hashes (for unsafe-hashes) are supposed to include the javascript: part, per spec
    public boolean allowsJavascriptUrlNavigation(final Optional source, final Optional origin) {
        return allowsNavigation(
                Optional.of(
                            new GUID("javascript", source.orElse(""))),
                Optional.of(false), Optional.empty(), origin)
                &&
                    doesElementMatchSourceListForTypeAndSource(
                                InlineType.Navigation, Optional.empty(),
                                            source.map(s -> "javascript:" + s), Optional.of(false));
    }

    public boolean allowsExternalStyle(final Optional nonce,
            final Optional styleUrl, final Optional origin) {
        // Effective directive is "script-src-elem" per
        // https://w3c.github.io/webappsec-csp/#effective-directive-for-a-request
        final SourceExpressionDirective directive
                = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.StyleSrcElem).orElse(null);
        if (directive == null) {
            return true;
        }
        if (nonce.isPresent()) {
            final String actualNonce = nonce.get();
            if (actualNonce.length() > 0
                    && directive.getNonces().stream().anyMatch(n -> n.getBase64ValuePart().equals(actualNonce))) {
                return true;
            }
        }
        // integrity is not used: https://github.com/w3c/webappsec-csp/issues/430
        if (styleUrl.isPresent()) {
            return doesUrlMatchSourceListInOrigin(styleUrl.get(), directive, origin);
        }
        return false;
    }

    public boolean allowsInlineStyle(final Optional nonce, final Optional source) {
        return doesElementMatchSourceListForTypeAndSource(InlineType.Style, nonce, source, Optional.empty());
    }

    public boolean allowsStyleAsAttribute(final Optional source) {
        return doesElementMatchSourceListForTypeAndSource(
                InlineType.StyleAttribute, Optional.empty(), source, Optional.empty());
    }

    public boolean allowsFrame(final Optional source, final Optional origin) {
        final SourceExpressionDirective sourceList
            = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.FrameSrc).orElse(null);
        if (sourceList == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        return doesUrlMatchSourceListInOrigin(source.get(), sourceList, origin);
    }

    public boolean allowsFrameAncestor(final Optional source, final Optional origin) {
        if (frameAncestors_ == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        return doesUrlMatchSourceListInOrigin(source.get(), frameAncestors_, origin);
    }

    // This assumes that a `ws:` or `wss:` URL is being used with `new WebSocket` specifically
    public boolean allowsConnection(final Optional source, final Optional origin) {
        final SourceExpressionDirective sourceList
                = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.ConnectSrc).orElse(null);
        if (sourceList == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        // See https://fetch.spec.whatwg.org/#concept-websocket-establish
        // Also browsers don't implement this; see https://github.com/w3c/webappsec-csp/issues/429
        final URLWithScheme actualSource = source.get();
        final String scheme = actualSource.getScheme();
        URLWithScheme usedSource = actualSource;
        if (actualSource instanceof URI) {
            if ("ws".equals(scheme)) {
                usedSource = new URI("http", actualSource.getHost(), actualSource.getPort(), actualSource.getPath());
            }
            else if ("wss".equals(scheme)) {
                usedSource = new URI("https", actualSource.getHost(), actualSource.getPort(), actualSource.getPath());
            }
        }

        return doesUrlMatchSourceListInOrigin(usedSource, sourceList, origin);
    }

    public boolean allowsFont(final Optional source, final Optional origin) {
        final SourceExpressionDirective sourceList
                = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.FontSrc).orElse(null);
        if (sourceList == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        return doesUrlMatchSourceListInOrigin(source.get(), sourceList, origin);
    }

    public boolean allowsImage(final Optional source, final Optional origin) {
        final SourceExpressionDirective sourceList
                = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.ImgSrc).orElse(null);
        if (sourceList == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        return doesUrlMatchSourceListInOrigin(source.get(), sourceList, origin);
    }

    public boolean allowsApplicationManifest(final Optional source,
                    final Optional origin) {
        final SourceExpressionDirective sourceList
                = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.ManifestSrc).orElse(null);
        if (sourceList == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        return doesUrlMatchSourceListInOrigin(source.get(), sourceList, origin);
    }

    public boolean allowsMedia(final Optional source, final Optional origin) {
        final SourceExpressionDirective sourceList
                = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.MediaSrc).orElse(null);
        if (sourceList == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        return doesUrlMatchSourceListInOrigin(source.get(), sourceList, origin);
    }

    public boolean allowsObject(final Optional source, final Optional origin) {
        final SourceExpressionDirective sourceList
                = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.ObjectSrc).orElse(null);
        if (sourceList == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        return doesUrlMatchSourceListInOrigin(source.get(), sourceList, origin);
    }

    // Not actually spec'd properly; see https://github.com/whatwg/fetch/issues/1008
    public boolean allowsPrefetch(final Optional source, final Optional origin) {
        final SourceExpressionDirective sourceList
                = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.PrefetchSrc).orElse(null);
        if (sourceList == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        return doesUrlMatchSourceListInOrigin(source.get(), sourceList, origin);
    }

    public boolean allowsWorker(final Optional source, final Optional origin) {
        final SourceExpressionDirective sourceList
                = getGoverningDirectiveForEffectiveDirective(FetchDirectiveKind.WorkerSrc).orElse(null);
        if (sourceList == null) {
            return true;
        }
        if (!source.isPresent()) {
            return false;
        }
        return doesUrlMatchSourceListInOrigin(source.get(), sourceList, origin);
    }

    public boolean allowsPlugin(final Optional mediaType) {
        if (pluginTypes_ == null) {
            return true;
        }
        if (!mediaType.isPresent()) {
            return false;
        }
        return pluginTypes_.getMediaTypes().contains(mediaType.get());
    }

    // https://w3c.github.io/webappsec-csp/#should-directive-execute
    public Optional getGoverningDirectiveForEffectiveDirective(
                                                final FetchDirectiveKind kind) {
        for (final FetchDirectiveKind candidate : FetchDirectiveKind.getFetchDirectiveFallbackList(kind)) {
            final SourceExpressionDirective list = fetchDirectives_.get(candidate);
            if (list != null) {
                return Optional.of(list);
            }
        }
        return Optional.empty();
    }

    // https://w3c.github.io/webappsec-csp/#directive-inline-check
    // https://w3c.github.io/webappsec-csp/#should-block-inline specifies the first four values
    // https://w3c.github.io/webappsec-csp/#should-block-navigation-request
    // specifies "navigation", used for `javascript:` urls
    // https://w3c.github.io/webappsec-csp/#effective-directive-for-inline-check
    private enum InlineType {
        Script(FetchDirectiveKind.ScriptSrcElem),
        ScriptAttribute(FetchDirectiveKind.ScriptSrcAttr),
        Style(FetchDirectiveKind.StyleSrcElem),
        StyleAttribute(FetchDirectiveKind.StyleSrcAttr),
        Navigation(FetchDirectiveKind.ScriptSrcElem);

        private final FetchDirectiveKind effectiveDirective_;

        InlineType(final FetchDirectiveKind effectiveDirective) {
            effectiveDirective_ = effectiveDirective;
        }
    }

    // Note: this assumes the element is nonceable. See https://w3c.github.io/webappsec-csp/#is-element-nonceable
    // https://w3c.github.io/webappsec-csp/#match-element-to-source-list
    private boolean doesElementMatchSourceListForTypeAndSource(final InlineType type,
                        final Optional nonce, final Optional source,
                        final Optional parserInserted) {
        final SourceExpressionDirective directive
                = getGoverningDirectiveForEffectiveDirective(type.effectiveDirective_).orElse(null);
        if (directive == null) {
            return true;
        }
        // https://w3c.github.io/webappsec-csp/#allow-all-inline
        final boolean allowAllInline
                    = directive.getNonces().isEmpty()
                        && directive.getHashes().isEmpty()
                        && !((type == InlineType.Script
                                    || type == InlineType.ScriptAttribute
                                    || type == InlineType.Navigation)
                        && directive.strictDynamic())
                        && directive.unsafeInline();
        if (allowAllInline) {
            return true;
        }
        if (nonce.isPresent()) {
            final String actualNonce = nonce.get();
            if (actualNonce.length() > 0
                    && directive.getNonces().stream().anyMatch(n -> n.getBase64ValuePart().equals(actualNonce))) {
                return true;
            }
        }
        if (source.isPresent()
                && !directive.getHashes().isEmpty()
                && (type == InlineType.Script || type == InlineType.Style || directive.unsafeHashes())) {
            final byte[] actualSource = source.get().getBytes(StandardCharsets.UTF_8);
            final Base64.Encoder base64encoder = Base64.getEncoder();
            String actualSha256 = null;
            String actualSha384 = null;
            String actualSha512 = null;
            try {
                for (final Hash hash : directive.getHashes()) {
                    switch (hash.getAlgorithm()) {
                        case SHA256:
                            if (actualSha256 == null) {
                                actualSha256 = base64encoder.encodeToString(
                                                    MessageDigest.getInstance("SHA-256").digest(actualSource));
                            }
                            if (actualSha256.equals(normalizeBase64Url(hash.getBase64ValuePart()))) {
                                return true;
                            }
                            break;
                        case SHA384:
                            if (actualSha384 == null) {
                                actualSha384 = base64encoder.encodeToString(
                                                    MessageDigest.getInstance("SHA-384").digest(actualSource));
                            }
                            if (actualSha384.equals(normalizeBase64Url(hash.getBase64ValuePart()))) {
                                return true;
                            }
                            break;
                        case SHA512:
                            if (actualSha512 == null) {
                                actualSha512 = base64encoder.encodeToString(
                                                    MessageDigest.getInstance("SHA-512").digest(actualSource));
                            }
                            if (actualSha512.equals(normalizeBase64Url(hash.getBase64ValuePart()))) {
                                return true;
                            }
                            break;
                        default:
                            throw new IllegalArgumentException("Unknown hash algorithm " + hash.getAlgorithm());
                    }
                }
            }
            catch (final NoSuchAlgorithmException e) {
                throw new RuntimeException(e);
            }
        }

        // This is not per spec, but matches implementations and the spec
        // author's intent: https://github.com/w3c/webappsec-csp/issues/426
        if (type == InlineType.Script && directive.strictDynamic() && !parserInserted.orElse(true)) {
            return true;
        }
        return false;
    }

    private static String normalizeBase64Url(final String input) {
        return input.replace('-', '+').replace('_', '/');
    }

    // https://w3c.github.io/webappsec-csp/#match-url-to-source-list
    public static boolean doesUrlMatchSourceListInOrigin(final URLWithScheme url,
            final HostSourceDirective list, final Optional origin) {
        final String urlScheme = url.getScheme();
        if (list.star()) {
            // https://fetch.spec.whatwg.org/#network-scheme
            // Note that "ws" and "wss" are _not_ network schemes
            if (Objects.equals(urlScheme, "ftp")
                        || Objects.equals(urlScheme, "http")
                        || Objects.equals(urlScheme, "https")) {
                return true;
            }
            if (origin.isPresent() && Objects.equals(urlScheme, origin.get().getScheme())) {
                return true;
            }
        }
        for (final Scheme scheme : list.getSchemes()) {
            if (schemePartMatches(scheme.getValue(), urlScheme)) {
                return true;
            }
        }
        for (final Host expression : list.getHosts()) {
            final String scheme = expression.getScheme();
            if (scheme != null) {
                if (!schemePartMatches(scheme, urlScheme)) {
                    continue;
                }
            }
            else {
                if (!origin.isPresent() || !schemePartMatches(origin.get().getScheme(), urlScheme)) {
                    continue;
                }
            }
            if (url.getHost() == null) {
                continue;
            }
            if (!hostPartMatches(expression.getHost(), url.getHost())) {
                continue;
            }
            // url.port is non-null whenever url.host is
            if (!portPartMatches(expression.getPort(), url.getPort(), urlScheme)) {
                continue;
            }
            if (!pathPartMatches(expression.getPath(), url.getPath())) {
                continue;
            }
            return true;
        }
        if (list.self()) {
            if (origin.isPresent()) {
                final URLWithScheme actualOrigin = origin.get();
                final String originScheme = actualOrigin.getScheme();
                if (
                        Objects.equals(actualOrigin.getHost(), url.getHost())
                        && (Objects.equals(actualOrigin.getPort(), url.getPort())
                                    || Objects.equals(actualOrigin.getPort(), URI.defaultPortForProtocol(originScheme))
                                    && Objects.equals(url.getPort(), URI.defaultPortForProtocol(urlScheme)))
                        && ("https".equals(urlScheme)
                                || "wss".equals(urlScheme)
                                || "http".equals(originScheme)
                                && ("http".equals(urlScheme) || "ws".equals(urlScheme)))
                ) {
                    return true;
                }
            }
        }
        return false;
    }

    // https://w3c.github.io/webappsec-csp/#scheme-part-match
    private static boolean schemePartMatches(final String a, final String b) {
        // Assumes inputs are already lowcased
        return a.equals(b)
                || "http".equals(a) && "https".equals(b)
                || "ws".equals(a) && ("wss".equals(b) || "http".equals(b) || "https".equals(b))
                || "wss".equals(a) && "https".equals(b);
    }

    // https://w3c.github.io/webappsec-csp/#host-part-match
    private static boolean hostPartMatches(final String a, final String b) {
        if (a.startsWith("*")) {
            final String remaining = a.substring(1);
            return b.toLowerCase(Locale.ROOT).endsWith(remaining.toLowerCase(Locale.ROOT));
        }

        if (!a.equalsIgnoreCase(b)) {
            return false;
        }

        final Matcher ipv4Matcher = Constants.IPv4address.matcher(a);
        final Matcher ipv6Matcher = Constants.IPv6addressWithOptionalBracket.matcher(a);
        final Matcher ipv6LoopbackMatcher = Constants.IPV6loopback.matcher(a);
        if ((ipv4Matcher.find() && !"127.0.0.1".equals(a)) || ipv6Matcher.find() || ipv6LoopbackMatcher.find()) {
            return false;
        }
        return true;
    }

    // https://w3c.github.io/webappsec-csp/#port-part-matches
    private static boolean portPartMatches(final int a, final int portB, final String schemeB) {
        if (a == Constants.EMPTY_PORT) {
            return portB == URI.defaultPortForProtocol(schemeB);
        }
        if (a == Constants.WILDCARD_PORT) {
            return true;
        }
        if (a == portB) {
            return true;
        }
        if (portB == Constants.EMPTY_PORT) {
            return a == URI.defaultPortForProtocol(schemeB);
        }
        return false;
    }

    // https://w3c.github.io/webappsec-csp/#path-part-match
    private static boolean pathPartMatches(String pathA, String pathB) {
        if (pathA == null) {
            pathA = "";
        }
        if (pathB == null) {
            pathB = "";
        }

        if (pathA.isEmpty()) {
            return true;
        }

        if ("/".equals(pathA) && pathB.isEmpty()) {
            return true;
        }

        final boolean exactMatch = !pathA.endsWith("/");

        final List pathListA = Utils.strictlySplit(pathA, '/');
        final List pathListB = Utils.strictlySplit(pathB, '/');

        if (pathListA.size() > pathListB.size()) {
            return false;
        }

        if (exactMatch && pathListA.size() != pathListB.size()) {
            return false;
        }

        if (!exactMatch) {
            pathListA.remove(pathListA.size() - 1);
        }

        final Iterator it1 = pathListA.iterator();
        final Iterator it2 = pathListB.iterator();

        while (it1.hasNext()) {
            final String a = Utils.decodeString(it1.next());
            final String b = Utils.decodeString(it2.next());
            if (!a.equals(b)) {
                return false;
            }
        }
        return true;
    }

    // Utilities and helper classes

    static void enforceAscii(final String s) {
        if (!StandardCharsets.US_ASCII.newEncoder().canEncode(s)) {
            throw new IllegalArgumentException("string is not ascii: \"" + s + "\"");
        }
    }

    private static String stripLeadingWhitespace(final String string) {
        return Constants.LEADING_WHITESPACE_PATTERN.matcher(string).replaceFirst("");
    }

    private static String stripTrailingWhitespace(final String string) {
        return Constants.TRAILING_WHITESPACE_PATTERN.matcher(string).replaceAll("");
    }

    private static String collect(final String input, final String regex) {
        final Matcher matcher = Pattern.compile(regex).matcher(input);
        if (!matcher.find() || matcher.start() != 0) {
            return "";
        }
        return input.substring(0, matcher.end());
    }

    private static final class NamedDirective {
        private final String name_;
        private final Directive directive_;

        private NamedDirective(final String name, final Directive directive) {
            name_ = name;
            directive_ = directive;
        }
    }

    // Info: strictly informative
    // Warning: it matches the grammar, but is meaningless, duplicated, or otherwise problematic
    // Error: it does not match the grammar
    public enum Severity {
            /** Severity Info. */
            Info,
            /** Severity Warning. */
            Warning,
            /** Severity Error. */
            Error }

    @FunctionalInterface
    public interface PolicyErrorConsumer {
        // valueIndex = -1 for errors not pertaining to a value
        void add(Severity severity, String message, int directiveIndex, int valueIndex);

        /** PolicyErrorConsumer ignored. */
        PolicyErrorConsumer ignored = (severity, message, directiveIndex, valueIndex) -> { };
    }

    @FunctionalInterface
    public interface PolicyListErrorConsumer {
        // valueIndex = -1 for errors not pertaining to a value
        void add(Severity severity, String message, int policyIndex, int directiveIndex, int valueIndex);

        /** PolicyListErrorConsumer ignored. */
        PolicyListErrorConsumer ignored = (severity, message, policyIndex, directiveIndex, valueIndex) -> { };
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy