Following our recent release to more easily protect Single Page Applications (SPA) and static websites, we wanted to deep dive with you on the matter.
“There’s a place where that idea still exists as a reality. And I’ve been there.”
“Where is it?”
“Like the JAMstack?”
“Yeah. The dream of the 90’s is alive in the JAMstack.”
So, you’ve bought into the dream, and now you’re building static websites with React or Vue, and you’re deploying them on bare-metal servers or even Netlify.
There’s a problem with the dream
There are two main reasons people are telling us to move to static websites: Speed, and security. There’s no doubt about the speed; but I’m here to talk security.
But we’re not children playing with toys, we’re building production applications with modern JS frameworks and APIs. We still use templating engines, we still write to a database. It will always remain true that our web application is the front door to the user data we need to protect.
More to the point, frameworks like Vue are still vulnerable to a range of cross-site scripting attacks. And doing anything really interesting still requires accessing a backend API via AJAX—so you still need security on your backend, and on those remote connections too.
What are we to do? There’s still a lot of work ahead of us to secure our web application thoroughly, but the first step is to start looking at how we can enlist browsers to help us—which means understanding how attackers can exploit front-end vulnerabilities.
The good news is that we can prevent these vulnerabilities with the judicious use of an HTTP response header. In fact, modern browsers offer (as we shall see) a very wide range of security features that are off by default, but that can be activated by your webserver via HTTP response headers.
In this article, we’re going to talk about
- Cross-site scripting (XSS)
- Clickjacking and framebusting
- Man in the Middle (MitM) and domain hijacking
- Referrer leakage
Let’s start with an easy case. Suppose your cool web app uses an API key to authorize user actions. Let’s suppose moreover that your users can (and do) pass that key in as a query parameter. So maybe they access a feature on your website with a URL like
Now, let us suppose that said user then clicks on a link to an external website, perhaps one operated by an adversary. Among the headers that their browser will send that external webserver is the
Referer (sic) header, which will look like this:
And…just like that, the adversarial web server has a perfectly valid API key. Crap.
(This is perhaps an overly dramatic example; but it will do to make the point. You can imagine lots of perfectly reasonable things to send via query parameters that you’d prefer to keep secret.)
In this case, the response header we’re after is called
Referrer-Policy. This header provides instructions to the browsers on when and how to construct the
Referer request header, and offers a great deal of fine-grained control. The most important values to consider, however, are these:
no-referrer-when-downgrade— this is the browser default; without going into detail, it’s not a great option.
no-referrer— never send the referrer header. Absolute safest option, but it kills your own visibility to track users across your app from your server logs.
same-origin— never send the referrer header, except to pages on the same domain, i.e. only sends the referrer header to sites under your control.
origin— This option tells the browser to strip out query parameters and path information, leaving only the hostname.
origin-when-cross-origin— Same as
origin, but leaves parameter details intact when visiting sites on the same page.
Depending on what you need from
Referer (sic) headers,
no-referrer are your safest, but most restrictive, options.
Have you ever visited a suspicious website, and later realized that you’ve somehow liked a bunch of posts on Facebook you’ve never seen before? If so, you’ve witnessed a clickjacking attack.
The basic idea behind clickjacking is to render invisible inline frames (
iframes) over tantalizing click targets. When you go to click on that enticing “download free now!” button, you actually end up clicking on the invisible inline frame, which then redirects your action to something else entirely, such as liking Facebook posts, following Twitter accounts, or activating an Amazon affiliate link.
Clickjacking can occur in one of two ways. The first is if you include a hostile inline frame in your webpage. The second is when a malicious site masquerades as your site by rendering your site inside an inline frame on their site.
The first case is straightforward to deal with. If you must use inline frames on your site, always load them into an iframe sandbox:
<iframe width="300" height="150" sandbox="allow-scripts allow-forms"></iframe>
By default, simply specifying a sandbox in the
iframe tag will prevent the browsers from executing any scripts, or basically doing much of anything short of rendering styled HTML, in the inline frame. You can then opt in to additional behaviors, such as script execution, enabling browser APIs, allowing popups, and so forth.
The other case, finding your site rendered into an
iframe on a hostile site, is again readily handled by emitting the
X-Frame-Options header to activate the security features of the browser to protect against this kind of malicious behavior.
This header instructs the browser whether it is permitted to render your page inside of an
iframe tag, easy as that. There are only three options, presented here from safest to least safe:
DENY— never allow this page to be rendered inside an
iframetag, the safest option
SAMEORIGIN— only allow this page to be rendered inside
iframetags on the same site.
ALLOW-FROM— provide a whitelist of domains permitted to render this page in an
If you don’t use
iframes to render parts of your site—and you probably shouldn’t without an extremely good reason—, then by all means set
DENY and be done with it.
Man in the Middle attacks
Pineapples. Yeah, they’re real. Any time your users connect to an open WiFi network, or even a closed network that isn’t trustworthy, they open themselves to Man in the Middle attacks. The idea is quite straightforward—the attacker pretends to be your website. And if they can earn the user’s trust (often not very difficult), they can do more or less anything that they like with that user’s account.
The first line of defense against this kind of attack is to encrypt all traffic to your website using TLS (aka HTTPS). If you aren’t doing that, there is nothing preventing a third party from listening in on your users, and there is nothing preventing a third party from pretending to be your site.
If you’re deploying to a dedicated static site hosting solution like Netlify, problem solved—they provide encryption free. If you aren’t, you can still easily get free TLS encryption using CloudFlare or Let’s Encrypt. On top of that, however, you should also stop serving your web app over non-encrypted connections.
Nevertheless, in a pineapple scenario, an attacker might attempt to masquerade as your site, forcing an unencrypted connection. In that case, the
Strict-Transport-Security header is your friend. This header instructs the browser not to connect over unencrypted connections; the downside is that the user will have needed to connect to your site over a legitimate connection at least once (although of course most will have, this is a minor downside, really).
Strict-Transport-Security header has several options, separated by semi-colons:
max-age=[value]— for how long the browser should honor the header before checking for it again (in seconds)
includeSubdomains— are subdomains included as part of this policy? (Default is no)
preload— instructs the browser to inform the browser author to hardcode your site into the list of sites that should never be connected to over an insecure connection.
When you are first setting up this option, use a short
max-age value, say 10 minutes, and do not use the
preload option. Once you are satisfied that everything is working, then consider setting
max-age to one year (31536000 seconds), and setting the
preload directive to make it basically permanent.
Of course, pineapples can fake the cryptographic certificates that are used to encrypt websites. All modern browsers will inform users that they are connecting to a site with a dodgy security certificate—but let’s be honest, how many times have you just clicked through that warning? Your users certainly have.
Key pinning is a browser security feature that prevents attackers from using falsified security certificates to pose as your site. The idea is that you can tell the browser which security keys are allowed to encrypt traffic to your site, and the browser will reject any subsequent attempt to connect using one you haven’t explicitly authorized. Activate this feature with the
Public-Key-Pins header, but be careful—misconfiguring this header can result in bricking your website!
Like the strict transport policy, there are several possible directives you can use to configure key pinning.
pin-sha256="[value]"— This specifies the SHA256 hash of a valid TLS key that is permitted to encrypt traffic to your site. You can (and should!) list multiple keys—if one key is revoked (or you mistype the hash!), and there are no fallbacks, your site will be bricked. So always set at least two valid keys in advance, just in case.
max-age=[value]— The duration, in seconds, that the key should remain pinned.
includeSubdomains— when present, indicates that the policy should affect all subdomains.
As before, begin with a very small value for
max-age for testing, perhaps ten minutes (600 seconds). Only once you are comfortable that all of the keys you want to specify are working with the policy should you extend the
max-age to something longer.
Cross site scripting
Take care when rendering templates
Prevention is key, as with many things. The easiest door to XSS is if an attacker can get a
script tag into the DOM. So, don’t let attackers do that!
innerHtml, but prefer
textContent instead for making textual updates. Escape user-provided strings with a js library such as
sanitize-html to transform “ to a harmless
<script>. Load JSON content with
JSON.parse() instead of
eval(). Easy stuff.
Content security policy
The browser can help us here too. In the past, you could rely on the
The better solution is to use a Content Security Policy or CSP. In broad terms, a CSP is a set of whitelists that tell browsers which domains are trusted for serving different kinds of content. CSPs are very fine-grained: You can specify independent sources for fonts, style, scripts, APIs, and a lot more.
Content-Security-Policy header is used to specify a CSP. However, because CSPs can be quite complex to configure, the CSP definition was designed to make it easy to use an external tool to help configure it. In fact, Sqreen now provides support for configuring CSPs (and other security headers!) for static websites. I strongly advise using a dedicated tool to craft your CSP (Sqreen is free to get started).
Even static websites have security issues that must be carefully attended to. The good news is the browser is our friend—we just need to tell it what we want. You should be setting all of the following HTTP response headers when serving static webpages to enable these features:
Nearly all hosting providers should provide a mechanism for setting these headers. If they don’t, you should request that they do support them, or you should seriously consider moving to a site that does. (Warning: GitHub Pages does NOT permit setting these headers!)
And of course if you need help making the right decisions on how to configure these headers, especially the Content Security Policy, Sqreen is your friend.