AXLR / Catalog / security

csp_lint

Lint a Content-Security-Policy header value with CSP Level 3 semantics and the checks of Google's CSP Evaluator: unsafe-inline and unsafe-eval in scripts, wildcards, http:, https: and data: sources, missing object-src, base-uri, script-src, default-src and frame-ancestors, known allowlist-bypass hosts, short nonces, strict-dynamic and nonce/hash interplay, deprecated, unknown and misspelt directives, missing semicolons, reporting and report-only or meta delivery notes. Each finding has a severity and a fix.

Install

/tools install csp_lint
/tools install csp_lint@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · csp_lint 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool csp_lint; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
policystringyesThe header value; a leading 'Content-Security-Policy:' is ignored; commas separate policies. length ≤ 100000
deliverystringno"header"one of "header", "report-only", "meta"
Input schema (JSON)
{
  "type": "object",
  "required": [
    "policy"
  ],
  "additionalProperties": false,
  "properties": {
    "policy": {
      "type": "string",
      "maxLength": 100000,
      "description": "The header value; a leading 'Content-Security-Policy:' is ignored; commas separate policies"
    },
    "delivery": {
      "type": "string",
      "enum": [
        "header",
        "report-only",
        "meta"
      ],
      "default": "header"
    }
  }
}

Example

Input

{
  "policy": "script-src 'unsafe-inline'"
}

Output (stdout)

{
  "strict_csp": false,
  "effective_script_directive": "script-src",
  "counts": {
    "high": 2,
    "medium": 2,
    "syntax": 0,
    "low": 1,
    "info": 2
  }
}

Files

The command the install card registers is python3 csp_lint.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

csp_lint.py · sha256 b0562499aafca32bfb004624abf2d23720daf1f5922862e3357d703d36e9784e
#!/usr/bin/env python3
"""Lint a Content-Security-Policy header value: parse its directives and report the weaknesses
Google's CSP Evaluator checks for (unsafe-inline and unsafe-eval in scripts, wildcards and
plain schemes, data: in script-src, missing object-src, base-uri, script-src or default-src,
known allowlist bypass hosts, short nonces, deprecated and unknown directives, missing
reporting) plus missing frame-ancestors, with CSP Level 3 semantics: a nonce or hash cancels
'unsafe-inline', and 'strict-dynamic' cancels host and scheme sources in script-src.

Input (JSON on stdin):
  policy    the header value (several policies separated by commas are linted one by one)
  delivery  "header" (default) | "report-only" | "meta"
"""
import json
import re
import sys

FETCH = ["child-src", "connect-src", "default-src", "font-src", "frame-src", "img-src", "manifest-src", "media-src",
         "object-src", "prefetch-src", "script-src", "script-src-elem", "script-src-attr", "style-src", "style-src-elem",
         "style-src-attr", "worker-src", "fenced-frame-src"]
OTHER = ["base-uri", "sandbox", "form-action", "frame-ancestors", "navigate-to", "report-uri", "report-to",
         "require-trusted-types-for", "trusted-types", "upgrade-insecure-requests", "block-all-mixed-content",
         "require-sri-for", "webrtc", "plugin-types", "reflected-xss", "referrer", "disown-opener"]
DEPRECATED = {"report-uri": "deprecated in CSP Level 3 in favour of report-to; keep both for older browsers",
              "block-all-mixed-content": "obsolete: browsers block or upgrade mixed content anyway; use upgrade-insecure-requests",
              "plugin-types": "removed from CSP Level 3; plugins are gone, set object-src 'none'",
              "reflected-xss": "removed, never widely supported; it does nothing",
              "referrer": "removed; use the Referrer-Policy header",
              "prefetch-src": "removed from the specification",
              "navigate-to": "removed from the specification",
              "require-sri-for": "never shipped in browsers",
              "disown-opener": "never shipped; use Cross-Origin-Opener-Policy"}
KEYWORDS = {"'self'", "'none'", "'unsafe-inline'", "'unsafe-eval'", "'strict-dynamic'", "'unsafe-hashes'",
            "'report-sample'", "'unsafe-allow-redirects'", "'wasm-unsafe-eval'", "'inline-speculation-rules'"}
# Hosts that serve JSONP endpoints or old AngularJS/libraries, letting an attacker run script
# through an allowlisted origin (CSP Evaluator's allowlist bypass lists, abridged).
BYPASS_HOSTS = ["www.google.com", "google.com", "*.google.com", "www.googleapis.com", "ajax.googleapis.com",
                "*.googleapis.com", "accounts.google.com", "www.gstatic.com", "*.gstatic.com", "cdnjs.cloudflare.com",
                "cdn.jsdelivr.net", "unpkg.com", "*.yandex.ru", "api.twitter.com", "*.twitter.com", "www.youtube.com",
                "*.facebook.com", "connect.facebook.net", "*.vk.com", "cse.google.com", "translate.googleapis.com",
                "*.amazonaws.com", "*.cloudfront.net", "raw.githubusercontent.com", "*.github.io", "cdn.rawgit.com"]
SCRIPT = ["script-src", "script-src-elem", "script-src-attr"]


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def parse(policy_text):
    directives, order, findings = {}, [], []
    for raw in policy_text.split(";"):
        tokens = raw.split()
        if not tokens:
            continue
        name = tokens[0].lower()
        values = tokens[1:]
        if not re.fullmatch(r"[a-z0-9-]+", name):
            findings.append(finding("syntax", "invalid_directive_name", name, None, f"{tokens[0]!r} is not a valid directive name.", "Remove it or fix the typo."))
            continue
        if name in directives:
            findings.append(finding("syntax", "duplicate_directive", name, None, f"{name} appears twice; browsers ignore every occurrence after the first.", "Merge the values into one directive."))
            continue
        directives[name] = values
        order.append(name)
    return directives, order, findings


def finding(severity, check, directive, value, description, fix):
    return {"severity": severity, "check": check, "directive": directive, "value": value, "description": description, "fix": fix}


def is_nonce(source):
    return source.lower().startswith("'nonce-") and source.endswith("'")


def is_hash(source):
    return re.fullmatch(r"'(sha256|sha384|sha512)-[A-Za-z0-9+/_-]+={0,2}'", source, re.I) is not None


def host_of(source):
    match = re.fullmatch(r"(?:([a-z][a-z0-9+.-]*)://)?(\*|\*\.[^:/]+|[^:/*]+)(?::(\d+|\*))?(/.*)?", source, re.I)
    return (match.group(2).lower(), (match.group(1) or "").lower()) if match else (None, None)


def lint(policy_text, delivery):
    directives, order, findings = parse(policy_text)

    def add(*args):
        findings.append(finding(*args))

    for name in order:
        if name not in FETCH and name not in OTHER:
            add("syntax", "unknown_directive", name, None, f"{name} is not a CSP directive; browsers ignore it.", "Check the spelling against the CSP Level 3 directive list.")
        if name in DEPRECATED:
            add("info", "deprecated_directive", name, None, f"{name}: {DEPRECATED[name]}.", "Replace or remove it.")
        for value in directives[name]:
            low = value.lower()
            if low in {k.strip("'") for k in KEYWORDS} or low.startswith(("nonce-", "sha256-", "sha384-", "sha512-")):
                add("syntax", "keyword_without_quotes", name, value, f"{value} without single quotes is read as a host name, not a keyword.", f"Write '{value}'.")
            elif value.startswith("'") and low not in KEYWORDS and not is_nonce(value) and not is_hash(value) and name not in ("sandbox", "trusted-types", "require-trusted-types-for"):
                add("syntax", "invalid_keyword", name, value, f"{value} is not a CSP keyword.", "Check the spelling ('self', 'none', 'strict-dynamic', 'nonce-…').")
            if low.rstrip(":") in FETCH + OTHER and not low.endswith(":"):
                add("syntax", "missing_semicolon", name, value, f"{value} looks like a directive name inside {name}: a semicolon is probably missing.", "Separate directives with ';'.")
        if name in FETCH and "'none'" in [v.lower() for v in directives[name]] and len(directives[name]) > 1:
            add("syntax", "none_with_sources", name, "'none'", f"'none' together with other sources in {name}: 'none' is ignored.", "Use either 'none' alone or a list of sources.")

    default = directives.get("default-src")
    script_name = "script-src" if "script-src" in directives else "default-src" if default is not None else None
    script = directives.get(script_name, []) if script_name else []
    lower = [v.lower() for v in script]
    has_nonce = any(is_nonce(v) for v in script)
    has_hash = any(is_hash(v) for v in script)
    strict_dynamic = "'strict-dynamic'" in lower

    # Missing directives (CSP Evaluator checkMissingDirectives) and frame-ancestors.
    if script_name is None:
        add("high", "missing_script_src", "script-src", None, "Neither script-src nor default-src: any script from anywhere may run.", "Add script-src with nonces or hashes and 'strict-dynamic' (a strict CSP).")
    if "object-src" not in directives and not (default is not None and [v.lower() for v in default] == ["'none'"]):
        add("high", "missing_object_src", "object-src", None, "No object-src (and default-src is not 'none'): plugins such as Flash could load and run script.", "Add object-src 'none'.")
    if "base-uri" not in directives:
        severity = "high" if (has_nonce or has_hash or strict_dynamic) else "low"
        add(severity, "missing_base_uri", "base-uri", None, "No base-uri: an injected <base> tag can redirect relative script URLs to the attacker's host (it defeats nonces).", "Add base-uri 'none' (or 'self').")
    if default is None:
        add("medium", "missing_default_src", "default-src", None, "No default-src: every fetch directive not listed (connect-src, img-src, frame-src…) is unrestricted.", "Add default-src 'self' (or 'none') as the fallback.")
    if "frame-ancestors" not in directives:
        add("medium", "missing_frame_ancestors", "frame-ancestors", None, "No frame-ancestors: the page can be framed by any site (clickjacking). default-src does not cover it.", "Add frame-ancestors 'none' (or 'self').")
    if "report-uri" not in directives and "report-to" not in directives:
        add("info", "no_reporting", "report-to", None, "No report-uri or report-to: violations are not reported.", "Add report-to (and report-uri for older browsers).")
    if "require-trusted-types-for" not in directives:
        add("info", "no_trusted_types", "require-trusted-types-for", None, "Trusted Types are not required: DOM XSS sinks accept plain strings.", "Consider require-trusted-types-for 'script'.")

    # Script sources with CSP3 semantics.
    for name in [n for n in SCRIPT if n in directives] or ([script_name] if script_name else []):
        values = directives[name]
        low = [v.lower() for v in values]
        nonce = any(is_nonce(v) for v in values) or (name != script_name and has_nonce)
        hashed = any(is_hash(v) for v in values) or (name != script_name and has_hash)
        dynamic = "'strict-dynamic'" in low
        if "'unsafe-inline'" in low:
            if nonce or hashed:
                add("info", "unsafe_inline_ignored", name, "'unsafe-inline'", "'unsafe-inline' is ignored by CSP2+ browsers because a nonce or hash is present; it only serves old browsers.", "Keep it only as a fallback for CSP1 browsers.")
            else:
                add("high", "script_unsafe_inline", name, "'unsafe-inline'", "'unsafe-inline' allows inline <script> and event handlers: injected markup runs.", "Remove it and use nonces or hashes.")
        if "'unsafe-eval'" in low:
            add("medium", "script_unsafe_eval", name, "'unsafe-eval'", "'unsafe-eval' allows code built from strings (eval, new Function, setTimeout with a string), a common DOM XSS path.", "Remove it if the code does not need string evaluation ('wasm-unsafe-eval' is enough for WebAssembly).")
        if "'unsafe-hashes'" in low:
            add("medium", "script_unsafe_hashes", name, "'unsafe-hashes'", "'unsafe-hashes' allows hashed inline event handlers, which can be reused by injected markup.", "Move handlers to scripts with addEventListener.")
        if dynamic and not (nonce or hashed):
            add("info", "strict_dynamic_without_nonce", name, "'strict-dynamic'", "'strict-dynamic' without a nonce or hash: no script can load at all.", "Add a nonce or hashes.")
        for value in values:
            lv = value.lower()
            ignored = dynamic and (nonce or hashed) and (lv in ("'self'", "*") or lv.endswith(":") and lv != "data:" or host_of(lv)[0] is not None and not lv.startswith("'"))
            if lv == "data:":
                add("high", "script_data_scheme", name, value, "data: in a script directive lets an attacker supply the script as a data: URL.", "Remove data: from script sources.")
            elif ignored:
                continue
            elif lv == "*":
                add("high", "script_wildcard", name, value, "* allows scripts from any host.", "Use nonces or hashes with 'strict-dynamic' instead of host lists.")
            elif lv in ("https:", "http:"):
                add("high", "script_plain_scheme", name, value, f"{value} allows scripts from any host over that scheme.", "Use nonces or hashes with 'strict-dynamic'.")
            elif lv in ("blob:", "filesystem:"):
                add("medium", "script_blob_scheme", name, value, f"{value} allows scripts from locally created URLs.", "Remove it unless the application needs it.")
            elif not lv.startswith("'") and not lv.endswith(":"):
                host, scheme = host_of(lv)
                if host is None:
                    continue
                if host.startswith("*."):
                    add("medium", "script_wildcard_host", name, value, f"{value} allows every subdomain of {host[2:]}.", "List the exact hosts, or move to nonces.")
                if host in BYPASS_HOSTS or any(b.startswith("*.") and host.endswith(b[1:]) for b in BYPASS_HOSTS):
                    add("high", "script_allowlist_bypass", name, value, f"{value} hosts JSONP endpoints or libraries (AngularJS and others) known to bypass CSP allowlists.", "Remove it; load scripts with nonces and 'strict-dynamic'.")
                if scheme == "http":
                    add("medium", "script_http_source", name, value, f"{value} loads script over plain HTTP: a network attacker can replace it.", "Use https://.")
                if re.fullmatch(r"\d+\.\d+\.\d+\.\d+", host):
                    add("info", "ip_source", name, value, "An IP address as a source: it cannot be checked for allowlist bypasses and 127.0.0.1 is the attacker's own machine.", "Use host names.")
        for value in values:
            if is_nonce(value):
                nonce_value = value[7:-1]
                if len(nonce_value) < 8:
                    add("medium", "short_nonce", name, "'nonce-…'", f"The nonce has {len(nonce_value)} characters; it should be at least 128 bits of randomness (22+ base64 characters), new on every response.", "Generate a fresh random nonce of 16+ bytes per response.")
                elif not re.fullmatch(r"[A-Za-z0-9+/_-]+={0,2}", nonce_value):
                    add("info", "nonce_charset", name, "'nonce-…'", "The nonce is not base64: browsers match it as text, but the specification expects base64.", "Use base64 nonces.")
        if not (nonce or hashed) and any(not v.startswith("'") for v in values) and "'none'" not in low:
            add("info", "host_allowlist", name, None, "The script policy relies on a host allowlist; such lists are bypassable on most sites (Weichselbaum et al., CCS 2016).", "Prefer a strict CSP: nonces or hashes with 'strict-dynamic'.")
        if (nonce or hashed) and "'unsafe-inline'" not in low and name == script_name:
            add("info", "no_unsafe_inline_fallback", name, None, "Nonce or hash without 'unsafe-inline': CSP1 browsers will block all inline scripts.", "Add 'unsafe-inline' as a fallback (CSP2+ browsers ignore it).")
        if dynamic and not any(v in low for v in ("https:", "http:")) and name == script_name:
            add("info", "no_allowlist_fallback", name, None, "'strict-dynamic' without https: fallback: browsers without CSP3 will block scripts that are not nonced.", "Add https: as a fallback (ignored by CSP3 browsers).")

    # Wildcards and schemes in object-src and base-uri.
    for name in ("object-src", "base-uri"):
        values = directives.get(name, default if name == "object-src" else None) or []
        for value in values:
            lv = value.lower()
            if lv == "*":
                add("high", f"{name.split('-')[0]}_wildcard", name, value, f"* in {name} allows any origin.", f"Use {name} 'none'.")
            elif lv in ("https:", "http:", "data:"):
                add("high", f"{name.split('-')[0]}_plain_scheme", name, value, f"{value} in {name} allows any host with that scheme.", f"Use {name} 'none'.")
    for name in ("style-src",):
        values = [v.lower() for v in directives.get(name, default or [])]
        if "'unsafe-inline'" in values:
            add("low", "style_unsafe_inline", name, "'unsafe-inline'", "'unsafe-inline' in styles allows CSS injection (data exfiltration through selectors, UI redress).", "Use nonces or hashes for styles where possible.")
    for name, values in directives.items():
        for value in values:
            if value.lower().startswith("http://") and name not in SCRIPT:
                add("low", "http_source", name, value, f"{value} is fetched over plain HTTP.", "Use https://.")
    if "upgrade-insecure-requests" in directives and directives["upgrade-insecure-requests"]:
        add("syntax", "unexpected_values", "upgrade-insecure-requests", None, "upgrade-insecure-requests takes no values.", "Remove the values.")

    if delivery == "report-only":
        add("info", "report_only", None, None, "Content-Security-Policy-Report-Only: nothing is blocked, violations are only reported; sandbox is ignored in report-only policies.", "Enforce the policy with the Content-Security-Policy header once the reports are clean.")
        if "report-uri" not in directives and "report-to" not in directives:
            add("medium", "report_only_without_reporting", None, None, "A report-only policy without report-uri or report-to does nothing at all.", "Add report-to or report-uri.")
    elif delivery == "meta":
        for name in ("frame-ancestors", "report-uri", "sandbox"):
            if name in directives:
                add("medium", "ignored_in_meta", name, None, f"{name} is ignored in a <meta> policy (CSP3 section 3.3).", "Send it in the HTTP header.")
    rank = {"high": 0, "medium": 1, "syntax": 2, "low": 3, "info": 4}
    findings.sort(key=lambda f: rank[f["severity"]])
    strict = (has_nonce or has_hash) and "object-src" in directives and "base-uri" in directives and not any(f["severity"] == "high" for f in findings)
    return {"directives": {name: directives[name] for name in order}, "effective_script_directive": script_name,
            "strict_csp": strict, "nonce": has_nonce, "hashes": has_hash, "strict_dynamic": strict_dynamic, "findings": findings,
            "counts": {s: sum(f["severity"] == s for f in findings) for s in rank}}


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("stdin must be a JSON object")
    unknown = sorted(set(request) - {"policy", "delivery"})
    if unknown:
        fail(f"unknown field: {unknown[0]}")
    policy = request.get("policy")
    if not isinstance(policy, str) or not policy.strip():
        fail("policy is required: the Content-Security-Policy header value")
    if len(policy) > 100_000:
        fail("policy is longer than 100000 characters")
    policy = re.sub(r"^\s*content-security-policy(-report-only)?\s*:\s*", "", policy, flags=re.I)
    delivery = request.get("delivery", "header")
    if delivery not in ("header", "report-only", "meta"):
        fail("delivery must be header, report-only or meta")
    policies = [p for p in policy.split(",") if p.strip()]
    results = [lint(p, delivery) for p in policies]
    if len(results) == 1:
        output = dict(results[0])
    else:
        output = {"policies": results, "note": "Several policies: a resource must pass every one of them."}
    output["delivery"] = delivery
    output["severity_scale"] = "high, medium, syntax, low, info (after CSP Evaluator's HIGH, MEDIUM, SYNTAX and INFO)"
    print(json.dumps(output))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/csp_lint/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/csp_lint/1.0.0/csp_lint.py