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 --><metahttp-equiv="Content-Security-Policy"content="script-src 'self'"/></head><body><!-- inline event handlers --><buttononclick="alert('nope.')">click me</button><!-- inline script tags --><script>console.log('nice try.');</script><!-- Loading scripts from other origins --><!-- (jQuery's still in btw.) --><scriptsrc="https://code.jquery.com/jquery-3.7.1.min.js"></script><!-- Function constructors --><scriptsrc="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:
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).
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=`<imgsrc="x"onerror="console.warn('imma getchu');"><ahref="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.
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:
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:
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.
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:
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:
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.
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.
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.
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. 👍