From b375ad31017d84063a0a0e3b1949bd43cc0b927d Mon Sep 17 00:00:00 2001 From: KaKa Date: Fri, 5 Mar 2021 17:44:18 +0800 Subject: [PATCH] feat: csp nonce generation (#115) * feat: csp nonce generation * chore: typings * test: add test case * docs: how to use csp nonce generation * docs: elaborate more on new feat * docs: elaborate more on new feat * docs: not closed anchor tag * docs: fix linkage to section --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ index.d.ts | 11 +++++++- index.js | 33 ++++++++++++++++++++++ index.test-d.ts | 21 +++++++++++++- test.js | 64 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1b2d945..0b7385c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,81 @@ fastify.listen(3000, err => { }) ``` +### Content-Security-Policy Nonce + +`fastify-helmet` provide a simple way for `csp nonces generation`. You can enable +this behavior by passing `{ enableCSPNonces: true }` into the options. Then, you can +retrieve the `nonces` through `reply.cspNonce`. + +Note: This feature is implemented inside this module. It is not a valid option or + supported by helmet. If you need to use helmet feature only for csp nonce you + can follow the example [here](#example---generate-by-helmet). + +#### Example - Generate by options + +```js +fastify.register( + helmet, + // enable csp nonces generation with default content-security-policy option + { enableCSPNonces: true } +) + +fastify.register( + helmet, + // customize content security policy with nonce generation + { + enableCSPNonces: true, + contentSecurityPolicy: { + directives: { + ... + } + } + } +) + +fastify.get('/', function(request, reply) { + // retrieve script nonce + reply.cspNonce.script + // retrieve style nonce + reply.cspNonce.style +}) +``` + +#### Example - Generate by helmet + +```js +fastify.register( + helmet, + { + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: [ + function (req, res) { + // "res" here is actually "reply.raw" in fastify + res.scriptNonce = crypto.randomBytes(16).toString('hex') + } + ], + styleSrc: [ + function (req, res) { + // "res" here is actually "reply.raw" in fastify + res.styleNonce = crypto.randomBytes(16).toString('hex') + } + ] + } + } + } +) + +fastify.get('/', function(request, reply) { + // you can access the generated nonce by "reply.raw" + reply.raw.scriptNonce + reply.raw.styleNonce +}) + +``` + + ## How it works `fastify-helmet` is just a tiny wrapper around helmet that adds an `'onRequest'` hook. diff --git a/index.d.ts b/index.d.ts index 672d69f..1ed65ac 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,7 +1,16 @@ import { FastifyPluginCallback } from "fastify"; import helmet = require("helmet"); -type FastifyHelmetOptions = Parameters[0]; +declare module 'fastify' { + interface FastifyReply { + cspNonce: { + script: string + style: string + } + } +} + +type FastifyHelmetOptions = Parameters[0] & { enableCSPNonces?: boolean }; export const fastifyHelmet: FastifyPluginCallback>; diff --git a/index.js b/index.js index 5c573e9..7f50973 100644 --- a/index.js +++ b/index.js @@ -2,14 +2,47 @@ const fp = require('fastify-plugin') const helmet = require('helmet') +const crypto = require('crypto') module.exports = fp(function (app, options, next) { + const enableCSPNonces = options.enableCSPNonces + // clear options as helmet will throw when any options is "true" + options.enableCSPNonces = undefined + const middleware = helmet(options) app.addHook('onRequest', function (req, reply, next) { middleware(req.raw, reply.raw, next) }) + if (enableCSPNonces) { + // outside onRequest hooks so that it can be reused in every route + const cspDirectives = options.contentSecurityPolicy ? options.contentSecurityPolicy.directives : helmet.contentSecurityPolicy.getDefaultDirectives() + const cspReportOnly = options.contentSecurityPolicy ? options.contentSecurityPolicy.reportOnly : undefined + + app.decorateReply('cspNonce', null) + app.addHook('onRequest', function (req, reply, next) { + // create csp nonce + reply.cspNonce = { + script: crypto.randomBytes(16).toString('hex'), + style: crypto.randomBytes(16).toString('hex') + } + + // push nonce to csp + // allow both script-src or scriptSrc syntax + const scriptKey = Array.isArray(cspDirectives['script-src']) ? 'script-src' : 'scriptSrc' + cspDirectives[scriptKey] = Array.isArray(cspDirectives.scriptSrc) ? cspDirectives.scriptSrc : [] + cspDirectives[scriptKey].push('nonce-' + reply.cspNonce.script) + // allow both style-src or styleSrc syntax + const styleKey = Array.isArray(cspDirectives['style-src']) ? 'style-src' : 'styleSrc' + cspDirectives[styleKey] = Array.isArray(cspDirectives.styleSrc) ? cspDirectives.styleSrc : [] + cspDirectives[styleKey].push('nonce-' + reply.cspNonce.style) + + const cspMiddleware = helmet.contentSecurityPolicy({ directives: cspDirectives, reportOnly: cspReportOnly }) + cspMiddleware(req.raw, reply.raw, next) + }) + } + next() }, { fastify: '3.x', diff --git a/index.test-d.ts b/index.test-d.ts index 87c2145..5c27614 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,5 +1,6 @@ -import fastifyHelmet from "."; import fastify from "fastify"; +import { expectType } from "tsd"; +import fastifyHelmet from "."; const app = fastify(); @@ -54,3 +55,21 @@ app.register(fastifyHelmet, { // noSniff: false, // xssFilter: false }); + + +app.register(fastifyHelmet, { enableCSPNonces: true }); +app.register(fastifyHelmet, { + enableCSPNonces: true, + contentSecurityPolicy: { + directives: { + 'directive-1': ['foo', 'bar'] + }, + reportOnly: true + }, +}); +app.get('/', function(request, reply) { + expectType<{ + script: string + style: string + }>(reply.cspNonce) +}) \ No newline at end of file diff --git a/test.js b/test.js index ec4bae8..47df2ed 100644 --- a/test.js +++ b/test.js @@ -134,3 +134,67 @@ test('default CSP directives can be accessed through plugin export', (t) => { t.end() }) }) + +test('auto generate nonce pre request', async (t) => { + t.plan(7) + + const fastify = Fastify() + fastify.register(helmet, { + enableCSPNonces: true + }) + + fastify.get('/', (request, reply) => { + t.ok(reply.cspNonce) + reply.send(reply.cspNonce) + }) + + let cspCache, res + + try { + res = await fastify.inject({ method: 'GET', url: '/' }) + cspCache = res.json() + t.ok(cspCache.script) + t.ok(cspCache.style) + + res = await fastify.inject({ method: 'GET', url: '/' }) + const newCsp = res.json() + t.notEqual(cspCache, newCsp) + t.ok(cspCache.script) + t.ok(cspCache.style) + } catch (err) { + t.error(err) + } +}) + +test('allow merging options for enableCSPNonces', async (t) => { + t.plan(4) + + const fastify = Fastify() + fastify.register(helmet, { + enableCSPNonces: true, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'"] + } + } + }) + + fastify.get('/', (request, reply) => { + t.ok(reply.cspNonce) + reply.send(reply.cspNonce) + }) + + try { + const res = await fastify.inject({ method: 'GET', url: '/' }) + const cspCache = res.json() + t.ok(cspCache.script) + t.ok(cspCache.style) + t.includes(res.headers, { + 'content-security-policy': `default-src 'self';script-src 'self' nonce-${cspCache.script};style-src 'self' nonce-${cspCache.style}` + }) + } catch (err) { + t.error(err) + } +})