How to use Helmet in Express.js

Written by Ulises Gascón

Apr 08, 20206 min read

Understanding Helmet

If you include Express on your stack, I highly recommend to extend the HTTP Headers definitions using the middleware Helmet

I just adapted and extended the Official documentation from Helmet in order to adapt the content to our needs as follow:

Using Helmet

Helmet is a very simple and customizable. We will split the setup in three steps (Basic, extended and evolutionary)

Basic Setup

By default Helmet will handle several headers just by adding the middleware to the project

Headers in scope

Code

const helmet = require('helmet')

app.use(helmet())

Response headers

Express by default without helmet:

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 12
ETag: W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"
Date: Wed, 08 Apr 2020 07:00:48 GMT
Connection: keep-alive

With Helmet:

HTTP/1.1 200 OK
X-DNS-Prefetch-Control: off
X-Frame-Options: SAMEORIGIN
Strict-Transport-Security: max-age=15552000; includeSubDomains
X-Download-Options: noopen
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Content-Type: text/html; charset=utf-8
Content-Length: 12
ETag: W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"
Date: Wed, 08 Apr 2020 07:01:18 GMT
Connection: keep-alive

Extended rules

As we are going to support Adobe products (reader, flash...) and we will use HTTPS. We will extend the Helmet rules.

Optional: you need to have a report URL in order to monitor the violations of Expect Certificate Transparency performed by old browsers

Headers in scope

Code

const helmet = require('helmet')

app.use(helmet())

// Sets Expect-CT: enforce; max-age=30;
app.use(helmet.expectCt({
  enforce: true,
  maxAge: 30 // 30 minutes
}))

// Sets "X-Permitted-Cross-Domain-Policies: none"
app.use(helmet.permittedCrossDomainPolicies())

Response headers

HTTP/1.1 200 OK
X-DNS-Prefetch-Control: off
X-Frame-Options: SAMEORIGIN
Strict-Transport-Security: max-age=15552000; includeSubDomains
X-Download-Options: noopen
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Expect-CT: enforce, max-age=30
X-Permitted-Cross-Domain-Policies: none
Content-Type: text/html; charset=utf-8
Content-Length: 12
ETag: W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"
Date: Wed, 08 Apr 2020 07:21:28 GMT
Connection: keep-alive

Evolutionary rules

The most powerful headers for security are Feature Policy and Content Security Policy as they will define what content source (images, js, css..) or features (xhr, geolocation, iframes...) is allowed to be part of our application.

For sure these headers are going to evolve with our project requirements. By default we will lock down all the content sources and features in order to progressively open them.

A a best practice, please include violation routes in order to track any potential issue.

Headers in scope

A great starter policy for CSP allows images, scripts, AJAX, form actions, and CSS from the same origin, and does not allow any other resources to load (eg object, frame, media, etc). It is a good starting point for many sites.

Code

const helmet = require('helmet')

app.use(helmet())

// Sets Expect-CT: enforce; max-age=30;
app.use(helmet.expectCt({
  enforce: true,
  maxAge: 30 // 30 minutes
}))

// Sets "X-Permitted-Cross-Domain-Policies: none"
app.use(helmet.permittedCrossDomainPolicies())

// Sets "Referrer-Policy: same-origin".
app.use(helmet.referrerPolicy({ policy: 'same-origin' }))

// Sets "X-Frame-Options: DENY"
app.use(helmet.frameguard({ action: 'deny' }))

/* Sets "default-src: 'none'; 
script-src 'self'; 
connect-src 'self'; 
img-src 'self'; 
style-src 'self';
base-uri 'self';
form-action 'self'"
*/
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'none'"],
    scriptSrc: ["'self'"],
    connectSrc: ["'self'"],
    imgSrc: ["'self'"],
    styleSrc: ["'self'"],
    baseUri: ["'self'"],
    formAction: ["'self'"],
    reportUri: '/report-csp-violation',
    upgradeInsecureRequests: true,
    workerSrc: false
  }
}))

