A page rarely loses control because JavaScript exists. It loses control because some particular JavaScript gets to execute.

That sounds obvious until a script-src header turns into a long domain list:

Content-Security-Policy: script-src 'self' https://cdn.example https://analytics.example

Now the policy reads like network perimeter equipment. It is not. For scripts, Content Security Policy is closer to an execution guest list. The question is not “is this host generally reputable?” The question is:

does this script execution event match a source expression in this policy?

That distinction matters because CSP has several kinds of source expression, and they are not interchangeable. A host source says where code may come from. A nonce says this particular response intentionally marked this particular script element. A hash says this exact inline program text is expected. strict-dynamic says a nonce- or hash-blessed script may load dependencies at runtime without predeclaring their hosts.

Those are different security models hiding in one directive.

The Policy Is Not One List

The CSP3 spec says script-src restricts the locations from which scripts may be executed, including URLs in script elements and inline script blocks.1 Inline script is blocked unless the policy allows it implicitly, uses unsafe-inline, or matches a nonce or hash source.2

The grammar makes the split visible:

host-source     https://cdn.example
scheme-source   https:
keyword-source  'self' 'unsafe-inline' 'strict-dynamic'
nonce-source    'nonce-...'
hash-source     'sha256-...'

Nonces are strict string matches against the element’s nonce metadata.3 Hashes are base64-encoded digest witnesses for exact code or integrity metadata, with Subresource Integrity doing the response-body verification for external loads.4

Those two witnesses shift the policy from:

anything from this place may run

to:

this marked script, or this exact script text, may run

That shift is why modern CSP guidance is skeptical of large script host allowlists. A 2016 study by Weichselbaum, Spagnuolo, Lekies, and Janc reported that most deployed CSP policies they studied did not provide useful XSS protection, and that script allowlists were frequently bypassable through trusted-but-dangerous endpoints. The paper also proposed the strict-dynamic keyword as a way to build policies around nonces rather than domain whitelists.5

Strict Dynamic Changes The Verb

strict-dynamic is easy to remember incorrectly as:

also allow dynamically inserted scripts

That is only half the rule. In CSP3, when strict-dynamic appears in a script-src or default-src directive, host sources, scheme sources, self, and unsafe-inline are ignored for script loading; nonce and hash sources are still honored. Script requests triggered by non-parser-inserted script elements are allowed.6

So this policy:

Content-Security-Policy:
  script-src 'nonce-r4nd0m-demo-nonce' 'strict-dynamic' https:

does not mean “nonce plus any HTTPS script.” In a CSP3 browser, the https: token is a compatibility crutch for older browsers. The modern rule is:

the nonce root may execute;
runtime-created descendants may execute;
parser-inserted host matches do not rescue unmarked scripts.

This is powerful and sharp. If the nonce root creates a dependency from a constant URL, the policy can avoid brittle domain enumeration. If the nonce root creates a dependency from attacker-controlled data, the policy will happily propagate trust to the wrong place.

Lab: Who Gets To Execute?

The lab below is a small CSP source-list model. It is not a browser engine and does not set a real Content-Security-Policy header on this page. Instead it isolates the execution checks that are easy to blur:

parser-inserted external script
inline script block
inline event handler
runtime-created script element
nonce match
hash match
host/scheme match
strict-dynamic propagation

The default policy is nonce plus strict-dynamic; the default scenario is a trusted app script that creates a runtime dependency. The executable model produces:

Default metric Value
Script events considered 3
Executed 2
Blocked 1
Attacker script executed 0
Runtime-created scripts loaded 1
Loader hazards surfaced 0

Change Scenario to CDN endpoint turns hostile. Under host allowlist, the compromised parser-inserted CDN script runs. Under nonce + strict-dynamic, it does not, because host and scheme sources are ignored for parser-inserted script loading after the nonce/hash checks.

Change Scenario to tainted loader URL. Now strict-dynamic allows an attacker script, because trusted app code created a runtime script element from attacker-controlled input. That is not a CSP bypass in the model. It is the model doing exactly what the policy asked.

executed0
blocked0
attacker ranno
runtime loads0
hazardno
auditpasses

The nonce root may execute, and strict-dynamic lets its runtime-created dependency execute.

Reproducing The Model

The artifact is a plain JavaScript module:

node - <<'NODE'
const lab = require("./assets/js/csp-script-lab.js");
const EXPECTED_CRITICAL_CHECK_NAMES = [
  "SHA-256 base64 test vector",
  "strict-dynamic blocks parser-inserted CDN attacker",
  "strict-dynamic records parser-inserted block",
  "host allowlist admits compromised CDN script",
  "strict-dynamic allows trusted runtime dependency",
  "strict-dynamic dependency scenario stays non-attacker",
  "nonce-only blocks un-nonced runtime dependency",
  "unsafe-inline admits inline block and event handler",
  "hash policy admits exact inline config",
  "hash policy blocks injected inline attacker",
  "strict-dynamic propagates to tainted runtime URL",
  "tainted runtime URL is surfaced as a hazard"
];
const EXPECTED_CASE_KEYS = [
  "hostAllowlist/inlineInjection",
  "hostAllowlist/cdnCompromise",
  "hostAllowlist/dynamicDependency",
  "hostAllowlist/untrustedLoader",
  "nonceOnly/inlineInjection",
  "nonceOnly/cdnCompromise",
  "nonceOnly/dynamicDependency",
  "nonceOnly/untrustedLoader",
  "nonceStrictDynamic/inlineInjection",
  "nonceStrictDynamic/cdnCompromise",
  "nonceStrictDynamic/dynamicDependency",
  "nonceStrictDynamic/untrustedLoader",
  "hashAndSelf/inlineInjection",
  "hashAndSelf/cdnCompromise",
  "hashAndSelf/dynamicDependency",
  "hashAndSelf/untrustedLoader",
  "unsafeInline/inlineInjection",
  "unsafeInline/cdnCompromise",
  "unsafeInline/dynamicDependency",
  "unsafeInline/untrustedLoader"
];
const EXPECTED_POLICY_SHAPE = [
  "hashAndSelf:4 cases/4 checks",
  "hostAllowlist:4 cases/4 checks",
  "nonceOnly:4 cases/4 checks",
  "nonceStrictDynamic:4 cases/4 checks",
  "unsafeInline:4 cases/4 checks"
];
const EXPECTED_SCENARIO_SHAPE = [
  "cdnCompromise:5 cases/5 checks",
  "dynamicDependency:5 cases/5 checks",
  "inlineInjection:5 cases/5 checks",
  "untrustedLoader:5 cases/5 checks"
];
const EXPECTED_HASH_SOURCE =
  "'sha256-ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0='";
const EXPECTED_SUMMARY_KEYS = [
  "scripts",
  "executed",
  "blocked",
  "attackerExecuted",
  "dynamicLoaded",
  "parserInsertedBlockedByStrict",
  "eventHandlersBlocked",
  "loaderHazards"
];
const EXPECTED_DEFAULT_SUMMARY_SHAPE = [
  "scripts:3",
  "executed:2",
  "blocked:1",
  "attackerExecuted:0",
  "dynamicLoaded:1",
  "parserInsertedBlockedByStrict:0",
  "eventHandlersBlocked:0",
  "loaderHazards:0"
];
const EXPECTED_CRITICAL_CHECKS = EXPECTED_CRITICAL_CHECK_NAMES.length;
const EXPECTED_CASES = EXPECTED_CASE_KEYS.length;
const EXPECTED_CHECKS = EXPECTED_CRITICAL_CHECKS + EXPECTED_CASES;
const EXPECTED_TOTALS = {
  criticalChecks: EXPECTED_CRITICAL_CHECKS,
  cases: EXPECTED_CASES,
  checked: EXPECTED_CHECKS,
  passedChecks: EXPECTED_CHECKS,
  total: EXPECTED_CHECKS,
  totalChecks: EXPECTED_CHECKS
};
const EXPECTED_POLICY_DETAIL_SHAPE = [
  "hashAndSelf:4/4:13/5/0:0/0/0",
  "hostAllowlist:4/4:13/7/1:1/0/0",
  "nonceOnly:4/4:13/9/0:0/0/0",
  "nonceStrictDynamic:4/4:13/7/1:2/1/1",
  "unsafeInline:4/4:13/0/4:2/0/1"
];
const EXPECTED_SCENARIO_DETAIL_SHAPE = [
  "cdnCompromise:5/5:15/6/2:0/1/0",
  "dynamicDependency:5/5:15/5/0:3/0/0",
  "inlineInjection:5/5:20/11/2:0/0/0",
  "untrustedLoader:5/5:15/6/2:2/0/2"
];

