Locking Down Inline Scripts with CSP Nonces

Content Security Policy deployments typically rely on a whitelist of allowed script sources plus the 'unsafe-inline' directive. But that directive is precisely what leaves a page vulnerable to XSS: any injected inline script or event handler will still execute. CSP's nonce mechanism offers a way out, but deploying it at scale takes careful planning. This post covers how we rolled out nonce-based script restrictions on the Dropbox website.

Before diving in, a brief refresher: CSP by default blocks all inline <script> blocks and inline event handlers like <div onclick="...">, enforcing strict separation of code and data. The nonce source syntax — part of CSP2, supported in Chrome and Firefox — relaxes that by allowing inline scripts that carry a matching nonce attribute. Browsers without nonce support (Safari and Edge at the time) ignore the syntax and fall back to 'unsafe-inline'. Note that CSP is a mitigation layer, not a replacement for robust validation and sanitization.

Adding Nonces to Generated HTML

Rolling out script nonces required two changes: injecting the right nonce into every inline <script> tag, and eliminating inline event handlers. Dropbox generates server-side HTML with Pyxl, which converts HTML tags into Python objects and auto-escapes untrusted data during serialization. We modified Pyxl's serialization code to insert the correct nonce attribute into script tags.

The harder task was removing inline event handlers. New code at Dropbox had long been written without them, but a substantial body of legacy code remained. To ease migration, we wrote an automatic transform that rewrites inline event handlers as inline script tags. The code:

<div id="foo" onload="somescript">

gets converted to:

<div id="foo"><!-- if id is absent, we create a unique id -->
  <script> 
  // The nonce in the script tag above will be 
  // inserted during Pyxl serialization
  function(){
      var e = document.getElementById(foo);
      e.addEventListener("load", function(ev){
  //  ...somescript.. 
      });
  }();
  </script>

This transform uses an immediately invoked function expression to avoid polluting the global namespace, and inserts the new <script> tag immediately after the original element opens — rather than after the element closes or at DOMContentLoaded — to closely match the browser's native behavior.

This transformation does not fix existing XSS vulnerabilities in the onload code itself. We accepted that risk because Pyxl is reasonably good at catching and preventing XSS in server-side code, and because the plan is to eventually deprecate all inline event handlers entirely. Still, once the change shipped, only inline scripts we knew about could execute; browsers with nonce support would block anything an attacker injected via a DOMXSS flaw.

Filtering Violation Reports

As with any CSP rollout, we started in report-only mode with nonces for nearly a month. Filtering noise from inline script violation reports needed extra care. Chrome helpfully sends two extra fields with such reports: a script sample and the source file that triggered the violation. The script sample lets us grep through the codebase quickly; the source file points to the JavaScript that inserted the offending inline script via DOM APIs. We filtered out reports where the source file URI didn't belong to our application — ad injectors and browser extensions are common offenders — and, following well-known advice, discarded reports whose script samples clearly weren't ours (for example, anything containing the string "lastPass").

Firefox had a bug where it would report a violation for a nonce-allowed inline script even while executing it. Since the report-uri noise was pointless, we recommend dropping the report-uri for Firefox clients during nonce deployment. The bug has since been fixed, but only shipped in Firefox 43 (December 2015), so holding off until late January 2016 is safest.

With filtering in place, we moved to enforcement mode and began the slow, methodical work of fixing real violations. After a couple of weeks of tightening, we reached a point where nonce sources could be enforced for all users.

A Fallback for Browsers Without Nonce Support

Safari and Edge ignore nonce syntax entirely, falling back to 'unsafe-inline'. To harden against DOMXSS on those browsers, we used another trick:

document.addEventListener('DOMContentLoaded', function () {
    var metaTag = document.createElement('meta');
    metaTag.setAttribute('http-equiv', 'Content-Security-Policy');
    metaTag.setAttribute('content', "script-src https: 'unsafe-eval';");
    document.head.appendChild(metaTag);
});

The DOMContentLoaded event fires after the browser has executed all HTML and synchronous scripts, including inline ones. Following performance best practices, Dropbox already avoids synchronous remote scripts, so the vast majority of JavaScript runs after that event.

The code above injects a second CSP policy once DOMContentLoaded fires — one that omits 'unsafe-inline'. Browsers enforce multiple CSP policies additively, meaning code must pass all of them to execute. The result: Safari and Edge permit inline scripts only during initial parsing, and stop supporting inline event handlers after DOMContentLoaded.

Consider an attacker injecting <div onclick=alert(1)> via innerHTML. In Chrome and Firefox, the nonce-based header policy blocks it outright. In Safari and Edge, the first header allows the handler, but the second policy injected after DOMContentLoaded blocks it. This is weaker than native nonce support, but it covers a substantial class of DOMXSS attacks.

Net Effect

Between native nonce support in Chrome and Firefox and the post-DOMContentLoaded policy for Safari and Edge, the vast majority of Dropbox users now have a strong second barrier against injection — even if a vulnerability is successfully exploited. It's not a complete fix for all injection flaws, but it's meaningful relief while we continue migrating remaining legacy code and hardening sanitization.