/*
Sets "Feature-Policy: sync-script 'self';
sync-xhr 'self';
accelerometer 'none';
ambient-light-sensor 'none';
autoplay 'none';
camera 'none';
document-domain 'none';
document-write 'none';
encrypted-media 'none';
font-display-late-swap 'none';
fullscreen 'none';
geolocation 'none';
gyroscope 'none';
layout-animations 'none';
legacy-image-formats 'none';
loading-frame-default-eager 'none';
magnetometer 'none';
microphone 'none';
midi 'none';
oversized-images 'none';
payment 'none';
picture-in-picture 'none';
serial 'none';speaker 'none';
unoptimized-images 'none';
unoptimized-lossless-images 'none';
unoptimized-lossy-images 'none';
unsized-media 'none';usb 'none';
vertical-scroll 'none';
vibrate 'none';
vr 'none';
wake-lock 'none';
xr 'none'"
*/

app.use(helmet.featurePolicy({
  features: {
    syncScript: ["'self'"],
    syncXhr: ["'self'"],
    accelerometer: ["'none'"],
    ambientLightSensor: ["'none'"],
    autoplay: ["'none'"],
    camera: ["'none'"],
    documentDomain: ["'none'"],
    documentWrite: ["'none'"],
    encryptedMedia: ["'none'"],
    fontDisplayLateSwap: ["'none'"],
    fullscreen: ["'none'"],
    geolocation: ["'none'"],
    gyroscope: ["'none'"],
    layoutAnimations: ["'none'"],
    legacyImageFormats: ["'none'"],
    loadingFrameDefaultEager: ["'none'"],
    magnetometer: ["'none'"],
    microphone: ["'none'"],
    midi: ["'none'"],
    oversizedImages: ["'none'"],
    payment: ["'none'"],
    pictureInPicture: ["'none'"],
    serial: ["'none'"],
    speaker: ["'none'"],
    unoptimizedImages: ["'none'"],
    unoptimizedLosslessImages: ["'none'"],
    unoptimizedLossyImages: ["'none'"],
    unsizedMedia: ["'none'"],
    usb: ["'none'"],
    verticalScroll: ["'none'"],
    vibrate: ["'none'"],
    vr: ["'none'"],
    wakeLock: ["'none'"],
    xr: ["'none'"]
  }
}))

Response headers

Connection: keep-alive
Content-Length: 12
Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; base-uri 'self'; form-action 'self'; report-uri /report-csp-violation; upgrade-insecure-requests
Content-Type: text/html; charset=utf-8
Date: Wed, 08 Apr 2020 07:52:55 GMT
ETag: W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"
Expect-CT: enforce, max-age=30
Feature-Policy: sync-script 'self';sync-xhr 'self';accelerometer 'none';ambient-light-sensor 'none';autoplay 'none';camera 'none';document-domain 'none';document-write 'none';encrypted-media 'none';font-display-late-swap 'none';fullscreen 'none';geolocation 'none';gyroscope 'none';layout-animations 'none';legacy-image-formats 'none';loading-frame-default-eager 'none';magnetometer 'none';microphone 'none';midi 'none';oversized-images 'none';payment 'none';picture-in-picture 'none';serial 'none';speaker 'none';unoptimized-images 'none';unoptimized-lossless-images 'none';unoptimized-lossy-images 'none';unsized-media 'none';usb 'none';vertical-scroll 'none';vibrate 'none';vr 'none';wake-lock 'none';xr 'none'
Strict-Transport-Security: max-age=15552000; includeSubDomains
Referrer-Policy: same-origin
X-Content-Type-Options: nosniff
X-DNS-Prefetch-Control: off
X-Download-Options: noopen
X-Frame-Options: DENY
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 1; mode=block

CSP Violations

Note: If you’re using a CSRF module like csurf, you might have problems handling these violations without a valid CSRF token. The fix is to put your CSP report route above csurf middleware.

// You need a JSON parser first.
app.use(bodyParser.json({
  type: ['json', 'application/csp-report']
}))

app.post('/report-csp-violation', (req, res) => {
  if (req.body) {
    console.log('CSP Violation: ', req.body)
  } else {
    console.log('CSP Violation: No data received!')
  }

  res.status(204).end()
}) 

Implementation Strategies

Using aggressive policies for CSP can be very very challenging. This is why is very important to have a clear implementation strategy.

Weapp from Scratch

If you are building a site from scratch you can always follow the lock down strategy and open the policies as soon as you got rules violations an you have a clear business reason, like enable a CDN to load your Images but not CSS or JS.

Webapp refactor

If there is a running platform, the best approach is to collect data before implementing any real policy. Use the reportOnly: true setup in order to get all the violations without affecting the current users.

This will let you a lot of time to understand the current dependencies and validate the rules based on a product requirements.

Other headers

There are other relevant headers that aren't part of Helmet.

Refs: