Back to all posts
·5 min read

Content Security Policy against XSS: report, tighten, enforce

The first line of defense against XSS is always handling input correctly and encoding output correctly. But however careful you are, one day a stray innerHTML, a third-party script or a dependency update can punch a hole in that line. Content Security Policy (CSP) is a second layer that tells the browser which sources of code may run on a page. Set up properly, it stops an injected script from executing.

The hard part of CSP is adding it to an existing application without breaking anything. In this post I describe the process: ship the policy in reporting mode first, read the violations, move to a nonce-based policy, and then enforce it.

What CSP does and does not do

CSP is an HTTP response header. The browser checks script, style, image, connection and frame sources against the directives in it. A source that is not allowed is not loaded or not executed.

The limit you should know: CSP does not close the vulnerability, it makes exploitation harder. Output encoding is still required. Think of CSP as a seat belt that matters when that defense fails. It is also not a fix for server-side authorization problems, CSRF or SQL injection.

Why an allowlist is not enough

The first idea is usually to list trusted hosts:

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

This looks reasonable but is fragile in practice. Any script hosted on an allowed host, such as an open JSONP endpoint or an old library, can hand an attacker executable code. The longer the list grows, the harder it is to maintain. That is why current guidance is to rely on a per-response nonce or on script hashes instead of hosts.

A nonce-based policy

The idea is simple: for each request the server generates a random value and writes it both into the header and into the trusted script tags. The browser runs only scripts that carry that nonce.

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

In ASP.NET Core you can do this with a middleware:

app.Use(async (context, next) =>
{
    var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
    context.Items["csp-nonce"] = nonce;

    context.Response.Headers["Content-Security-Policy"] =
        $"default-src 'self'; " +
        $"script-src 'nonce-{nonce}' 'strict-dynamic'; " +
        "object-src 'none'; base-uri 'none'; frame-ancestors 'none'";

    await next();
});

The key point: the nonce must be regenerated on every request and must be unpredictable. A fixed value, or reusing the same nonce because the page is cached, wipes out the protection. If you use full-page caching, consider a hash-based approach instead of nonces.

'strict-dynamic' extends trust from a nonce-approved script to the scripts it loads. That way you do not have to write an allowance for every file that a bundler loads dynamically.

Report-Only first: learning without breaking

If you enforce the policy right away, an inline script you forgot about can make a button stop working in production. Instead, publish the same policy with the Content-Security-Policy-Report-Only header. The browser does not block violations, it only reports them.

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'nonce-{RANDOM}' 'strict-dynamic';
  report-to csp-endpoint

The report endpoint can be a simple POST receiver. Write the reports to a table and look at which directive was violated, on which page and by which source. After a few days or a week of data, you can filter out the typical noise: violations caused by browser extensions, inline event handlers on an old page, a forgotten analytics script.

Protect the reporting endpoint with rate limiting. It is public and unauthenticated, so someone with bad intentions could flood it with fake reports.

Cleaning up inline code

The reports usually point to the same three things:

  • Inline event handlers such as onclick="...". Move them into a script file with addEventListener.
  • Inline <script> blocks. If you really need them, add a nonce; otherwise move them to an external file.
  • Libraries that use eval or new Function. These need 'unsafe-eval'; replace the library if you can, or review its configuration.

On the style side, inline style attributes are less risky but can be handled with the same logic. In the first phase it is a realistic priority to tighten scripts and leave the style policy for later; script execution is a much more serious risk than style injection.

Moving to enforcement

When the report stream has calmed down, change the header to Content-Security-Policy. Do the switch gradually: first for a small percentage of traffic or only for internal users, then for everyone. If you control it with a feature flag, you can roll back in one step if something breaks.

Do not drop Report-Only entirely after enforcing. While trying a new policy version (for example a stricter style-src) in Report-Only, you can send both headers together and keep enforcing the current policy. They work independently.

Common mistakes

  • Adding 'unsafe-inline'. It fixes the problem quickly but largely removes CSP's value for scripts. When a nonce is present, modern browsers ignore 'unsafe-inline' anyway; still, do not count it as a security mechanism.
  • Caching the nonce. Serving the same HTML with the same nonce to different users makes the nonce predictable.
  • Forgetting object-src and base-uri. If these two are missing, it may be possible to run code another way despite the script restriction.
  • Never reading the reports. If the report stream is set up but not watched, CSP quietly goes stale.
  • One policy everywhere. If the admin panel and the public pages have different needs, producing different policies per path can make sense.

Closing

CSP is not a header you write once and forget; it is a contract that lives alongside the application. Observe with Report-Only first, clean up inline code, tighten the policy with a nonce and strict-dynamic, then enforce gradually. It does not replace output encoding, but it can reduce the consequence of an injection that slips through to "the script did not run and a report arrived". That turns an incident into a manageable alert.