Mitigating cross-site scripting with Content Security Policy

In this post, we’re going to look at using Content Security Policy (CSP) as a defense-in-depth technique to block script injection attacks.

When building website that hosts user-generated content, such as:

Great to be here!
<script>window.location='https://example.com'</script>

It’s necessary to encode user-generated content so that browsers don’t mistake it for markup, and execute an untrusted script. This is easy to do for plain text, but what if a page needs to render user-generated HTML? Here’s an example of HTML that contains inline Javascript, which browsers could execute:

<p>Great to <b>be</b> here!</p>
<img src="" onerror="alert(0)" />
<a href="javascript:alert(0)">Hi</a>
<script>window.location='https://example.com'</script>

This content must be sanitized before rendering. Libraries such as HTMLAgilityPack or DOMPurify provide a way to parse the HTML and strip out elements or attributes known to execute scripts.

Sanitization is important, but what if an attacker has discovered a way around the filter? This is where Content Security Policy comes in.

If the Content-Security-Policy header is present when retrieving the page, and contains a script-src definition, scripts will be blocked unless they match one of the sources specified in the policy. A policy might look something like:

script-src 'self'; object-src 'none'; base-uri 'none';

This policy disallows:

  • External scripts not hosted on the same domain as the current page.
  • Inline script elements, such as <script>
  • Evaluated Javascript, such as <img src="" onerror="alert(0)" />
  • Base elements, which could break scripts loaded from a relative path
  • Object elements, which can host interactive content, such as Flash

Whitelisting inline scripts

Sometimes it is necessary to run inline scripts on your page. In these cases, the nonce attribute on script elements can be used to whitelist scripts that you control.

<script nonce="00deadbeef">doSomething()</script>

A matching nonce must be present in the CSP for the script to run. For compatibility with older browsers, unsafe-inline allows scripts to run if the nonce tag is unsupported.

script-src 'self' 'nonce-00deadbeef' 'unsafe-inline'; object-src 'none'; base-uri 'none';

It is critical that this nonce is derived from a cryptographic random number generator so that an attacker can’t guess a future nonce. In .NET, RNGCryptoServiceProvider.GetBytes can be used to fill a 16 byte array:

using (var random = new RNGCryptoServiceProvider())
{
    byte[] nonce = new byte[16];
    random.GetBytes(nonce);
    return Convert.ToBase64String(nonce);
}

Whitelisting external scripts

strict-dynamic can be used to allow scripts hosted on a third-party domain to be loaded by scripts that you control. However, at the time of writing, this isn’t supported by all major browsers, so a host whitelist should be used as well as a fallback until it has broad support.

script-src 'self' 'nonce-00deadbeef' 'unsafe-inline' 'strict-dynamic' https://example.com; object-src 'none'; base-uri 'none';

When using strict-dynamic, you will also need to add a nonce to any external scripts that are referenced.

<script nonce="00deadbeef" src="https://example.com/analytics.js" />

Note:

Be careful what sources you whitelist. If any endpoints return JSONP and fail to sanitize the callback, it is possible to inject code. For example:

<script src="https://example.com/getuser?callback=window.location='http://google.com';test"></script>

Might return window.location='http://google.com';test({}) in the JSONP response, which would cause arbitrary code to be executed!

There are other policies that you can define to strengthen your site’s security, such as restricting where stylesheets are loaded from. This post only focuses on mitigating cross-site scripting attacks.

Further Reading

Thanks to Bradley Grainger and Kyle Sletten for reviewing this implementation.

Posted by Dustin Masters on December 21, 2017