Content Security Policy

Chapter 8
Content Security Policy (CSP)
Directives, nonces & hashes, the unsafe-inline trap, and CSP as a safety net

Encoding (Chapter 6) and sanitization (Chapter 7) prevent injection. Content Security Policy is different in kind: it's a browser-enforced safety net that limits the damage if a payload does slip through. It's the classic defence-in-depth layer for XSS — not a substitute for encoding, but a powerful backstop that can turn a critical XSS into a non-event.

What CSP Does

CSP is an HTTP response header (Content-Security-Policy) that tells the browser which sources of content are allowed to load and execute. The browser enforces it. For XSS, the decisive power is controlling what scripts may run: even if an attacker injects <script> into the page, a good CSP means the browser refuses to execute it.

# a response header restricting where content can come from Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'

This says: load resources only from our own origin, run scripts only from our own origin (not inline, not from other domains), and block plugins entirely. An injected inline <script>alert(1)</script> violates script-src 'self' and is not executed.

Key Directives

DirectiveControls
default-srcfallback for all resource types not otherwise specified
script-srcwhere scripts may load/execute — the key XSS control
style-srcwhere stylesheets may come from
img-src / font-src / connect-srcimages / fonts / fetch/XHR/WebSocket targets
object-srcplugins (<object>/<embed>) — set to 'none'
base-urirestricts <base> — set to 'self' to block base-tag hijacks
frame-ancestorswho may frame you (clickjacking defence)

Common source values: 'self' (same origin), 'none' (nothing), a domain allowlist, 'nonce-…', 'sha256-…', and the dangerous 'unsafe-inline' / 'unsafe-eval'.

The Hard Part: Inline Scripts

A strict script-src 'self' blocks all inline scripts — including the legitimate ones most sites have (<script>…</script> in the page, onclick= handlers). That's what makes CSP effective against XSS (injected scripts are inline), but it's also why adopting CSP is work. There are two safe ways to allow your inline scripts while still blocking injected ones: nonces and hashes.

# NONCE: server puts a fresh random token in the header AND on each trusted script Content-Security-Policy: script-src 'nonce-r4nd0m123' <script nonce="r4nd0m123"> /* trusted: runs */ </script> <script> /* injected: no nonce -> blocked */ </script>

The nonce is unpredictable and regenerated per response, so an attacker injecting a script can't know it and can't add a valid nonce= attribute — their script is blocked while yours runs. Hashes work similarly for static inline scripts: you put the script's SHA-256 in the policy ('sha256-…'), and only scripts matching that exact content execute.

'unsafe-inline' defeats the whole purpose — it's the #1 CSP mistake
The quickest way to "make CSP not break my site" is to add 'unsafe-inline' to script-src — which re-allows all inline scripts, including the attacker's. A policy of script-src 'self' 'unsafe-inline' provides essentially zero XSS protection, because injected inline scripts are exactly what it now permits. Likewise 'unsafe-eval' re-enables eval()-class sinks. A CSP that lists 'unsafe-inline' for scripts looks like a defence but isn't one. Use nonces or hashes instead — that's the entire point of CSP for XSS.

Why CSP Is Defence-in-Depth, Not the Primary Fix

CSP must not be your only XSS defence, for several reasons: policies are easy to misconfigure (one 'unsafe-inline' and it's hollow); some XSS doesn't need to inject script tags (e.g. stealing data via allowed image/connect requests, or DOM-clobbering); older browsers have partial support; and a too-strict policy that breaks the site pressures teams to weaken it. Encoding and sanitization stop the injection; CSP limits the blast radius if they fail. Together they're strong; CSP alone is a leaky net.

Build a CSP safely with report-only mode first
Deploying a strict CSP on a complex existing site usually breaks something (an inline handler, a third-party widget). The safe rollout: send Content-Security-Policy-Report-Only first — the browser reports violations (to a report-uri/report-to endpoint) without blocking anything. You collect real violation data, fix your inline scripts (move to nonces/external files), tighten the policy until reports are clean, then switch to the enforcing header. This avoids the "strict CSP broke prod, revert it" cycle that leads to 'unsafe-inline'.

A Strong Starting Policy

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'

Modern guidance favours a nonce-based, "strict-dynamic" policy over long domain allowlists (which are easy to bypass via an allowlisted host's open redirect or a vulnerable script on it). Tools like Google's CSP Evaluator grade a policy and flag weaknesses like 'unsafe-inline' or overly broad allowlists.

Hands-On Exercises

Exercise 1

Given Content-Security-Policy: script-src 'self', explain what happens to (a) a legitimate external script from your own origin, (b) an injected inline <script>, (c) an injected <img onerror> handler. State why CSP turns an injected script into a non-event.

📄 View solution
Exercise 2

Explain how a CSP nonce lets your inline scripts run while blocking an attacker's injected inline script. Why must the nonce be random and per-response? Then explain why adding 'unsafe-inline' to make the site work destroys the protection.

📄 View solution
Exercise 3

Argue why CSP should be defence-in-depth rather than the primary XSS defence. Give two ways a misconfigured or bypassed CSP still leaves XSS exploitable, and describe the report-only rollout strategy and why it prevents the "strict CSP broke the site" failure mode.

📄 View solution

Chapter 8 Quick Reference

  • CSP = a response header telling the browser which content sources may load/execute — a browser-enforced safety net
  • Key XSS control is script-src: a good policy means an injected <script> simply won't run
  • Useful directives: default-src, script-src, object-src 'none', base-uri 'self', frame-ancestors
  • Nonces (random, per-response) and hashes let your inline scripts run while blocking injected ones
  • 'unsafe-inline' destroys CSP's XSS value — it re-allows the attacker's inline scripts; the #1 mistake (also avoid 'unsafe-eval')
  • CSP is defence-in-depth, not primary — encoding/sanitization stop injection; CSP limits the blast radius if they fail
  • Roll out with Content-Security-Policy-Report-Only first, fix violations, then enforce; prefer nonce + strict-dynamic over domain allowlists
  • Grade policies with CSP Evaluator; watch for 'unsafe-inline' and broad allowlists
  • Next chapter: framework protections & modern XSS — React/Vue/Angular auto-escaping, escape hatches, Trusted Types