Back to All Posts

You Might As Well Use a Content Security Policy

Content Security Policies, even for simple, content-focused sites, offer good protection against rare but real vulnerabilities out there. You might as well just get one.

A few weeks ago, someone emailed to let me know that JamComments wasn’t playing nicely with his Content Security Policy (CSP). This was the first time I’d heard of the problem, which probably indicates how infrequently the feature is used, despite having been standardized since 2013 and being extremely well-supported by modern browsers.

At the time, JamComments used two things obstructed by even the simplest CSP: Function constructors (used by Alpine.js) and JavaScript within <script> tags. Both of these things (including a couple other things I’ve thrown) are neutralized with a little <meta> tag in your document:

<head>
  <!-- the CSP -->
  <meta http-equiv="Content-Security-Policy" content="script-src 'self'" />
</head>

<body>
  <!-- inline event handlers -->
  <button onclick="alert('nope.')">click me</button>

  <!-- inline script tags -->
  <script>
    console.log('nice try.');
  </script>

  <!-- Loading scripts from other origins -->
  <!-- (jQuery's still in btw.) -->
  <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>

  <!-- Function constructors -->
  <script src="src/main.js"></script>
  <!--
    // main.js
  
    const no = new Function('console.log("LOL no");');

    no(2, 6);
  -->
</body>

That one-liner allows JavaScript loaded only through your own domain ("self") to execute, including inline scripts and event handlers. You'd see a mess of errors in your console trying to run that code above:

CSP errors

Fortunately, it was straightforward to make JamComments compliant with a policy like this. I switched to Alpine's CSP build, began loading scripts & styles externally (better for caching anyway), and introduced an option to proxy assets through your own host.

You might think the hassle burned my liking of CSPs, but actually, the opposite happened. I've moved toward liking them even more. In fact, I think every site should probably just get one, even if it's hosting simple, static content (like your blog).

Sanitization Backup

If you accept any form of user-generated content (UGC), like comments, it’s a good idea to use a CSP to disable any nefarious code, even if that code is supposedly already being sanitized. As unlikely as it may be, it’s possible things could otherwise blow up in your face. A CSP is an affordable way to prevent that. A couple reasons:

First, sanitization could be inadequate or overstated. Someone could claim UGC is “sanitized” by inserting code via .innerHTML, for example, because it doesn’t permit <script> tags from executing.

const content = document.getElementById('content');

content.innerHTML = `
  <script>
    console.log("will not fire!");
  <.script>
`;

That’s true, but inline event handlers and links using the JavaScript pseudo-protocol will still fire:

const content = document.getElementById('content');

content.innerHTML = `
  <img src="x" onerror="console.warn('imma getchu');">
  <a href="javascript:alert('ur so done.')">Save</a>
`;

That leaves your attack surface area plenty large enough to still worry about, even if the "obvious" vulnerabilities are covered.

Supply Chain Attacks

You might've heard of the Polyfill.io supply chain attack exposed earlier this year. When the cdn.polyfill.io domain was acquired by new owners, they began swapping out "good" code for something more malicious. The group specifically targeted the mobile devices of carefully selected types of users, who were then directed to scam sites. A lot of sites were impacted – over 100k. And all you needed to do to make yourself vulnerable was have a Polyfill.io CDN link on your site.

A supply chain attack is one reason why you ought to be picky about the third-party hosts you use to serve assets. Any of them could theoretically pull out the rug from under you at any moment. Andrew Betts says it more scarily:

screenshot of tweet describing how serious the risk of a supply chain attack is

A CSP is helpful on this front because it restricts third-party hosts to an explicit list of the ones you truly trust (or none at all). As long as that policy's in place, there's no way anyone could accidentally or dastardly slip in something through an untrusted domain.

And for the domains you do want to permit, list them in your policy. Using the jQuery example from above:

<meta
  http-equiv="Content-Security-Policy"
  content="script-src 'self' code.jquery.com"
/>

If it's not obvious, this won't protect you if those trusted domains themselves are compromised, but it'll limit the risk. Implement this while serving as many other resources as possible through your own domain, and you'll be in pretty good shape.

Building Your Policy

The policy that's "best" for you will heavily depend on the type of site you're running. Whether you allow form submissions, accept any UGC, or even if you serve content to authenticated users will all inform that policy. There are plenty of directives to lock down all sorts of things (read about them all here). But I'm writing this for a public, content-focused blog, so I'll focus there.

One tactic for building your policy is to start aggressively and relax it from there. Here's a pretty locked-down policy:

<meta http-equiv="Content-Security-Policy" content="default-src 'self';">

The default-src directive serves as the fallback value for other directives, preventing any third-party resources from being loaded. It's either your own domain or nothing. When you first run this, it'll very likely break something, after which you could begin whitelisting domains, or using specific directives:

<meta
  http-equiv="Content-Security-Policy"
  content="
    script-src 'self'; 
    img-src 'self'; 
    style-src 'self';
    font-src 'self';"
/>

That one will restrict all script, image, style, and font sources to your own domain. A little more chill, but still more than adequate for a blog.

If you'd like to start with something a little more tame, I'd probably go with this:

<meta http-equiv="Content-Security-Policy" content="script-src 'self'; />

This would arguably protect you from the most common vulnerabilities out there (no execution of any third-party scripts) , and you could then tighten from there.

Setting a Policy via HTTP Header

The <meta /> tag isn't the only way to define a CSP. The Content-Security-Policy header does the same thing, but with a couple perks.

First, you can define a destination for violations to be reported when they occur. You'll need a report-to directive in your policy, as well as an additional Reporting-Endpoints header.

Reporting-Endpoints: csp-endpoint='https://example.com/csp-report'
Content-Security-Policy: script-src 'self'; report-to csp-endpoint

For every violation, that endpoint will receive a POST request containing a big, ol' blob of JSON detailing went wrong. I can't immediately imagine why that information would be useful, but I'm sure it is to someone.

Second, defining your policy with a header makes it a smidge more obfuscated, and a little more difficult for people to snoop for a loophole. They could still find it; it just wouldn't be right there in your HTML. They'd need to crack open some dev tools and view the response headers of the request:

But even aside from that, many might prefer to have browser directives (including things like Cache-Control neatly tucked away in the headers, rather than plastered in your HTML. It's up to you. but if you do choose to go that route, setting it is fairly simple for some of the more common modern hosts out there.

Cloudflare Pages

In a _headers file at the root of your project, set it on every response from your site.

/*
  Content-Security-Policy: script-src 'self'

You could also set it in a middleware function.

Netlify

You could use a _headers file like above, or define it in your netlify.toml file.

[[headers]]
  for = "/*"
  [headers.values]
    Content-Security-Policy = "script-src 'self'"

Vercel

Drop it in your vercel.json file.

{
  "headers": [
    {
       "source": "/(.*)", 
       "headers": [
        {
          "key": "Content-Security-Policy", 
          "value": "script-src 'self'"
        }
      ]
    }
  ]
}

Or if you'd like to do it the hard way, use edge middleware.

You get the idea.

Free Value

I'll admit: the chance of anything horrible happening to my static blog because I didn't have a Content Security Policy in place is very, very low. And if there were more downsides to implementing one, I might encourage most people to not even bother.

But as far as I can tell, the cost to implementing even a simple policy is virtually nothing – just a little annoyance when you try to use a third party CDN after setting up a CSP, but I can roll with that (plus, it's easily solvable with whitelisting). On the other end, the potential value in preventing a headache of security issues when the moment does arise is pretty sizable, in my opinion.

The cost is negligible, and the value is possibly huge. It's like an incredibly affordable, effective insurance policy. You might as well just get one.


Alex MacArthur is a software engineer working for Dave Ramsey in Nashville-ish, TN.
Soli Deo gloria.

Get irregular emails about new posts or projects.

No spam. Unsubscribe whenever.
Leave a Free Comment

1 comment
  • Tyler Mercer

    Thank you for the speedy turnaround on this issue! And this is a great blog post explaining CSPs.

    I haven't tested it, but I think there's an even simpler way to proxy the JamComments assets through a first-party domain on Cloudflare Pages: you can use a redirects file.

    (I'll try it on my site later and report back.)


    1 reply
    • Alex MacArthur

      Ah, that'd be much nicer than needing to rely on a function. Definitely report back on your findings. Glad you piqued my attention about all of this. 👍