fastify-helmet
Advanced tools
Comparing version 7.0.0 to 7.0.1
168
index.js
@@ -7,3 +7,3 @@ 'use strict' | ||
function helmetPlugin (fastify, options, next) { | ||
async function helmetPlugin (fastify, options) { | ||
// helmet will throw when any option is explicitly set to "true" | ||
@@ -15,61 +15,70 @@ // using ECMAScript destructuring is a clean workaround as we do not need to alter options | ||
// We initialize the `helmet` reply decorator | ||
fastify.decorateReply('helmet', null) | ||
// We initialize the `helmet` reply decorator only if it does not already exists | ||
if (!fastify.hasReplyDecorator('helmet')) { | ||
fastify.decorateReply('helmet', null) | ||
} | ||
// We will add the onRequest helmet middleware functions through the onRoute hook if needed | ||
// We initialize the `cspNonce` reply decorator only if it does not already exists | ||
if (!fastify.hasReplyDecorator('cspNonce')) { | ||
fastify.decorateReply('cspNonce', null) | ||
} | ||
fastify.addHook('onRoute', (routeOptions) => { | ||
if (typeof routeOptions.helmet !== 'undefined') { | ||
if (typeof routeOptions.helmet === 'object') { | ||
const { enableCSPNonces: enableRouteCSPNonces, ...helmetRouteConfiguration } = routeOptions.helmet | ||
// If route helmet options are set they overwrite the global helmet configuration | ||
const mergedHelmetConfiguration = Object.assign({}, globalConfiguration, helmetRouteConfiguration) | ||
buildRouteHooks(mergedHelmetConfiguration, routeOptions) | ||
if (enableRouteCSPNonces) { | ||
routeOptions.onRequest.push(buildCSPNonce(fastify, mergedHelmetConfiguration)) | ||
} | ||
routeOptions.config = Object.assign(routeOptions.config || Object.create(null), { helmet: routeOptions.helmet }) | ||
} else if (routeOptions.helmet === false) { | ||
// don't apply any helmet settings but decorate the reply with a fallback to the | ||
// global helmet options | ||
buildRouteHooks(globalConfiguration, routeOptions, true) | ||
routeOptions.config = Object.assign(routeOptions.config || Object.create(null), { helmet: { skipRoute: true } }) | ||
} else { | ||
throw new Error('Unknown value for route helmet configuration') | ||
} | ||
} else if (isGlobal) { | ||
// if the plugin is set globally (meaning that all the routes will be decorated) | ||
// As the endpoint, does not have a custom helmet configuration, use the global one. | ||
buildRouteHooks(globalConfiguration, routeOptions) | ||
} | ||
}) | ||
if (enableCSPNonces) { | ||
routeOptions.onRequest.push(buildCSPNonce(fastify, globalConfiguration)) | ||
} | ||
fastify.addHook('onRequest', async (request, reply) => { | ||
const { helmet: routeOptions } = request.context.config | ||
if (typeof routeOptions !== 'undefined') { | ||
const { enableCSPNonces: enableRouteCSPNonces, skipRoute, ...helmetRouteConfiguration } = routeOptions | ||
// If route helmet options are set they overwrite the global helmet configuration | ||
const mergedHelmetConfiguration = Object.assign(Object.create(null), globalConfiguration, helmetRouteConfiguration) | ||
// We decorate the reply with a fallback to the route scoped helmet options | ||
return replyDecorators(request, reply, mergedHelmetConfiguration, enableRouteCSPNonces) | ||
} else { | ||
// if no options are specified and the plugin is not global, then we still want to decorate | ||
// the reply in this case | ||
buildRouteHooks(globalConfiguration, routeOptions, true) | ||
// We decorate the reply with a fallback to the global helmet options | ||
return replyDecorators(request, reply, globalConfiguration, enableCSPNonces) | ||
} | ||
}) | ||
next() | ||
} | ||
fastify.addHook('onRequest', (request, reply, next) => { | ||
const { helmet: routeOptions } = request.context.config | ||
function buildCSPNonce (fastify, configuration) { | ||
const cspDirectives = configuration.contentSecurityPolicy | ||
? configuration.contentSecurityPolicy.directives | ||
: helmet.contentSecurityPolicy.getDefaultDirectives() | ||
const cspReportOnly = configuration.contentSecurityPolicy | ||
? configuration.contentSecurityPolicy.reportOnly | ||
: undefined | ||
if (typeof routeOptions !== 'undefined') { | ||
const { enableCSPNonces: enableRouteCSPNonces, skipRoute, ...helmetRouteConfiguration } = routeOptions | ||
return function (request, reply, next) { | ||
if (!fastify.hasReplyDecorator('cspNonce')) { | ||
fastify.decorateReply('cspNonce', null) | ||
if (skipRoute === true) { | ||
// If helmet route option is set to `false` we skip the route | ||
} else { | ||
// If route helmet options are set they overwrite the global helmet configuration | ||
const mergedHelmetConfiguration = Object.assign(Object.create(null), globalConfiguration, helmetRouteConfiguration) | ||
return buildHelmetOnRoutes(request, reply, mergedHelmetConfiguration, enableRouteCSPNonces) | ||
} | ||
return next() | ||
} else if (isGlobal) { | ||
// if the plugin is set globally (meaning that all the routes will be decorated) | ||
// As the endpoint, does not have a custom helmet configuration, use the global one. | ||
return buildHelmetOnRoutes(request, reply, globalConfiguration, enableCSPNonces) | ||
} else { | ||
// if the plugin is not global we can skip the route | ||
} | ||
// prevent object reference: https://github.com/fastify/fastify-helmet/issues/118 | ||
const directives = { ...cspDirectives } | ||
return next() | ||
}) | ||
} | ||
// create csp nonce | ||
async function replyDecorators (request, reply, configuration, enableCSP) { | ||
if (enableCSP) { | ||
reply.cspNonce = { | ||
@@ -79,53 +88,52 @@ script: randomBytes(16).toString('hex'), | ||
} | ||
} | ||
// push nonce to csp | ||
// allow both script-src or scriptSrc syntax | ||
const scriptKey = Array.isArray(directives['script-src']) ? 'script-src' : 'scriptSrc' | ||
directives[scriptKey] = Array.isArray(directives[scriptKey]) ? [...directives[scriptKey]] : [] | ||
directives[scriptKey].push(`'nonce-${reply.cspNonce.script}'`) | ||
// allow both style-src or styleSrc syntax | ||
const styleKey = Array.isArray(directives['style-src']) ? 'style-src' : 'styleSrc' | ||
directives[styleKey] = Array.isArray(directives[styleKey]) ? [...directives[styleKey]] : [] | ||
directives[styleKey].push(`'nonce-${reply.cspNonce.style}'`) | ||
reply.helmet = function (opts) { | ||
const helmetConfiguration = opts | ||
? Object.assign(Object.create(null), configuration, opts) | ||
: configuration | ||
const cspMiddleware = helmet.contentSecurityPolicy({ directives, reportOnly: cspReportOnly }) | ||
cspMiddleware(request.raw, reply.raw, next) | ||
return helmet(helmetConfiguration)(request.raw, reply.raw, done) | ||
} | ||
} | ||
function buildRouteHooks (configuration, routeOptions, decorateOnly) { | ||
if (Array.isArray(routeOptions.onRequest)) { | ||
routeOptions.onRequest.push(addHelmetReplyDecorator) | ||
} else if (typeof routeOptions.onRequest === 'function') { | ||
routeOptions.onRequest = [routeOptions.onRequest, addHelmetReplyDecorator] | ||
} else { | ||
routeOptions.onRequest = [addHelmetReplyDecorator] | ||
} | ||
async function buildHelmetOnRoutes (request, reply, configuration, enableCSP) { | ||
if (enableCSP === true) { | ||
const cspDirectives = configuration.contentSecurityPolicy | ||
? configuration.contentSecurityPolicy.directives | ||
: helmet.contentSecurityPolicy.getDefaultDirectives() | ||
const cspReportOnly = configuration.contentSecurityPolicy | ||
? configuration.contentSecurityPolicy.reportOnly | ||
: undefined | ||
const middleware = helmet(configuration) | ||
// We get the csp nonce from the reply | ||
const { script: scriptCSPNonce, style: styleCSPNonce } = reply.cspNonce | ||
function addHelmetReplyDecorator (request, reply, next) { | ||
// We decorate `reply.helmet` with all helmet middleware functions | ||
// NB: we allow users to pass a custom helmet options object with a fallback | ||
// to global helmet configuration. | ||
reply.helmet = (opts) => opts | ||
? helmet(opts)(request.raw, reply.raw) | ||
: helmet(configuration)(request.raw, reply.raw) | ||
// We prevent object reference: https://github.com/fastify/fastify-helmet/issues/118 | ||
const directives = { ...cspDirectives } | ||
next() | ||
} | ||
// We push nonce to csp | ||
// We allow both 'script-src' or 'scriptSrc' syntax | ||
const scriptKey = Array.isArray(directives['script-src']) ? 'script-src' : 'scriptSrc' | ||
directives[scriptKey] = Array.isArray(directives[scriptKey]) ? [...directives[scriptKey]] : [] | ||
directives[scriptKey].push(`'nonce-${scriptCSPNonce}'`) | ||
// allow both style-src or styleSrc syntax | ||
const styleKey = Array.isArray(directives['style-src']) ? 'style-src' : 'styleSrc' | ||
directives[styleKey] = Array.isArray(directives[styleKey]) ? [...directives[styleKey]] : [] | ||
directives[styleKey].push(`'nonce-${styleCSPNonce}'`) | ||
if (decorateOnly) { | ||
return | ||
} | ||
const contentSecurityPolicy = { directives, reportOnly: cspReportOnly } | ||
const mergedHelmetConfiguration = Object.assign(Object.create(null), configuration, { contentSecurityPolicy }) | ||
// At this point `routeOptions.onRequest` is an array | ||
// we just have to push our `onRequest` function | ||
routeOptions.onRequest.push(onRequest) | ||
function onRequest (request, reply, next) { | ||
middleware(request.raw, reply.raw, next) | ||
helmet(mergedHelmetConfiguration)(request.raw, reply.raw, done) | ||
} else { | ||
helmet(configuration)(request.raw, reply.raw, done) | ||
} | ||
} | ||
// Helmet forward a typeof Error object so we just need to throw it as is. | ||
function done (error) { | ||
if (error) throw error | ||
} | ||
module.exports = fp(helmetPlugin, { | ||
@@ -132,0 +140,0 @@ fastify: '3.x', |
{ | ||
"name": "fastify-helmet", | ||
"version": "7.0.0", | ||
"version": "7.0.1", | ||
"description": "Important security headers for Fastify", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
'use strict' | ||
const stream = require('stream') | ||
const { test } = require('tap') | ||
const fp = require('fastify-plugin') | ||
const Fastify = require('fastify') | ||
@@ -378,6 +380,3 @@ const helmet = require('..') | ||
path: '/three', | ||
method: 'GET', | ||
headers: { | ||
'accept-encoding': 'deflate' | ||
} | ||
method: 'GET' | ||
}).then((response) => { | ||
@@ -423,2 +422,42 @@ t.equal(response.statusCode, 200) | ||
test('It should not throw when trying to add the `helmet` and `cspNonce` reply decorators if they already exist', async (t) => { | ||
t.plan(7) | ||
const fastify = Fastify() | ||
// We decorate the reply with helmet and cspNonce to trigger the existence check | ||
fastify.decorateReply('helmet', null) | ||
fastify.decorateReply('cspNonce', null) | ||
await fastify.register(helmet, { enableCSPNonces: true, global: true }) | ||
fastify.get('/', async (request, reply) => { | ||
t.ok(reply.helmet) | ||
t.not(reply.helmet, null) | ||
t.ok(reply.cspNonce) | ||
t.not(reply.cspNonce, null) | ||
reply.send(reply.cspNonce) | ||
}) | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/' | ||
}) | ||
const cspCache = response.json() | ||
t.ok(cspCache.script) | ||
t.ok(cspCache.style) | ||
const expected = { | ||
'x-dns-prefetch-control': 'off', | ||
'x-frame-options': 'SAMEORIGIN', | ||
'x-download-options': 'noopen', | ||
'x-content-type-options': 'nosniff', | ||
'x-xss-protection': '0' | ||
} | ||
t.has(response.headers, expected) | ||
}) | ||
test('It should be able to pass custom options to the `helmet` reply decorator', async (t) => { | ||
@@ -459,3 +498,3 @@ t.plan(4) | ||
test('It should be able to conditionally apply the middlewares through the `helmet` reply decorator', async (t) => { | ||
t.plan(8) | ||
t.plan(10) | ||
@@ -489,19 +528,247 @@ const fastify = Fastify() | ||
} | ||
let response | ||
response = await fastify.inject({ | ||
{ | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/no-frameguard' | ||
}) | ||
t.equal(response.statusCode, 200) | ||
t.notMatch(response.headers, maybeExpected) | ||
t.has(response.headers, expected) | ||
} | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/no-frameguard' | ||
path: '/frameguard' | ||
}) | ||
t.notMatch(response.headers, maybeExpected) | ||
t.equal(response.statusCode, 200) | ||
t.has(response.headers, maybeExpected) | ||
t.has(response.headers, expected) | ||
}) | ||
response = await fastify.inject({ | ||
test('It should apply helmet headers when returning error messages', async (t) => { | ||
t.plan(6) | ||
const fastify = Fastify() | ||
await fastify.register(helmet, { enableCSPNonces: true }) | ||
fastify.get('/', { | ||
onRequest: async (request, reply) => { | ||
reply.code(401) | ||
reply.send({ message: 'Unauthorized' }) | ||
} | ||
}, async (request, reply) => { | ||
return { message: 'ok' } | ||
}) | ||
fastify.get('/error-handler', { | ||
}, async (request, reply) => { | ||
return Promise.reject(new Error('error handler triggered')) | ||
}) | ||
const expected = { | ||
'x-dns-prefetch-control': 'off', | ||
'x-frame-options': 'SAMEORIGIN', | ||
'x-download-options': 'noopen', | ||
'x-content-type-options': 'nosniff', | ||
'x-xss-protection': '0' | ||
} | ||
{ | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/' | ||
}) | ||
t.equal(response.statusCode, 401) | ||
t.has(response.headers, expected) | ||
} | ||
{ | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/error-handler' | ||
}) | ||
t.equal(response.statusCode, 500) | ||
t.has(response.headers, expected) | ||
} | ||
{ | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/404-route' | ||
}) | ||
t.equal(response.statusCode, 404) | ||
t.has(response.headers, expected) | ||
} | ||
}) | ||
// To avoid regressions. | ||
// ref.: https://github.com/fastify/fastify-helmet/pull/169#issuecomment-1017413835 | ||
test('It should not return a fastify `FST_ERR_REP_ALREADY_SENT - Reply already sent` error', async (t) => { | ||
t.plan(5) | ||
const logs = [] | ||
const destination = new stream.Writable({ | ||
write: function (chunk, encoding, next) { | ||
logs.push(JSON.parse(chunk)) | ||
next() | ||
} | ||
}) | ||
const fastify = Fastify({ logger: { level: 'info', stream: destination } }) | ||
await fastify.register(helmet) | ||
await fastify.register(fp(async (instance, options) => { | ||
instance.addHook('onRequest', async (request, reply) => { | ||
const unauthorized = new Error('Unauthorized') | ||
const errorResponse = (err) => { | ||
return { error: err.message } | ||
} | ||
// We want to crash in the scope of this test | ||
const crash = request.context.config.fail | ||
Promise.resolve(crash).then((fail) => { | ||
if (fail === true) { | ||
reply.code(401) | ||
reply.send(errorResponse(unauthorized)) | ||
return reply | ||
} | ||
}).catch(() => undefined) | ||
}) | ||
}, { | ||
name: 'regression-plugin-test' | ||
})) | ||
fastify.get('/fail', { | ||
config: { fail: true } | ||
}, async (request, reply) => { | ||
return { message: 'unreachable' } | ||
}) | ||
const expected = { | ||
'x-dns-prefetch-control': 'off', | ||
'x-frame-options': 'SAMEORIGIN', | ||
'x-download-options': 'noopen', | ||
'x-content-type-options': 'nosniff', | ||
'x-xss-protection': '0' | ||
} | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/frameguard' | ||
path: '/fail' | ||
}) | ||
t.has(response.headers, maybeExpected) | ||
const failure = logs.find((entry) => entry.err && entry.err.statusCode === 500) | ||
if (failure) { | ||
t.not(failure.err.message, 'Reply was already sent.') | ||
t.not(failure.err.name, 'FastifyError') | ||
t.not(failure.err.code, 'FST_ERR_REP_ALREADY_SENT') | ||
t.not(failure.err.statusCode, 500) | ||
t.not(failure.msg, 'Reply already sent') | ||
} | ||
t.equal(failure, undefined) | ||
t.equal(response.statusCode, 401) | ||
t.has(response.headers, expected) | ||
t.equal(JSON.parse(response.payload).error, 'Unauthorized') | ||
t.not(JSON.parse(response.payload).message, 'unreachable') | ||
}) | ||
test('It should forward `helmet` errors to `fastify-helmet`', async (t) => { | ||
t.plan(3) | ||
const fastify = Fastify() | ||
await fastify.register(helmet, { | ||
contentSecurityPolicy: { | ||
directives: { | ||
defaultSrc: ["'self'", () => 'bad;value'] | ||
} | ||
} | ||
}) | ||
fastify.get('/', async (request, reply) => { | ||
return { message: 'ok' } | ||
}) | ||
const notExpected = { | ||
'x-dns-prefetch-control': 'off', | ||
'x-frame-options': 'SAMEORIGIN', | ||
'x-download-options': 'noopen', | ||
'x-content-type-options': 'nosniff', | ||
'x-xss-protection': '0' | ||
} | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/' | ||
}) | ||
t.equal(response.statusCode, 500) | ||
t.equal( | ||
JSON.parse(response.payload).message, | ||
'Content-Security-Policy received an invalid directive value for "default-src"' | ||
) | ||
t.notMatch(response.headers, notExpected) | ||
}) | ||
test('It should be able to catch `helmet` errors with a fastify `onError` hook', async (t) => { | ||
t.plan(7) | ||
const errorDetected = [] | ||
const fastify = Fastify() | ||
await fastify.register(helmet, { | ||
contentSecurityPolicy: { | ||
directives: { | ||
defaultSrc: ["'self'", () => 'bad;value'] | ||
} | ||
} | ||
}) | ||
fastify.addHook('onError', async (request, reply, error) => { | ||
if (error) { | ||
t.ok(error) | ||
errorDetected.push(error) | ||
} | ||
}) | ||
fastify.get('/', async (request, reply) => { | ||
return { message: 'ok' } | ||
}) | ||
const notExpected = { | ||
'x-dns-prefetch-control': 'off', | ||
'x-frame-options': 'SAMEORIGIN', | ||
'x-download-options': 'noopen', | ||
'x-content-type-options': 'nosniff', | ||
'x-xss-protection': '0' | ||
} | ||
t.equal(errorDetected.length, 0) | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/' | ||
}) | ||
t.equal(response.statusCode, 500) | ||
t.equal( | ||
JSON.parse(response.payload).message, | ||
'Content-Security-Policy received an invalid directive value for "default-src"' | ||
) | ||
t.notMatch(response.headers, notExpected) | ||
t.equal(errorDetected.length, 1) | ||
t.equal( | ||
errorDetected[0].message, | ||
'Content-Security-Policy received an invalid directive value for "default-src"' | ||
) | ||
}) |
@@ -197,3 +197,3 @@ 'use strict' | ||
test('It should be able to conditionally apply the middlewares through the `helmet` reply decorator', async (t) => { | ||
t.plan(8) | ||
t.plan(10) | ||
@@ -227,13 +227,15 @@ const fastify = Fastify() | ||
} | ||
let response | ||
response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/no-frameguard' | ||
}) | ||
{ | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/no-frameguard' | ||
}) | ||
t.notMatch(response.headers, maybeExpected) | ||
t.has(response.headers, expected) | ||
t.equal(response.statusCode, 200) | ||
t.notMatch(response.headers, maybeExpected) | ||
t.has(response.headers, expected) | ||
} | ||
response = await fastify.inject({ | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
@@ -243,2 +245,3 @@ path: '/frameguard' | ||
t.equal(response.statusCode, 200) | ||
t.has(response.headers, maybeExpected) | ||
@@ -248,3 +251,3 @@ t.has(response.headers, expected) | ||
test('It should throw an error when route specific helmet options are of an invalid type', (t) => { | ||
test('It should throw an error when route specific helmet options are of an invalid type', async (t) => { | ||
t.plan(2) | ||
@@ -259,11 +262,73 @@ | ||
fastify.inject({ | ||
method: 'GET', | ||
path: '/' | ||
}).catch((err) => { | ||
if (err) { | ||
t.ok(err) | ||
t.equal(err.message, 'Unknown value for route helmet configuration') | ||
try { | ||
await fastify.ready() | ||
} catch (error) { | ||
t.ok(error) | ||
t.equal(error.message, 'Unknown value for route helmet configuration') | ||
} | ||
}) | ||
test('It should forward `helmet` reply decorator and route specific errors to `fastify-helmet`', async (t) => { | ||
t.plan(6) | ||
const fastify = Fastify() | ||
await fastify.register(helmet, { global: false }) | ||
fastify.get('/helmet-reply-decorator-error', async (request, reply) => { | ||
await reply.helmet({ | ||
contentSecurityPolicy: { | ||
directives: { | ||
defaultSrc: ["'self'", () => 'bad;value'] | ||
} | ||
} | ||
}) | ||
return { message: 'ok' } | ||
}) | ||
fastify.get('/helmet-route-configuration-error', { | ||
helmet: { | ||
contentSecurityPolicy: { | ||
directives: { | ||
defaultSrc: ["'self'", () => 'bad;value'] | ||
} | ||
} | ||
} | ||
}, async (request, reply) => { | ||
return { message: 'ok' } | ||
}) | ||
const notExpected = { | ||
'x-dns-prefetch-control': 'off', | ||
'x-frame-options': 'SAMEORIGIN', | ||
'x-download-options': 'noopen', | ||
'x-content-type-options': 'nosniff', | ||
'x-xss-protection': '0' | ||
} | ||
{ | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/helmet-reply-decorator-error' | ||
}) | ||
t.equal(response.statusCode, 500) | ||
t.equal( | ||
JSON.parse(response.payload).message, | ||
'Content-Security-Policy received an invalid directive value for "default-src"' | ||
) | ||
t.notMatch(response.headers, notExpected) | ||
} | ||
const response = await fastify.inject({ | ||
method: 'GET', | ||
path: '/helmet-route-configuration-error' | ||
}) | ||
t.equal(response.statusCode, 500) | ||
t.equal( | ||
JSON.parse(response.payload).message, | ||
'Content-Security-Policy received an invalid directive value for "default-src"' | ||
) | ||
t.notMatch(response.headers, notExpected) | ||
}) |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
54605
1291
0