function sameList(left, right) {
  return left.length === right.length &&
    left.every((value, index) => value === right[index]);
}

function sameJson(left, right) {
  return JSON.stringify(left) === JSON.stringify(right);
}

function summaryShape(summary) {
  return EXPECTED_SUMMARY_KEYS.map((key) => `${key}:${summary[key]}`);
}

function groupDetailShape(row, key) {
  return [
    row[key],
    `${row.cases}/${row.totalChecks}`,
    `${row.scripts}/${row.blocked}/${row.attackerExecuted}`,
    `${row.dynamicLoaded}/${row.parserInsertedBlockedByStrict}/${row.loaderHazards}`
  ].join(":");
}

function auditShape(audit) {
  return {
    totals: {
      criticalChecks: audit.criticalTotal,
      cases: audit.cases.length,
      checked: audit.checked,
      passedChecks: audit.passedChecks,
      total: audit.total,
      totalChecks: audit.totalChecks
    },
    criticalChecks: audit.criticalChecks.map((row) => row.check),
    cases: audit.cases.map((row) => `${row.policy}/${row.scenario}`),
    byPolicy: audit.byPolicy.map(
      (row) => `${row.policy}:${row.cases} cases/${row.totalChecks} checks`
    ),
    byScenario: audit.byScenario.map(
      (row) => `${row.scenario}:${row.cases} cases/${row.totalChecks} checks`
    ),
    byPolicyDetails: audit.byPolicy.map((row) => groupDetailShape(row, "policy")),
    byScenarioDetails: audit.byScenario.map((row) => groupDetailShape(row, "scenario"))
  };
}

const hashSource = lab.hashSourceFor("abc");
const defaultSummary = lab.evaluate({}).summary;
console.log(hashSource);
console.log(defaultSummary);
const audit = lab.auditCspScriptLab();
const shape = auditShape(audit);
const failed = audit.cases.filter(
  (row) => !row.passed || row.passedChecks !== row.totalChecks
);
const failedCriticalChecks = audit.criticalChecks.filter(
  (row) => !row.passed || row.passedChecks !== row.totalChecks
);
const shapeErrors = [
  hashSource === EXPECTED_HASH_SOURCE ? null : "hashSource",
  sameJson(summaryShape(defaultSummary), EXPECTED_DEFAULT_SUMMARY_SHAPE)
    ? null
    : "defaultSummary",
  sameJson(shape.totals, EXPECTED_TOTALS) ? null : "totals",
  sameList(shape.criticalChecks, EXPECTED_CRITICAL_CHECK_NAMES) ? null : "criticalChecks",
  sameList(shape.cases, EXPECTED_CASE_KEYS) ? null : "cases",
  sameList(shape.byPolicy, EXPECTED_POLICY_SHAPE) ? null : "byPolicy",
  sameList(shape.byScenario, EXPECTED_SCENARIO_SHAPE) ? null : "byScenario",
  sameList(shape.byPolicyDetails, EXPECTED_POLICY_DETAIL_SHAPE)
    ? null
    : "byPolicyDetails",
  sameList(shape.byScenarioDetails, EXPECTED_SCENARIO_DETAIL_SHAPE)
    ? null
    : "byScenarioDetails"
].filter(Boolean);
console.table(audit.byPolicy);
console.table(audit.byScenario);
console.table(audit.criticalChecks);
if (shapeErrors.length) {
  throw new Error(
    `audit grid drifted in ${shapeErrors.join(", ")}:\n` +
    JSON.stringify(shape, null, 2)
  );
}
const summary = `${audit.passedChecks}/${audit.totalChecks} checks passed`;
if (
  shapeErrors.length ||
  failed.length ||
  failedCriticalChecks.length ||
  !audit.ok ||
  audit.passedChecks !== audit.totalChecks
) {
  throw new Error(JSON.stringify({
    summary,
    shapeErrors,
    shape,
    failed,
    failedCriticalChecks,
    passedChecks: audit.passedChecks,
    totalChecks: audit.totalChecks
  }, null, 2));
}
console.log(summary);
NODE

The hash line prints the CSP-style SHA-256 source expression for abc:

'sha256-ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0='

The default summary is:

{
  "scripts": 3,
  "executed": 2,
  "blocked": 1,
  "attackerExecuted": 0,
  "dynamicLoaded": 1,
  "parserInsertedBlockedByStrict": 0,
  "eventHandlersBlocked": 0,
  "loaderHazards": 0
}

The audit currently runs 12 critical checks plus 20 generated policy/scenario partition checks, for 32 total checks. It verifies the SHA-256/base64 test vector, the host-allowlist CDN failure mode, the parser-inserted block under strict-dynamic, the nonce-only runtime-dependency block, the exact inline hash case, the unsafe-inline injection case, and the tainted-loader caveat.

A Small Decision Table

Here are the combinations I reach for when reading a production CSP incident:

Policy and scenario Result in the model Interpretation
Host allowlist / CDN hostile attacker executes The location was on the list, so a bad endpoint inherits trust.
Nonce + strict-dynamic / CDN hostile attacker blocked A parser-inserted CDN script with no nonce is not rescued by https:.
Nonce only / runtime dependency dependency blocked The root script was marked, but trust did not propagate.
Nonce + strict-dynamic / runtime dependency dependency executes The root script creates a non-parser-inserted child.
Unsafe inline / inline XSS attacker executes twice The inline block and event handler both run.
Nonce + strict-dynamic / tainted loader attacker executes The trusted loader chose an attacker-controlled URL.

The last row is the one that keeps the model honest. strict-dynamic is not a magic XSS eraser. It changes the audit from “are all hostnames safe?” to “which nonce/hash roots can create script elements, and where do their URLs come from?”

Boundaries

This lab intentionally leaves out large pieces of CSP: default-src fallback details, script-src-elem, script-src-attr, unsafe-hashes, modules, unsafe-eval, WebAssembly compilation, Trusted Types, report-only policies, multiple simultaneous policies, redirects, browser-specific console messages, and real network fetching.

Those omissions are not loopholes. They are the edges of this artifact. The point here is smaller: if a team talks about CSP as one allowlist, it will miss the security difference between a place, a per-response mark, an exact digest, and a trust chain.

  1. W3C, Content Security Policy Level 3, script-src

  2. W3C, CSP3 script-src inline behavior, which describes inline script blocks passing through CSP inline checks. 

  3. W3C, CSP3, “Does nonce match source list?”

  4. W3C, CSP3, “Does integrity metadata match source list?”

  5. Lukas Weichselbaum, Michele Spagnuolo, Sebastian Lekies, and Artur Janc, CSP Is Dead, Long Live CSP!

  6. W3C, CSP3, usage of strict-dynamic