The Script Has a Guest List
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.
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.
-
W3C, CSP3
script-srcinline behavior, which describes inline script blocks passing through CSP inline checks. ↩ -
Lukas Weichselbaum, Michele Spagnuolo, Sebastian Lekies, and Artur Janc, CSP Is Dead, Long Live CSP!. ↩