Skip to content

Instantly share code, notes, and snippets.

@danrubins
Last active December 20, 2020 02:05
Show Gist options
  • Save danrubins/31248506873a006ba5e6b459a81855c2 to your computer and use it in GitHub Desktop.
Save danrubins/31248506873a006ba5e6b459a81855c2 to your computer and use it in GitHub Desktop.
Legal Robot's options for helmet.js

Security Headers at Legal Robot

We're big fans of open source software at Legal Robot. We also know that getting security right is a tough job, so we want to share some of the useful tools we use to build and run Legal Robot and keep it secure.

We are also proud to run Legal Robot on the Meteor framework for Node.js. With this recent change, Helmet.js becomes the official recommendation for security headers in Meteor, replacing the previous Meteor browser-policy package.

One of the most helpful tools in our Meteor security toolbox at Legal Robot is Content Security Policy (CSP)  — basically, our server tells the browser what code it is allowed to run and how to handle something like code injection from a malicious browser extension.

CSP can be quite tricky, but there are some excellent tools out there to help, like Google's CSP Evaluator, Report-URI's CSP Builder, CSP documentation from Mozilla, CSPValidator.org. Plus, the awesome SecurityHeaders.com since Helmet.js also helps you set other headers aside from CSP.

Line-by-line Detail

If you want to see the whole thing, our full code for setting CSP and other security headers with helmet.js is in this Github Gist.

contentSecurityPolicy': {
  'directives': { ... },

CSP is a powerful but painfully complex tool to control how the code that a server loads to a browser gets executed. Within CSP, there are different directives to control various controls in the browser. Most commonly used are the *-src directives that let you limit the domains that are permitted to load scripts, images, etc.

Crafting a good CSP can be difficult and time consuming. The tools listed above can help for the basics, but I'll walk you through some of our Meteor-specific challenges here.

A good baseline policy is provided in the security section of the Meteor Guide:

contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'"],
    connectSrc: ["*"],
    imgSrc: ["'self'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
  },
  browserSniff: false
})

We started with a similar policy, but when we removed the unsafe-inline keyword from the script-src directive, Meteor was blocked from loading anything to the client. When Meteor starts up on the client, one of the first things that runs is an inline script that loads a variable called __meteor_runtime_config__ which is a serialized version of Meteor.settings. The problem is this serialized JSON changes with each server build so the Autoupdate package can perform a hot reload. Unfortunately, this means we need to get the hash of that inline script every time we rebuild, then we drop it into the script-src directive.

'dnsPrefetchControl': true,

DNS Prefetching isn't a big part of our threat model, but it could protect our users from information leakage when dealing with private documents, and there is little downside to blocking DNS prefetching.

'expectCt': {
  'enforce': true,
  'maxAge': 604800,
  'reportUri': 'https://legalrobot.report-uri.com/r/d/ct/enforce'
},

Certificate Transparency is a relatively new paradigm in securing the web, and this option lets us tell the browser that we at Legal Robot will only use TLS certificates from Certificate Authorities (CAs) that support public logging in certificate transparency logs. Certificate logging does not cure all ills, but it does enable us to receive and alert when a new TLS certificate is created for one of our domains. If we were not expecting a new certificate, we would then know to ask the issuing CA to revoke the certificate.

The reportUri property lets us monitor usage and violations of this policy through a free/low-cost service at report-uri.com.

'frameguard': { 'action': 'deny' },

The X-Frame-Options header is pretty important because it blocks clickjacking attacks, which can occur when your page is put into a frame by another page. At this point in the internet's evolution, it's fairly irresponsible not to set this header. You don't need to deny everything as we do at Legal Robot, but we don't use frames in our app so we are confident there are no downsides to a very restrictive framing policy.

'hidePoweredBy': true,

We are very open about the technology we use at Legal Robot (obviously) and Meteor/node.js doesn't send the X-Powered-By header anyway, but there is no downside to blocking this header.

'hsts': true,

Plain old HTTP is awful. There is no reason not to use a secure channel. There is even good evidence that HTTPS can be faster in practice. HTTP Strict Transport Security (HSTS) forces the browser to use a secure connection.

'ieNoOpen': true,

We use this option to set the X-Download-Options header, which prevents older versions of IE from automatically opening downloaded files in the browser's context.

'noCache': true,

We are still in beta with many of our products at Legal Robot and we use Continuous Integration/Continuous Deployment, so there are constant changes to our codebase. Just in case, we use this option to disable cacheing.

'noSniff': true,

This option lets us set the X-Content-Type-Options header, which keeps the browser from "sniffing" the content type and makes it stick with the content-type we send.

'referrerPolicy': { 'policy': 'no-referrer' },

When someone clicks on a link from our website, it could be from a private document and the owner may not want to share the fact that they are analyzing a document in Legal Robot with the owner of that link.

'xssFilter': true

This option sets the X-XSS-Protection header to prevent a class of attacks called reflected cross-site scripting (XSS).

Aren't you worried about helping someone hack you?

Nope, all our code really does is creates a content security policy and configures other security options; everyone can already see the result because we send it to the client's browser (albeit, in a much less readable format).

Plus, we love working with hackers through our HackerOne bug bounty program! If you find any problems with this code, submit a report.

import helmet from 'helmet'
import { helmetOptions } from '/imports/utils/server/helmet'
Meteor.startup(() => {
WebApp.connectHandlers.use(helmet(helmetOptions()))
})
import { HTTP } from 'meteor/http'
import crypto from 'crypto'
import { Autoupdate } from 'meteor/autoupdate'
export function helmetOptions() {
const domain = Meteor.absoluteUrl().replace(/http(s)*:\/\//, '').replace(/\/$/, '')
const s = Meteor.absoluteUrl().match(/(?!=http)s(?=:\/\/)/) ? 's' : ''
const test = Meteor.absoluteUrl().match('https://app.legalrobot.com') ? '' : '-test'
const sri = HTTP.get(`https://static.legalrobot${test}.com/sri.json`)
const runtimeConfig = Object.assign(__meteor_runtime_config__, Autoupdate, { accountsConfigCalled: true })
const runtimeConfigHash = crypto.createHash('sha256').update(`__meteor_runtime_config__ = JSON.parse(decodeURIComponent("${encodeURIComponent(JSON.stringify(runtimeConfig))}"))`).digest('base64')
const opt = {
'contentSecurityPolicy': {
'browserSniff': false,
'directives': {
'blockAllMixedContent': true,
'childSrc': [
"'self'",
'https://checkout.stripe.com',
'https://js.stripe.com'
],
'connectSrc': [
"'self'",
`http${s}://${domain}`,
`ws${s}://${domain}`,
'https://*.stripe.com',
`https://s3.amazonaws.com/${Meteor.settings.private.aws.files.bucket}`,
'https://apm-engine.meteor.com',
`https://*.legalrobot.com`
],
'defaultSrc': [
"'self'"
],
'fontSrc': [
"'self'",
'https://fonts.gstatic.com',
`https://*.legalrobot.com`,
'data:'
],
'formAction': ["'self'"],
'frameAncestors': ["'self'"],
'frameSrc': [
"'self'",
'https://*.stripe.com'
],
'imgSrc': [
"'self'",
'https://*.stripe.com',
`https://*.legalrobot.com`,
'data:',
'blob:'
],
'manifestSrc': ["'self'"],
'mediaSrc': ["'self'"],
'objectSrc': ["'none'"],
'reportUri': 'https://legalrobot.report-uri.com/r/d/csp/enforce',
'sandbox': [
'allow-same-origin',
'allow-scripts',
'allow-forms',
'allow-popups',
'allow-popups-to-escape-sandbox'
],
'scriptSrc': [
"'self'",
"'unsafe-eval'",
`'sha256-${runtimeConfigHash}'`,
"'sha256-AAsmjL2rJpChx2IfaYPEM+cben4QTg4GLjB0qeYWvNU='", // Cloudflare
"'sha256-ttDcT9F8YnpWeNY0d1C0++IqjxDzhB6tAl+Z7UOKUQE='", // Cloudflare
`'${_.where(sri.data, {group: 'jquery', file: 'jquery.min.js', latest: true})[0].sri}'`,
`'${_.where(sri.data, {group: 'semantic-ui', file: 'semantic.min.js', latest: true})[0].sri}'`,
`'${_.where(sri.data, {group: 'toastr.js', file: 'toastr.min.js', latest: true})[0].sri}'`,
`https://*.legalrobot.com`,
'https://ajax.cloudflare.com',
'https://*.stripe.com'
],
'styleSrc': [
"'self'",
"'unsafe-inline'",
'https://fonts.googleapis.com',
`https://*.legalrobot.com`
],
'workerSrc': ["'self'", 'blob:']
}
},
'dnsPrefetchControl': true,
'expectCt': {
'enforce': true,
'maxAge': 604800,
'reportUri': 'https://legalrobot.report-uri.com/r/d/ct/enforce'
},
'frameguard': { 'action': 'deny' },
'hidePoweredBy': true,
'hsts': true,
'ieNoOpen': true,
'noCache': true,
'noSniff': true,
'referrerPolicy': { 'policy': 'no-referrer' },
'xssFilter': true
}
if (s === '') {
opt.contentSecurityPolicy.directives.blockAllMixedContent = false
opt.contentSecurityPolicy.directives.scriptSrc = [`'self'`, `'unsafe-eval'`, `'unsafe-inline'`, 'https://*']
}
if (test !== '') {
opt.contentSecurityPolicy.directives.connectSrc.push(`https://*.legalrobot${test}.com`)
opt.contentSecurityPolicy.directives.fontSrc.push(`https://*.legalrobot${test}.com`)
opt.contentSecurityPolicy.directives.imgSrc.push(`https://*.legalrobot${test}.com`)
opt.contentSecurityPolicy.directives.scriptSrc.push(`https://*.legalrobot${test}.com`)
opt.contentSecurityPolicy.directives.styleSrc.push(`https://*.legalrobot${test}.com`)
}
return opt
}
@jankapunkt
Copy link

jankapunkt commented Sep 1, 2020

Thanks a lot for this awesome resource! There are things I'd like to ask

  1. is there a specific reason to add http${s}://${domain} and ws${s}://${domain} to connectSrc when 'self' is already in place?
  2. The latest helmet does not support true as middleware option anymore, care to take a look at this?
  3. Line 104 opt.contentSecurityPolicy.directives.blockAllMixedContent = false makes it impossible to start the app in dev mode

@jankapunkt
Copy link

jankapunkt commented Sep 4, 2020

I also found the sha-256 hasg not matching the __meteor_runtime_config__ so I checked and found that I had to manually add the AutoUpdate update versions for the respective architectures:

  const runtimeConfig = Object.assign(__meteor_runtime_config__, Autoupdate, { accountsConfigCalled: true })


  // add missing client versions to runtimeconfig
  Object.keys(WebApp.clientPrograms).forEach(arch => {
    __meteor_runtime_config__.versions[arch] = {
      version: Autoupdate.autoupdateVersion || WebApp.clientPrograms[arch].version(),
      versionRefreshable: Autoupdate.autoupdateVersion || WebApp.clientPrograms[arch].versionRefreshable(),
      versionNonRefreshable: Autoupdate.autoupdateVersion || WebApp.clientPrograms[arch].versionNonRefreshable(),
    };
  });

  __meteor_runtime_config__.isModern = true

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment