Observatory by Mozilla helps websites by teaching developers, system administrators, and security professionals how to configure their sites safely and securely.
Let's take a look at the scores Observatory gives for a fairly straightforward Static Buildpack app, https://2017.keeprubyweird.com.
Test Scores
Test | Pass | Score | Explanation |
---|---|---|---|
Content Security Policy | ✗ | -25 | Content Security Policy (CSP) header not implemented |
Cookies | ― | 0 | No cookies detected |
Cross-origin Resource Sharing | ✔ | 0 | Content is not visible via cross-origin resource sharing (CORS) files or headers |
HTTP Public Key Pinning | ― | 0 | HTTP Public Key Pinning (HPKP) header not implemented (optional) |
HTTP Strict Transport Security | ✗ | -20 | HTTP Strict Transport Security (HSTS) header not implemented |
Redirection | ✔ | 0 | Initial redirection is to https on same host, final destination is https |
Referrer Policy | ― | 0 | Referrer-Policy header not implemented (optional) |
Subresource Integrity | ― | 0 | Subresource Integrity (SRI) not implemented, but all scripts are loaded from a similar origin |
X-Content-Type-Options | ✗ | -5 | X-Content-Type-Options header not implemented |
X-Frame-Options | ✗ | -20 | X-Frame-Options (XFO) header not implemented |
X-XSS-Protection | ✗ | -10 | X-XSS-Protection header not implemented |
Even though we use Heroku's Automated Certificate Management to easily get an SSL certificate for our domains, our overall score is an F, 20/100. We'll walk through each failing test, learn what caused the failure, and try to fix them.
Content Security Policy (CSP)
The failure here is "CSP header not implemented", and when we view the linked security guideline we see that CSP gives us control over where scripts and resources we reference on our site can be loaded from. For Keep Ruby Weird, this means fonts, several external image sources, and a couple of analytics sources.
CSP gives us a few levels of strictness:
default-src <source>
,script-src <source>
,object-src <source>
, etc. These limit the sources of various types of resources.https:
limit resources of the specified type, or all resources, to HTTPS onlyhttps://example.com
limit resources to this domain. Multiple such sources can be provided for the same*-src
directive.'self'
means that resources can only be loaded from the current host, useful for relative resources like<script src="/index.js">
.- See CSP: default-src on MDN for full options here.
- The
Content-Security-Policy
header disallows<script>
tags with inline code by default. Those with asrc
instead are allowed. This can be disabled by adding'unsafe-inline'
which makes our site less secure. You can also specifynonce
s or SHA sums of the content of those scripts to allow them to execute. frame-ancestors 'none'
Prevents your site from being loaded in an iframe and being used in a clickjacking attack. If you do need to display your site in an iframe, you can specify URLs instead.
For our purposes, we will want to be able to use self-hosted resources, images from Twitter and AWS, scripts from Google and Twitter analytics, and both a stylesheet and font entry from Google Fonts. We'll also need to re-define 'self'
in a few directives because they don't fall back on the default unless the options aren't specified. For example, we haven't specified object-src
so it falls back on the default-src
value of 'self'
.
Content-Security-Policy: default-src 'self';
script-src https://static.ads-twitter.com https://www.google-analytics.com;
img-src 'self' https://s3.amazonaws.com https://twitter.com https://pbs.twimg.com;
font-src 'self' https://fonts.gstatic.com;
style-src 'self' https://fonts.googleapis.com;
frame-ancestors 'none';
We can add this to our static.json as a part of the headers collection for all paths:
# ...
"headers": {
"/**": {
"Content-Security-Policy": "default-src 'self'; script-src https://static.ads-twitter.com https://www.google-analytics.com; img-src 'self' https://s3.amazonaws.com https://twitter.com https://pbs.twimg.com; font-src 'self' https://fonts.gstatic.com; style-src 'self' https://fonts.googleapis.com; frame-ancestors 'none';"
}
}
When we add or remove external resources, we'll need to update this collection or our users will see errors in the browser's console and the resources will be unavailable.
HTTP Strict Transport Security (HSTS)
We failed this test for basically the same reason: "HTTP Strict Transport Security (HSTS) header not implemented".
HSTS tells a browser that our site should only be viewed over HTTPS. Looking at the HSTS security guideline, we see that HSTS provides several nonexclusive flags:
max-age=<seconds>
. How long user agents will redirect to HTTPS, in seconds. This tells a browser "once you've seen this, assume that all requests to this domain will be over HTTPS for this long." Mozilla recommends 2 years, or63072000
seconds. This flag is required.includeSubDomains
. Whether user agents should upgrade requests on subdomains. Unless you have a reason you wouldn't have SSL on all subdomains, you probably want this. I'd recommend it even if you don't currently have subdomains.preload
. If you have this flag and also register your domain on the Chrome HSTS preload list, browsers will not even need to see this header before forcing HTTPS requests. This is useful, but opt-in as you do need to register your own domain by looking it up on the preload list, checking some boxes, and submitting. It's pretty easy and we'll do it here.
# ...
"headers": {
"/**": {
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload"
}
}
X-Content-Type-Options
This header tells browsers not to load scripts and stylesheets if their MIME type as indicated by the server is incorrect. It's a good thing to have on.
# ...
"headers": {
"/**": {
"X-Content-Type-Options": "nosniff"
}
}
X-Frame-Options
This header prevents your site from being loaded in an iframe. It helps prevent "clickjacking" attacks. It is the same protection offered by frame-ancestors 'none'
in Content-Security-Policy but adds support for older browsers. If you do need to display your site in an iframe on another page of your site, you can instead use the SAMEORIGIN
option.
# ...
"headers": {
"/**": {
"X-Frame-Options": "DENY"
}
}
X-XSS-Protection
This header protects from cross-site scripting (XSS) attacks. It provides similar protection as Content-Security-Policy but again protects older browsers.
# ...
"headers": {
"/**": {
"X-XSS-Protection": "1; mode=block"
}
}
Putting It All Together
Adding this header block to our static.json increases our score from an F to an A on the Observatory.
"headers": {
"/**": {
"Content-Security-Policy": "default-src 'self'; script-src https://static.ads-twitter.com https://www.google-analytics.com 'sha256-q2sY7jlDS4SrxBg6oq/NBYk9XVSwDsterXWpH99SAn0='; img-src 'self' https://s3.amazonaws.com https://twitter.com https://pbs.twimg.com; font-src 'self' https://fonts.gstatic.com; style-src 'self' https://fonts.googleapis.com; frame-ancestors 'none';",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block"
}
}
When we load up the browser, everything looks right! Unfortunately we did miss one thing, which we can see in the console.
Refused to execute inline script because it violates the following Content Security Policy
directive: "script-src https://static.ads-twitter.com https://www.google-analytics.com".
Either the 'unsafe-inline' keyword, a hash ('sha256-q2sY7jlDS4SrxBg6oq/NBYk9XVSwDsterXWpH99SAn0='),
or a nonce ('nonce-...') is required to enable inline execution.
Even though we added https://www.google-analytics.com
to our script-src, because
it is being loaded in an inline script we'll need to allow it to be run
explicitly. The error message is kind enough to offer us a couple of options: "Either the 'unsafe-inline' keyword, a hash ('sha256-q2sY7jlDS4SrxBg6oq/NBYk9XVSwDsterXWpH99SAn0='), or a nonce ('nonce-...') is required to enable inline execution."
'unsafe-inline'
sounds, well, unsafe, so let's skip that. A nonce is a one-time-use number that would allow the inline script to be run. Nonces can be made safe, but as we're talking about static pages that's out of scope here. The hash provided in the error message is the actual sha-256 sum of the content of the inline code block, is more secure than the other options. It will keep attackers from changing the content of the Google Analytics inline script which makes it safer than an unprotected inline script. Like changing external dependencies, if we ever change that script tag we'll also need to change the sha-256 sum.
"Content-Security-Policy": "default-src 'self'; script-src https://static.ads-twitter.com https://www.google-analytics.com 'sha256-q2sY7jlDS4SrxBg6oq/NBYk9XVSwDsterXWpH99SAn0='; img-src 'self' https://s3.amazonaws.com https://twitter.com https://pbs.twimg.com; font-src 'self' https://fonts.gstatic.com; style-src 'self' https://fonts.googleapis.com; frame-ancestors 'none';"
We've added the SHA sum, and now Google Analytics is all set up!
The Results
Test | Pass | Score | Explanation |
---|---|---|---|
Content Security Policy | ✔ | +5 | Content Security Policy (CSP) implemented without 'unsafe-inline' or 'unsafe-eval' |
Cookies | ― | 0 | No cookies detected |
Cross-origin Resource Sharing | ✔ | 0 | Content is not visible via cross-origin resource sharing (CORS) files or headers |
HTTP Public Key Pinning | ― | 0 | HTTP Public Key Pinning (HPKP) header not implemented (optional) |
HTTP Strict Transport Security | ✔ | 0 | HTTP Strict Transport Security (HSTS) header set to a minimum of six months (15768000) |
Redirection | ✔ | 0 | Initial redirection is to https on same host, final destination is https |
Referrer Policy | ✔ | 0 | Referrer-Policy header not implemented (optional) |
Subresource Integrity | ― | 0 | Subresource Integrity (SRI) not implemented, but all scripts are loaded from a similar origin |
X-Content-Type-Options | ✔ | 0 | X-Content-Type-Options header set to "nosniff" |
X-Frame-Options | ✔ | +5 | X-Frame-Options (XFO) implemented via the CSP frame-ancestors directive |
X-XSS-Protection | ✔ | 0 | X-XSS-Protection header set to "1; mode=block" |
We're up to A+! Not bad for an hour's work, and our users are much more secure now when visiting our site.
Extra Credit
We're on the home stretch now. There are a few optional things we can do to beef up security and privacy even more.
Referrer Policy
Browsers include a Referrer
header that identifies where a user came from when visiting a new page. It's useful in tracking where users are coming from, but there are some privacy concerns with that. The Referrer-Policy
header controls when and how much information is provided.
no-referrer
. Tells the browser to never send theReferer
header.same-origin
. Send the referrer, but only on requests inside the site (e.g. /security-in-the-static-buildpack => /posts)strict-origin
. Send the referrer information to all origins, but only the URL sans path (e.g. https://example.com/)strict-origin-when-cross-origin
. Send full referrer information on same origin, but only the URL sans path on foreign origin.
no-referrer
can be used as a fallback for browsers as many of these options have not yet been implemented at this point.
Referrer-Policy: no-referrer, strict-origin-when-cross-origin
More?
Mozilla Observatory also has tests for Cookies and Subresource Integrity, but it was happy with the Keep Ruby Weird site after the changes we've already made so those are left as an exercise for the reader.
Final Result
Here is the final result of this change, excluding the opt-in "preload" directive to HSTS, and it is our recommendation for all static buildpack apps.
{
"headers": {
"/**": {
"Content-Security-Policy": "default-src 'self'; script-src https://static.ads-twitter.com https://www.google-analytics.com 'sha256-q2sY7jlDS4SrxBg6oq/NBYk9XVSwDsterXWpH99SAn0='; img-src 'self' https://s3.amazonaws.com https://twitter.com https://pbs.twimg.com; font-src 'self' https://fonts.gstatic.com; style-src 'self' https://fonts.googleapis.com; frame-ancestors 'none';",
"Referrer-Policy": "no-referrer, strict-origin-when-cross-origin",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block"
}
}
}