Topic: Web platform

Don't block first: use CSP Report-Only to find real dependencies, then tighten safely

CSP Report-Only is not a pause in security. It reveals real dependencies, noise, and legacy code patterns before a nonce-, hash-, and least-privilege policy reaches production.

The riskiest first move when adding Content Security Policy (CSP) is usually not missing one source. It is seeing a stream of violations and pasting every hostname into an allowlist. The page stops breaking, but the policy can become too broad to provide much protection.

CSP limits what a page can load and execute. It is a defense-in-depth layer alongside output encoding and sanitization, not a magic replacement for fixing XSS. To make that layer useful, treat Report-Only as an observation period, not a one-time compatibility switch.

Report-Only does not block requests, but it makes a draft policy observable

Content-Security-Policy-Report-Only is an HTTP response header. The browser reports resources that a draft policy would block, without actually blocking them. That makes it possible to see production dependencies before deciding what to change. It cannot be set in a <meta> element.

Here is an abstract starting point:

Reporting-Endpoints: csp="https://reports.example.com/csp"

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'nonce-{random}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  report-to csp;
  report-uri /csp-report;

This is not a copy-paste production configuration. {random} must be regenerated for every response, and endpoints, browser support, and real resources must fit the system. The important connection is that report-to names an endpoint in Reporting-Endpoints; for cross-browser coverage, MDN still recommends sending the older report-uri as well.

Animated meme (expand/collapse)
Every source that appears to need approval first needs a confirmed purpose, owner, and smallest allowed scope. · Source: GIPHY

The value of Report-Only is more than avoiding breakage. It makes us ask: which feature loaded this script, style, image, or frame? Do we own it? Is it still needed? One hostname can be a required payment component, an old tag, an experiment left behind, or a third-party loader that should not be open to the whole site.

Do not turn reports directly into an allowlist

I find it useful to send each kind of violation down one of three paths:

  1. Our code: fix the bundle, template, or inline pattern so it fits the intended policy.
  2. A necessary external service: confirm the provider, purpose, and loading chain, then allow only the needed directive and source.
  3. An item with no clear owner: inspect the page and environment that produced it; do not approve it without enough evidence.

That classification matters more than whether the number of reports falls. CSP allowlists tend to grow with third-party scripts. MDN specifically warns that overly broad source lists can allow unsafe domains and defeat the purpose of CSP. Buttondown’s production rollout shows the practical shape of the work: collect violations in Report-Only, then decide whether to remove a load or actually allow its source instead of gambling on turning enforcement on all at once.

Tighten script-src by changing code shape first

An effective CSP is not simply a longer list of domains after script-src. A strict CSP starts from identifiable code:

  • A nonce fits dynamic responses. The server generates an unpredictable fresh value for each response, and the header and permitted <script> elements share it.
  • A hash fits a fixed inline script. It names that exact content, so even a whitespace change can make it stop matching.
  • strict-dynamic lets a nonce- or hash-trusted root script load follow-on scripts in supporting browsers. It does not make arbitrary scripts trusted.

So when a report finds onclick="…", string-form setTimeout(), or eval(), do not first open the gate with unsafe-inline or unsafe-eval. The former broadly permits inline script; the latter permits treating strings as JavaScript. Moving event handlers to addEventListener() and keeping program data as data is usually the safer durable fix.

Animated meme (expand/collapse)
Enable enforcement after narrowing exceptions; when code structure can be fixed, do not make a risky exception permanent. · Source: GIPHY

When an exception is unavoidable, a narrower option may exist, such as unsafe-hashes for a specific inline event handler. It is still a trade-off, not a default. Record an owner, reason, and removal condition for every exception. Otherwise a policy quietly turns from a security rule into a historical list nobody can safely touch.

A release sequence that can be followed in practice

I split a CSP rollout into reversible steps:

  1. Draft the target policy, including explicit boundaries such as object-src 'none' and base-uri 'none'.
  2. Enable Report-Only through a response header in real traffic, and collect traceable reports.
  3. Fix our code, review external services, and reject sources that cannot be explained.
  4. After checking representative pages and flows, enable the same narrowed policy with Content-Security-Policy.
  5. Keep observing, so a later third-party or template change cannot quietly pierce the boundary.

Step 4 is the block. The first three steps avoid making normal users the test harness. A good CSP is not a very long domain list. It is an execution contract that explains why every executable source exists, who owns it, and when it should be removed.

What I learned

  • I can use Report-Only to see real dependencies before an enforcing policy affects users; its reports are review input, not an automatic allowlist.
  • Nonces, hashes, and strict-dynamic create a traceable trust chain instead of an ever-growing source list.
  • unsafe-inline and unsafe-eval point to code-shape problems. When a refactor is possible, it is usually safer than a permanent exception.
  • CSP is one more layer against XSS. It does not replace output encoding, sanitization, or ordinary security fixes.

External references