├── .gitattributes ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── eslint.config.js ├── examples └── example.js ├── index.js ├── package.json ├── test ├── global.test.js └── routes.test.js └── types ├── index.d.ts └── index.test-d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 15 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - "discussion" 8 | - "feature request" 9 | - "bug" 10 | - "help wanted" 11 | - "plugin suggestion" 12 | - "good first issue" 13 | # Label to use when marking an issue as stale 14 | staleLabel: stale 15 | # Comment to post when marking an issue as stale. Set to `false` to disable 16 | markComment: > 17 | This issue has been automatically marked as stale because it has not had 18 | recent activity. It will be closed if no further activity occurs. Thank you 19 | for your contributions. 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | license-check: true 28 | lint: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2022 Fastify Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | --> Original helmet license 24 | 25 | (The MIT License) 26 | 27 | Copyright (c) 2012-2017 Evan Hahn, Adam Baldwin 28 | 29 | Permission is hereby granted, free of charge, to any person obtaining 30 | a copy of this software and associated documentation files (the 31 | 'Software'), to deal in the Software without restriction, including 32 | without limitation the rights to use, copy, modify, merge, publish, 33 | distribute, sublicense, and/or sell copies of the Software, and to 34 | permit persons to whom the Software is furnished to do so, subject to 35 | the following conditions: 36 | 37 | The above copyright notice and this permission notice shall be 38 | included in all copies or substantial portions of the Software. 39 | 40 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 41 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 42 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 43 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 44 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 45 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 46 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 47 | 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/helmet 2 | 3 | [![CI](https://github.com/fastify/fastify-helmet/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-helmet/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/helmet)](https://www.npmjs.com/package/@fastify/helmet) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | Important security headers for Fastify, using [helmet](https://npm.im/helmet). 8 | 9 | ## Install 10 | ``` 11 | npm i @fastify/helmet 12 | ``` 13 | 14 | ### Compatibility 15 | 16 | | Plugin version | Fastify version | 17 | | ---------------|-----------------| 18 | | `>=12.x` | `^5.x` | 19 | | `>=9.x <12.x` | `^4.x` | 20 | | `>=7.x <9.x` | `^3.x` | 21 | | `>=1.x <7.x` | `^2.x` | 22 | | `>=1.x <7.x` | `^1.x` | 23 | 24 | 25 | Please note that if a Fastify version is out of support, then so are the corresponding versions of this plugin 26 | in the table above. 27 | See [Fastify's LTS policy](https://github.com/fastify/fastify/blob/main/docs/Reference/LTS.md) for more details. 28 | 29 | ## Usage 30 | 31 | Simply require this plugin to set basic security headers. 32 | 33 | ```js 34 | const fastify = require('fastify')() 35 | const helmet = require('@fastify/helmet') 36 | 37 | fastify.register( 38 | helmet, 39 | // Example disables the `contentSecurityPolicy` middleware but keeps the rest. 40 | { contentSecurityPolicy: false } 41 | ) 42 | 43 | fastify.listen({ port: 3000 }, err => { 44 | if (err) throw err 45 | }) 46 | ``` 47 | 48 | ## How it works 49 | 50 | `@fastify/helmet` is a wrapper around `helmet` that adds an `'onRequest'` hook 51 | and a `reply.helmet` decorator. 52 | 53 | It accepts the same options as `helmet`. See [helmet documentation](https://helmetjs.github.io/). 54 | 55 | ### Apply Helmet to all routes 56 | 57 | Pass `{ global: true }` to register Helmet for all routes. 58 | For granular control, pass `{ global: false }` to disable it at a global scope. 59 | Default is `true`. 60 | 61 | #### Example - enable `@fastify/helmet` globally 62 | 63 | ```js 64 | fastify.register(helmet) 65 | // or 66 | fastify.register(helmet, { global: true }) 67 | ``` 68 | 69 | #### Example - disable `@fastify/helmet` globally 70 | 71 | ```js 72 | // register the package with the `{ global: false }` option 73 | fastify.register(helmet, { global: false }) 74 | 75 | fastify.get('/route-with-disabled-helmet', async (request, reply) => { 76 | return { message: 'helmet is not enabled here' } 77 | }) 78 | 79 | fastify.get('/route-with-enabled-helmet', { 80 | // We enable and configure helmet for this route only 81 | helmet: { 82 | dnsPrefetchControl: { 83 | allow: true 84 | }, 85 | frameguard: { 86 | action: 'foo' 87 | }, 88 | referrerPolicy: false 89 | } 90 | }, async (request, reply) => { 91 | return { message: 'helmet is enabled here' } 92 | }) 93 | 94 | // helmet is disabled on this route but we have access to `reply.helmet` decorator 95 | // that allows us to apply helmet conditionally 96 | fastify.get('/here-we-use-helmet-reply-decorator', async (request, reply) => { 97 | if (condition) { 98 | // we apply the default options 99 | await reply.helmet() 100 | } else { 101 | // we apply customized options 102 | await reply.helmet({ frameguard: false }) 103 | } 104 | 105 | return { 106 | message: 'we use the helmet reply decorator to conditionally apply helmet middlewares' 107 | } 108 | }) 109 | ``` 110 | 111 | ### `helmet` route option 112 | 113 | `@fastify/helmet` allows enabling, disabling, and customizing `helmet` for each route using the `helmet` shorthand option 114 | when registering routes. 115 | 116 | To disable `helmet` for a specific endpoint, pass `{ helmet: false }` to the route options. 117 | 118 | To enable or customize `helmet` for a specific endpoint, pass a configuration object to route options, e.g., `{ helmet: { frameguard: false } }`. 119 | 120 | #### Example - `@fastify/helmet` configuration using the `helmet` shorthand route option 121 | 122 | ```js 123 | // register the package with the `{ global: true }` option 124 | fastify.register(helmet, { global: true }) 125 | 126 | fastify.get('/route-with-disabled-helmet', { helmet: false }, async (request, reply) => { 127 | return { message: 'helmet is not enabled here' } 128 | }) 129 | 130 | fastify.get('/route-with-enabled-helmet', async (request, reply) => { 131 | return { message: 'helmet is enabled by default here' } 132 | }) 133 | 134 | fastify.get('/route-with-custom-helmet-configuration', { 135 | // We change the helmet configuration for this route only 136 | helmet: { 137 | enableCSPNonces: true, 138 | contentSecurityPolicy: { 139 | directives: { 140 | 'directive-1': ['foo', 'bar'] 141 | }, 142 | reportOnly: true 143 | }, 144 | dnsPrefetchControl: { 145 | allow: true 146 | }, 147 | frameguard: { 148 | action: 'foo' 149 | }, 150 | hsts: { 151 | maxAge: 1, 152 | includeSubDomains: true, 153 | preload: true 154 | }, 155 | permittedCrossDomainPolicies: { 156 | permittedPolicies: 'foo' 157 | }, 158 | referrerPolicy: false 159 | } 160 | }, async (request, reply) => { 161 | return { message: 'helmet is enabled with a custom configuration on this route' } 162 | }) 163 | ``` 164 | 165 | ### Content-Security-Policy Nonce 166 | 167 | `@fastify/helmet` also allows CSP nonce generation, which can be enabled by passing `{ enableCSPNonces: true }` into the options. 168 | Retrieve the `nonces` through `reply.cspNonce`. 169 | 170 | > ℹ️ Note: This feature is implemented by this module and is not supported by `helmet`. 171 | > For using `helmet` only for csp nonces, see [example](#example---generate-by-helmet). 172 | 173 | #### Example - Generate by options 174 | 175 | ```js 176 | fastify.register( 177 | helmet, 178 | // enable csp nonces generation with default content-security-policy option 179 | { enableCSPNonces: true } 180 | ) 181 | 182 | fastify.register( 183 | helmet, 184 | // customize content security policy with nonce generation 185 | { 186 | enableCSPNonces: true, 187 | contentSecurityPolicy: { 188 | directives: { 189 | ... 190 | } 191 | } 192 | } 193 | ) 194 | 195 | fastify.get('/', function(request, reply) { 196 | // retrieve script nonce 197 | reply.cspNonce.script 198 | // retrieve style nonce 199 | reply.cspNonce.style 200 | }) 201 | ``` 202 | 203 | #### Example - Generate by helmet 204 | 205 | ```js 206 | fastify.register( 207 | helmet, 208 | { 209 | contentSecurityPolicy: { 210 | directives: { 211 | defaultSrc: ["'self'"], 212 | scriptSrc: [ 213 | function (req, res) { 214 | // "res" here is actually "reply.raw" in fastify 215 | res.scriptNonce = crypto.randomBytes(16).toString('hex') 216 | // make sure to return nonce-... directive to helmet, so it can be sent in the headers 217 | return `'nonce-${res.scriptNonce}'` 218 | } 219 | ], 220 | styleSrc: [ 221 | function (req, res) { 222 | // "res" here is actually "reply.raw" in fastify 223 | res.styleNonce = crypto.randomBytes(16).toString('hex') 224 | // make sure to return nonce-... directive to helmet, so it can be sent in the headers 225 | return `'nonce-${res.styleNonce}'` 226 | } 227 | ] 228 | } 229 | } 230 | } 231 | ) 232 | 233 | fastify.get('/', function(request, reply) { 234 | // access the generated nonce by "reply.raw" 235 | reply.raw.scriptNonce 236 | reply.raw.styleNonce 237 | }) 238 | 239 | ``` 240 | 241 | ### Disable Default `helmet` Directives 242 | 243 | By default, `helmet` adds [a default set of CSP directives](https://github.com/helmetjs/helmet/tree/main/middlewares/content-security-policy#content-security-policy-middleware) to the response. 244 | Disable this by setting `useDefaults: false` in the `contentSecurityPolicy` configuration. 245 | 246 | ```js 247 | fastify.register( 248 | helmet, 249 | { 250 | contentSecurityPolicy: { 251 | useDefaults: false, 252 | directives: { 253 | 'default-src': ["'self'"] 254 | } 255 | } 256 | } 257 | ) 258 | ``` 259 | 260 | ## License 261 | 262 | Licensed under [MIT](./LICENSE). 263 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /examples/example.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const helmet = require('..') 5 | 6 | const fastify = Fastify({ 7 | logger: { 8 | level: 'info' 9 | } 10 | }) 11 | 12 | fastify.register(helmet) 13 | 14 | const opts = { 15 | schema: { 16 | response: { 17 | 200: { 18 | type: 'object', 19 | properties: { 20 | hello: { 21 | type: 'string' 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | 29 | fastify.get('/', opts, function (_request, reply) { 30 | reply 31 | .header('Content-Type', 'application/json') 32 | .code(200) 33 | .send({ hello: 'world' }) 34 | }) 35 | 36 | fastify.get('/route-with-disabled-helmet', { ...opts, helmet: false }, function (_request, reply) { 37 | reply 38 | .header('Content-Type', 'application/json') 39 | .code(200) 40 | .send({ hello: 'world' }) 41 | }) 42 | 43 | fastify.listen({ port: 3000 }, err => { 44 | if (err) throw err 45 | fastify.log.info(`Server listening on ${fastify.server.address().address}:${fastify.server.address().port}`) 46 | }) 47 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { randomBytes } = require('node:crypto') 4 | const fp = require('fastify-plugin') 5 | const helmet = require('helmet') 6 | 7 | async function fastifyHelmet (fastify, options) { 8 | // helmet will throw when any option is explicitly set to "true" 9 | // using ECMAScript destructuring is a clean workaround as we do not need to alter options 10 | const { enableCSPNonces, global, ...globalConfiguration } = options 11 | 12 | const isGlobal = typeof global === 'boolean' ? global : true 13 | 14 | // We initialize the `helmet` reply decorator only if it does not already exists 15 | if (!fastify.hasReplyDecorator('helmet')) { 16 | fastify.decorateReply('helmet', null) 17 | } 18 | 19 | // We initialize the `cspNonce` reply decorator only if it does not already exists 20 | if (!fastify.hasReplyDecorator('cspNonce')) { 21 | fastify.decorateReply('cspNonce', null) 22 | } 23 | 24 | fastify.addHook('onRoute', (routeOptions) => { 25 | if (routeOptions.helmet !== undefined) { 26 | if (typeof routeOptions.helmet === 'object') { 27 | routeOptions.config = Object.assign(routeOptions.config || Object.create(null), { helmet: routeOptions.helmet }) 28 | } else if (routeOptions.helmet === false) { 29 | routeOptions.config = Object.assign(routeOptions.config || Object.create(null), { helmet: { skipRoute: true } }) 30 | } else { 31 | throw new Error('Unknown value for route helmet configuration') 32 | } 33 | } 34 | }) 35 | 36 | fastify.addHook('onRequest', async function helmetConfigureReply (request, reply) { 37 | /* c8 ignore next */ 38 | const { helmet: routeOptions } = request.routeOptions?.config || request.routeConfig 39 | 40 | if (routeOptions !== undefined) { 41 | const { enableCSPNonces: enableRouteCSPNonces, skipRoute, ...helmetRouteConfiguration } = routeOptions 42 | // If route helmet options are set they overwrite the global helmet configuration 43 | const mergedHelmetConfiguration = Object.assign(Object.create(null), globalConfiguration, helmetRouteConfiguration) 44 | 45 | // We decorate the reply with a fallback to the route scoped helmet options 46 | return replyDecorators(request, reply, mergedHelmetConfiguration, enableRouteCSPNonces) 47 | } 48 | 49 | // We decorate the reply with a fallback to the global helmet options 50 | return replyDecorators(request, reply, globalConfiguration, enableCSPNonces) 51 | }) 52 | 53 | fastify.addHook('onRequest', function helmetApplyHeaders (request, reply, next) { 54 | /* c8 ignore next */ 55 | const { helmet: routeOptions } = request.routeOptions?.config || request.routeConfig 56 | 57 | if (routeOptions !== undefined) { 58 | const { enableCSPNonces: enableRouteCSPNonces, skipRoute, ...helmetRouteConfiguration } = routeOptions 59 | 60 | if (skipRoute === true) { 61 | // If helmet route option is set to `false` we skip the route 62 | } else { 63 | // If route helmet options are set they overwrite the global helmet configuration 64 | const mergedHelmetConfiguration = Object.assign(Object.create(null), globalConfiguration, helmetRouteConfiguration) 65 | 66 | return buildHelmetOnRoutes(request, reply, mergedHelmetConfiguration, enableRouteCSPNonces) 67 | } 68 | 69 | return next() 70 | } 71 | if (isGlobal) { 72 | // if the plugin is set globally (meaning that all the routes will be decorated) 73 | // As the endpoint, does not have a custom helmet configuration, use the global one. 74 | return buildHelmetOnRoutes(request, reply, globalConfiguration, enableCSPNonces) 75 | } 76 | 77 | // if the plugin is not global we can skip the route 78 | return next() 79 | }) 80 | } 81 | 82 | async function replyDecorators (request, reply, configuration, enableCSP) { 83 | if (enableCSP) { 84 | reply.cspNonce = { 85 | script: randomBytes(16).toString('hex'), 86 | style: randomBytes(16).toString('hex') 87 | } 88 | } 89 | 90 | reply.helmet = function (opts) { 91 | const helmetConfiguration = opts 92 | ? Object.assign(Object.create(null), configuration, opts) 93 | : configuration 94 | 95 | return helmet(helmetConfiguration)(request.raw, reply.raw, done) 96 | } 97 | } 98 | 99 | async function buildHelmetOnRoutes (request, reply, configuration, enableCSP) { 100 | if (enableCSP === true && configuration.contentSecurityPolicy !== false) { 101 | const cspDirectives = configuration.contentSecurityPolicy 102 | ? configuration.contentSecurityPolicy.directives 103 | : helmet.contentSecurityPolicy.getDefaultDirectives() 104 | const cspReportOnly = configuration.contentSecurityPolicy 105 | ? configuration.contentSecurityPolicy.reportOnly 106 | : undefined 107 | const cspUseDefaults = configuration.contentSecurityPolicy 108 | ? configuration.contentSecurityPolicy.useDefaults 109 | : undefined 110 | 111 | // We get the csp nonce from the reply 112 | const { script: scriptCSPNonce, style: styleCSPNonce } = reply.cspNonce 113 | 114 | // We prevent object reference: https://github.com/fastify/fastify-helmet/issues/118 115 | const directives = { ...cspDirectives } 116 | 117 | // We push nonce to csp 118 | // We allow both 'script-src' or 'scriptSrc' syntax 119 | const scriptKey = Array.isArray(directives['script-src']) ? 'script-src' : 'scriptSrc' 120 | directives[scriptKey] = Array.isArray(directives[scriptKey]) ? [...directives[scriptKey]] : [] 121 | directives[scriptKey].push(`'nonce-${scriptCSPNonce}'`) 122 | // allow both style-src or styleSrc syntax 123 | const styleKey = Array.isArray(directives['style-src']) ? 'style-src' : 'styleSrc' 124 | directives[styleKey] = Array.isArray(directives[styleKey]) ? [...directives[styleKey]] : [] 125 | directives[styleKey].push(`'nonce-${styleCSPNonce}'`) 126 | 127 | const contentSecurityPolicy = { directives, reportOnly: cspReportOnly, useDefaults: cspUseDefaults } 128 | const mergedHelmetConfiguration = Object.assign(Object.create(null), configuration, { contentSecurityPolicy }) 129 | 130 | helmet(mergedHelmetConfiguration)(request.raw, reply.raw, done) 131 | } else { 132 | helmet(configuration)(request.raw, reply.raw, done) 133 | } 134 | } 135 | 136 | // Helmet forward a typeof Error object so we just need to throw it as is. 137 | function done (error) { 138 | if (error) throw error 139 | } 140 | 141 | module.exports = fp(fastifyHelmet, { 142 | fastify: '5.x', 143 | name: '@fastify/helmet' 144 | }) 145 | module.exports.default = fastifyHelmet 146 | module.exports.fastifyHelmet = fastifyHelmet 147 | 148 | module.exports.contentSecurityPolicy = helmet.contentSecurityPolicy 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/helmet", 3 | "version": "13.0.1", 4 | "description": "Important security headers for Fastify", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "types": "types/index.d.ts", 8 | "scripts": { 9 | "lint": "eslint", 10 | "lint:fix": "eslint --fix", 11 | "test": "npm run test:unit && npm run test:typescript", 12 | "test:typescript": "tsd", 13 | "test:unit": "c8 --100 node --test" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/fastify/fastify-helmet.git" 18 | }, 19 | "keywords": [ 20 | "fastify", 21 | "helmet", 22 | "security", 23 | "headers", 24 | "x-frame-options", 25 | "csp", 26 | "hsts", 27 | "clickjack" 28 | ], 29 | "author": "Matteo Collina ", 30 | "contributors": [ 31 | { 32 | "name": "Tomas Della Vedova", 33 | "url": "http://delved.org" 34 | }, 35 | { 36 | "name": "Manuel Spigolon", 37 | "email": "behemoth89@gmail.com" 38 | }, 39 | { 40 | "name": "Maksim Sinik", 41 | "url": "https://maksim.dev" 42 | }, 43 | { 44 | "name": "Frazer Smith", 45 | "email": "frazer.dev@icloud.com", 46 | "url": "https://github.com/fdawgs" 47 | } 48 | ], 49 | "license": "MIT", 50 | "bugs": { 51 | "url": "https://github.com/fastify/fastify-helmet/issues" 52 | }, 53 | "homepage": "https://github.com/fastify/fastify-helmet#readme", 54 | "funding": [ 55 | { 56 | "type": "github", 57 | "url": "https://github.com/sponsors/fastify" 58 | }, 59 | { 60 | "type": "opencollective", 61 | "url": "https://opencollective.com/fastify" 62 | } 63 | ], 64 | "devDependencies": { 65 | "@fastify/pre-commit": "^2.1.0", 66 | "@types/node": "^22.0.0", 67 | "c8": "^10.1.2", 68 | "eslint": "^9.17.0", 69 | "fastify": "^5.0.0", 70 | "neostandard": "^0.12.0", 71 | "tsd": "^0.31.0" 72 | }, 73 | "dependencies": { 74 | "fastify-plugin": "^5.0.0", 75 | "helmet": "^8.0.0" 76 | }, 77 | "tsd": { 78 | "directory": "test/types" 79 | }, 80 | "publishConfig": { 81 | "access": "public" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/global.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const stream = require('node:stream') 4 | const { test } = require('node:test') 5 | const fp = require('fastify-plugin') 6 | const Fastify = require('fastify') 7 | const helmet = require('..') 8 | 9 | test('It should set the default headers', async (t) => { 10 | t.plan(1) 11 | 12 | const fastify = Fastify() 13 | await fastify.register(helmet) 14 | 15 | fastify.get('/', (_request, reply) => { 16 | reply.send({ hello: 'world' }) 17 | }) 18 | 19 | const response = await fastify.inject({ 20 | method: 'GET', 21 | path: '/' 22 | }) 23 | 24 | const expected = { 25 | 'x-dns-prefetch-control': 'off', 26 | 'x-frame-options': 'SAMEORIGIN', 27 | 'x-download-options': 'noopen', 28 | 'x-content-type-options': 'nosniff', 29 | 'x-xss-protection': '0' 30 | } 31 | 32 | const actualResponseHeaders = { 33 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 34 | 'x-frame-options': response.headers['x-frame-options'], 35 | 'x-download-options': response.headers['x-download-options'], 36 | 'x-content-type-options': response.headers['x-content-type-options'], 37 | 'x-xss-protection': response.headers['x-xss-protection'] 38 | } 39 | 40 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 41 | }) 42 | 43 | test('It should not set the default headers when global is set to `false`', async (t) => { 44 | t.plan(1) 45 | 46 | const fastify = Fastify() 47 | await fastify.register(helmet, { global: false }) 48 | 49 | fastify.get('/', (_request, reply) => { 50 | reply.send({ hello: 'world' }) 51 | }) 52 | 53 | const response = await fastify.inject({ 54 | method: 'GET', 55 | path: '/' 56 | }) 57 | 58 | const notExpected = { 59 | 'x-dns-prefetch-control': 'off', 60 | 'x-frame-options': 'SAMEORIGIN', 61 | 'x-download-options': 'noopen', 62 | 'x-content-type-options': 'nosniff', 63 | 'x-xss-protection': '0' 64 | } 65 | 66 | const actualResponseHeaders = { 67 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 68 | 'x-frame-options': response.headers['x-frame-options'], 69 | 'x-download-options': response.headers['x-download-options'], 70 | 'x-content-type-options': response.headers['x-content-type-options'], 71 | 'x-xss-protection': response.headers['x-xss-protection'] 72 | } 73 | 74 | t.assert.notDeepStrictEqual(actualResponseHeaders, notExpected) 75 | }) 76 | 77 | test('It should set the default cross-domain-policy', async (t) => { 78 | t.plan(1) 79 | 80 | const fastify = Fastify() 81 | await fastify.register(helmet) 82 | 83 | fastify.get('/', (_request, reply) => { 84 | reply.send({ hello: 'world' }) 85 | }) 86 | 87 | const response = await fastify.inject({ 88 | method: 'GET', 89 | path: '/' 90 | }) 91 | const expected = { 92 | 'x-permitted-cross-domain-policies': 'none' 93 | } 94 | 95 | t.assert.deepStrictEqual( 96 | response.headers['x-permitted-cross-domain-policies'], 97 | expected['x-permitted-cross-domain-policies'] 98 | ) 99 | }) 100 | 101 | test('It should be able to set cross-domain-policy', async (t) => { 102 | t.plan(1) 103 | 104 | const fastify = Fastify() 105 | await fastify.register(helmet, { 106 | permittedCrossDomainPolicies: { permittedPolicies: 'by-content-type' } 107 | }) 108 | 109 | fastify.get('/', (_request, reply) => { 110 | reply.send({ hello: 'world' }) 111 | }) 112 | 113 | const response = await fastify.inject({ 114 | method: 'GET', 115 | path: '/' 116 | }) 117 | 118 | const expected = { 119 | 'x-permitted-cross-domain-policies': 'by-content-type' 120 | } 121 | 122 | t.assert.deepStrictEqual( 123 | response.headers['x-permitted-cross-domain-policies'], 124 | expected['x-permitted-cross-domain-policies'] 125 | ) 126 | }) 127 | 128 | test('It should not disable the other headers when disabling one header', async (t) => { 129 | t.plan(2) 130 | 131 | const fastify = Fastify() 132 | await fastify.register(helmet, { frameguard: false }) 133 | 134 | fastify.get('/', (_request, reply) => { 135 | reply.send({ hello: 'world' }) 136 | }) 137 | 138 | const response = await fastify.inject({ 139 | method: 'GET', 140 | path: '/' 141 | }) 142 | const notExpected = { 143 | 'x-frame-options': 'SAMEORIGIN' 144 | } 145 | 146 | const expected = { 147 | 'x-dns-prefetch-control': 'off', 148 | 'x-download-options': 'noopen', 149 | 'x-content-type-options': 'nosniff', 150 | 'x-xss-protection': '0' 151 | } 152 | 153 | const actualResponseHeaders = { 154 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 155 | 'x-download-options': response.headers['x-download-options'], 156 | 'x-content-type-options': response.headers['x-content-type-options'], 157 | 'x-xss-protection': response.headers['x-xss-protection'] 158 | } 159 | 160 | t.assert.notDeepStrictEqual(actualResponseHeaders, notExpected) 161 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 162 | }) 163 | 164 | test('It should be able to access default CSP directives through plugin export', async (t) => { 165 | t.plan(1) 166 | 167 | const fastify = Fastify() 168 | await fastify.register(helmet, { 169 | contentSecurityPolicy: { 170 | directives: { 171 | ...helmet.contentSecurityPolicy.getDefaultDirectives() 172 | } 173 | } 174 | }) 175 | 176 | fastify.get('/', (_request, reply) => { 177 | reply.send({ hello: 'world' }) 178 | }) 179 | 180 | const response = await fastify.inject({ 181 | method: 'GET', 182 | path: '/' 183 | }) 184 | 185 | const expected = { 186 | 'content-security-policy': 187 | "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" 188 | } 189 | 190 | t.assert.deepStrictEqual( 191 | response.headers['content-security-policy'], 192 | expected['content-security-policy'] 193 | ) 194 | }) 195 | 196 | test('It should not set default directives when useDefaults is set to `false`', async (t) => { 197 | t.plan(1) 198 | 199 | const fastify = Fastify() 200 | await fastify.register(helmet, { 201 | contentSecurityPolicy: { 202 | useDefaults: false, 203 | directives: { 204 | defaultSrc: ["'self'"] 205 | } 206 | } 207 | }) 208 | 209 | fastify.get('/', (_request, reply) => { 210 | reply.send({ hello: 'world' }) 211 | }) 212 | 213 | const response = await fastify.inject({ 214 | method: 'GET', 215 | path: '/' 216 | }) 217 | 218 | const expected = { 'content-security-policy': "default-src 'self'" } 219 | 220 | t.assert.deepStrictEqual( 221 | response.headers['content-security-policy'], 222 | expected['content-security-policy'] 223 | ) 224 | }) 225 | 226 | test('It should auto generate nonce per request', async (t) => { 227 | t.plan(7) 228 | 229 | const fastify = Fastify() 230 | await fastify.register(helmet, { 231 | enableCSPNonces: true 232 | }) 233 | 234 | fastify.get('/', (_request, reply) => { 235 | t.assert.ok(reply.cspNonce) 236 | reply.send(reply.cspNonce) 237 | }) 238 | 239 | let response 240 | 241 | response = await fastify.inject({ method: 'GET', path: '/' }) 242 | const cspCache = response.json() 243 | t.assert.ok(cspCache.script) 244 | t.assert.ok(cspCache.style) 245 | 246 | response = await fastify.inject({ method: 'GET', path: '/' }) 247 | const newCsp = response.json() 248 | t.assert.notDeepStrictEqual(cspCache, newCsp) 249 | t.assert.ok(cspCache.script) 250 | t.assert.ok(cspCache.style) 251 | }) 252 | 253 | test('It should allow merging options for enableCSPNonces', async (t) => { 254 | t.plan(4) 255 | 256 | const fastify = Fastify() 257 | await fastify.register(helmet, { 258 | enableCSPNonces: true, 259 | contentSecurityPolicy: { 260 | directives: { 261 | defaultSrc: ["'self'"], 262 | scriptSrc: ["'self'"], 263 | styleSrc: ["'self'"] 264 | } 265 | } 266 | }) 267 | 268 | fastify.get('/', (_request, reply) => { 269 | t.assert.ok(reply.cspNonce) 270 | reply.send(reply.cspNonce) 271 | }) 272 | 273 | const response = await fastify.inject({ method: 'GET', path: '/' }) 274 | const cspCache = response.json() 275 | 276 | t.assert.ok(cspCache.script) 277 | t.assert.ok(cspCache.style) 278 | t.assert.deepStrictEqual( 279 | response.headers['content-security-policy'], 280 | `default-src 'self';script-src 'self' 'nonce-${cspCache.script}';style-src 'self' 'nonce-${cspCache.style}';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src-attr 'none';upgrade-insecure-requests` 281 | ) 282 | }) 283 | 284 | test('It should not set default directives when using enableCSPNonces and useDefaults is set to `false`', async (t) => { 285 | t.plan(4) 286 | 287 | const fastify = Fastify() 288 | await fastify.register(helmet, { 289 | enableCSPNonces: true, 290 | contentSecurityPolicy: { 291 | useDefaults: false, 292 | directives: { 293 | defaultSrc: ["'self'"], 294 | scriptSrc: ["'self'"], 295 | styleSrc: ["'self'"] 296 | } 297 | } 298 | }) 299 | 300 | fastify.get('/', (_request, reply) => { 301 | t.assert.ok(reply.cspNonce) 302 | reply.send(reply.cspNonce) 303 | }) 304 | 305 | const response = await fastify.inject({ method: 'GET', path: '/' }) 306 | const cspCache = response.json() 307 | 308 | t.assert.ok(cspCache.script) 309 | t.assert.ok(cspCache.style) 310 | t.assert.deepStrictEqual( 311 | response.headers['content-security-policy'], 312 | `default-src 'self';script-src 'self' 'nonce-${cspCache.script}';style-src 'self' 'nonce-${cspCache.style}'` 313 | ) 314 | }) 315 | 316 | test('It should not stack nonce array in csp header', async (t) => { 317 | t.plan(8) 318 | 319 | const fastify = Fastify() 320 | await fastify.register(helmet, { 321 | enableCSPNonces: true, 322 | contentSecurityPolicy: { 323 | directives: { 324 | defaultSrc: ["'self'"], 325 | scriptSrc: ["'self'"], 326 | styleSrc: ["'self'"] 327 | } 328 | } 329 | }) 330 | 331 | fastify.get('/', (_request, reply) => { 332 | t.assert.ok(reply.cspNonce) 333 | reply.send(reply.cspNonce) 334 | }) 335 | 336 | let response = await fastify.inject({ method: 'GET', path: '/' }) 337 | let cspCache = response.json() 338 | 339 | t.assert.ok(cspCache.script) 340 | t.assert.ok(cspCache.style) 341 | t.assert.deepStrictEqual( 342 | response.headers['content-security-policy'], 343 | `default-src 'self';script-src 'self' 'nonce-${cspCache.script}';style-src 'self' 'nonce-${cspCache.style}';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src-attr 'none';upgrade-insecure-requests` 344 | ) 345 | 346 | response = await fastify.inject({ method: 'GET', path: '/' }) 347 | cspCache = response.json() 348 | 349 | t.assert.ok(cspCache.script) 350 | t.assert.ok(cspCache.style) 351 | t.assert.deepStrictEqual( 352 | response.headers['content-security-policy'], 353 | `default-src 'self';script-src 'self' 'nonce-${cspCache.script}';style-src 'self' 'nonce-${cspCache.style}';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src-attr 'none';upgrade-insecure-requests` 354 | ) 355 | }) 356 | 357 | test('It should access the correct options property', async (t) => { 358 | t.plan(4) 359 | 360 | const fastify = Fastify() 361 | await fastify.register(helmet, { 362 | enableCSPNonces: true, 363 | contentSecurityPolicy: { 364 | directives: { 365 | 'script-src': ["'self'", "'unsafe-eval'", "'unsafe-inline'"], 366 | 'style-src': ["'self'", "'unsafe-inline'"] 367 | } 368 | } 369 | }) 370 | 371 | fastify.get('/', (_request, reply) => { 372 | t.assert.ok(reply.cspNonce) 373 | reply.send(reply.cspNonce) 374 | }) 375 | 376 | const response = await fastify.inject({ method: 'GET', path: '/' }) 377 | const cspCache = response.json() 378 | 379 | t.assert.ok(cspCache.script) 380 | t.assert.ok(cspCache.style) 381 | t.assert.deepStrictEqual( 382 | response.headers['content-security-policy'], 383 | `script-src 'self' 'unsafe-eval' 'unsafe-inline' 'nonce-${cspCache.script}';style-src 'self' 'unsafe-inline' 'nonce-${cspCache.style}';default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src-attr 'none';upgrade-insecure-requests` 384 | ) 385 | }) 386 | 387 | test('It should not set script-src or style-src', async (t) => { 388 | t.plan(4) 389 | 390 | const fastify = Fastify() 391 | await fastify.register(helmet, { 392 | enableCSPNonces: true, 393 | contentSecurityPolicy: { 394 | directives: { 395 | defaultSrc: ["'self'"] 396 | } 397 | } 398 | }) 399 | 400 | fastify.get('/', (_request, reply) => { 401 | t.assert.ok(reply.cspNonce) 402 | reply.send(reply.cspNonce) 403 | }) 404 | 405 | const response = await fastify.inject({ method: 'GET', path: '/' }) 406 | const cspCache = response.json() 407 | 408 | t.assert.ok(cspCache.script) 409 | t.assert.ok(cspCache.style) 410 | t.assert.deepStrictEqual( 411 | response.headers['content-security-policy'], 412 | `default-src 'self';script-src 'nonce-${cspCache.script}';style-src 'nonce-${cspCache.style}';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src-attr 'none';upgrade-insecure-requests` 413 | ) 414 | }) 415 | 416 | test('It should add hooks correctly', async (t) => { 417 | t.plan(14) 418 | 419 | const fastify = Fastify() 420 | 421 | fastify.addHook('onRequest', async (_request, reply) => { 422 | reply.header('x-fastify-global-test', 'ok') 423 | }) 424 | 425 | await fastify.register(helmet, { global: true }) 426 | 427 | fastify.get( 428 | '/one', 429 | { 430 | onRequest: [ 431 | async (_request, reply) => { 432 | reply.header('x-fastify-test-one', 'ok') 433 | } 434 | ] 435 | }, 436 | () => { 437 | return { message: 'one' } 438 | } 439 | ) 440 | 441 | fastify.get( 442 | '/two', 443 | { 444 | onRequest: async (_request, reply) => { 445 | reply.header('x-fastify-test-two', 'ok') 446 | } 447 | }, 448 | () => { 449 | return { message: 'two' } 450 | } 451 | ) 452 | 453 | fastify.get('/three', { onRequest: async () => {} }, () => { 454 | return { message: 'three' } 455 | }) 456 | 457 | const expected = { 458 | 'x-dns-prefetch-control': 'off', 459 | 'x-frame-options': 'SAMEORIGIN', 460 | 'x-download-options': 'noopen', 461 | 'x-content-type-options': 'nosniff', 462 | 'x-xss-protection': '0' 463 | } 464 | 465 | await fastify 466 | .inject({ 467 | path: '/one', 468 | method: 'GET' 469 | }) 470 | .then((response) => { 471 | const actualResponseHeaders = { 472 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 473 | 'x-frame-options': response.headers['x-frame-options'], 474 | 'x-download-options': response.headers['x-download-options'], 475 | 'x-content-type-options': response.headers['x-content-type-options'], 476 | 'x-xss-protection': response.headers['x-xss-protection'] 477 | } 478 | t.assert.deepStrictEqual(response.statusCode, 200) 479 | t.assert.deepStrictEqual(response.headers['x-fastify-global-test'], 'ok') 480 | t.assert.deepStrictEqual(response.headers['x-fastify-test-one'], 'ok') 481 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 482 | t.assert.deepStrictEqual(JSON.parse(response.payload).message, 'one') 483 | }) 484 | .catch((err) => { 485 | t.assert.ifError(err) 486 | }) 487 | 488 | await fastify 489 | .inject({ 490 | path: '/two', 491 | method: 'GET' 492 | }) 493 | .then((response) => { 494 | const actualResponseHeaders = { 495 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 496 | 'x-frame-options': response.headers['x-frame-options'], 497 | 'x-download-options': response.headers['x-download-options'], 498 | 'x-content-type-options': response.headers['x-content-type-options'], 499 | 'x-xss-protection': response.headers['x-xss-protection'] 500 | } 501 | t.assert.deepStrictEqual(response.statusCode, 200) 502 | t.assert.deepStrictEqual(response.headers['x-fastify-global-test'], 'ok') 503 | t.assert.deepStrictEqual(response.headers['x-fastify-test-two'], 'ok') 504 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 505 | t.assert.deepStrictEqual(JSON.parse(response.payload).message, 'two') 506 | }) 507 | .catch((err) => { 508 | t.error(err) 509 | }) 510 | 511 | await fastify 512 | .inject({ 513 | path: '/three', 514 | method: 'GET' 515 | }) 516 | .then((response) => { 517 | const actualResponseHeaders = { 518 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 519 | 'x-frame-options': response.headers['x-frame-options'], 520 | 'x-download-options': response.headers['x-download-options'], 521 | 'x-content-type-options': response.headers['x-content-type-options'], 522 | 'x-xss-protection': response.headers['x-xss-protection'] 523 | } 524 | t.assert.deepStrictEqual(response.statusCode, 200) 525 | t.assert.deepStrictEqual(response.headers['x-fastify-global-test'], 'ok') 526 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 527 | t.assert.deepStrictEqual(JSON.parse(response.payload).message, 'three') 528 | }) 529 | .catch((err) => { 530 | t.assert.ifError(err) 531 | }) 532 | }) 533 | 534 | test('It should add the `helmet` reply decorator', async (t) => { 535 | t.plan(3) 536 | 537 | const fastify = Fastify() 538 | await fastify.register(helmet, { global: false }) 539 | 540 | fastify.get('/', async (_request, reply) => { 541 | t.assert.ok(reply.helmet) 542 | t.assert.notStrictEqual(reply.helmet, null) 543 | 544 | await reply.helmet() 545 | return { message: 'ok' } 546 | }) 547 | 548 | const response = await fastify.inject({ 549 | method: 'GET', 550 | path: '/' 551 | }) 552 | const expected = { 553 | 'x-dns-prefetch-control': 'off', 554 | 'x-frame-options': 'SAMEORIGIN', 555 | 'x-download-options': 'noopen', 556 | 'x-content-type-options': 'nosniff', 557 | 'x-xss-protection': '0' 558 | } 559 | 560 | const actualResponseHeaders = { 561 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 562 | 'x-frame-options': response.headers['x-frame-options'], 563 | 'x-download-options': response.headers['x-download-options'], 564 | 'x-content-type-options': response.headers['x-content-type-options'], 565 | 'x-xss-protection': response.headers['x-xss-protection'] 566 | } 567 | 568 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 569 | }) 570 | 571 | test('It should not throw when trying to add the `helmet` and `cspNonce` reply decorators if they already exist', async (t) => { 572 | t.plan(7) 573 | 574 | const fastify = Fastify() 575 | 576 | // We decorate the reply with helmet and cspNonce to trigger the existence check 577 | fastify.decorateReply('helmet', null) 578 | fastify.decorateReply('cspNonce', null) 579 | 580 | await fastify.register(helmet, { enableCSPNonces: true, global: true }) 581 | 582 | fastify.get('/', async (_request, reply) => { 583 | t.assert.ok(reply.helmet) 584 | t.assert.notDeepStrictEqual(reply.helmet, null) 585 | t.assert.ok(reply.cspNonce) 586 | t.assert.notDeepStrictEqual(reply.cspNonce, null) 587 | 588 | reply.send(reply.cspNonce) 589 | }) 590 | 591 | const response = await fastify.inject({ 592 | method: 'GET', 593 | path: '/' 594 | }) 595 | 596 | const cspCache = response.json() 597 | t.assert.ok(cspCache.script) 598 | t.assert.ok(cspCache.style) 599 | 600 | const expected = { 601 | 'x-dns-prefetch-control': 'off', 602 | 'x-frame-options': 'SAMEORIGIN', 603 | 'x-download-options': 'noopen', 604 | 'x-content-type-options': 'nosniff', 605 | 'x-xss-protection': '0' 606 | } 607 | 608 | const actualResponseHeaders = { 609 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 610 | 'x-frame-options': response.headers['x-frame-options'], 611 | 'x-download-options': response.headers['x-download-options'], 612 | 'x-content-type-options': response.headers['x-content-type-options'], 613 | 'x-xss-protection': response.headers['x-xss-protection'] 614 | } 615 | 616 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 617 | }) 618 | 619 | test('It should be able to pass custom options to the `helmet` reply decorator', async (t) => { 620 | t.plan(4) 621 | 622 | const fastify = Fastify() 623 | await fastify.register(helmet, { global: false }) 624 | 625 | fastify.get('/', async (_request, reply) => { 626 | t.assert.ok(reply.helmet) 627 | t.assert.notDeepStrictEqual(reply.helmet, null) 628 | 629 | await reply.helmet({ frameguard: false }) 630 | return { message: 'ok' } 631 | }) 632 | 633 | const response = await fastify.inject({ 634 | method: 'GET', 635 | path: '/' 636 | }) 637 | 638 | const expected = { 639 | 'x-dns-prefetch-control': 'off', 640 | 'x-download-options': 'noopen', 641 | 'x-content-type-options': 'nosniff', 642 | 'x-xss-protection': '0' 643 | } 644 | 645 | const notExpected = { 646 | 'x-frame-options': 'SAMEORIGIN' 647 | } 648 | 649 | const actualResponseHeaders = { 650 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 651 | 'x-download-options': response.headers['x-download-options'], 652 | 'x-content-type-options': response.headers['x-content-type-options'], 653 | 'x-xss-protection': response.headers['x-xss-protection'] 654 | } 655 | 656 | const actualNotExpectedHeaders = { 657 | 'x-frame-options': response.headers['x-frame-options'] 658 | } 659 | 660 | t.assert.notDeepStrictEqual(actualNotExpectedHeaders, notExpected) 661 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 662 | }) 663 | 664 | test('It should be able to conditionally apply the middlewares through the `helmet` reply decorator', async (t) => { 665 | t.plan(10) 666 | 667 | const fastify = Fastify() 668 | await fastify.register(helmet, { global: true }) 669 | 670 | fastify.get('/:condition', { helmet: false }, async (request, reply) => { 671 | const { condition } = request.params 672 | 673 | t.assert.ok(reply.helmet) 674 | t.assert.notDeepStrictEqual(reply.helmet, null) 675 | 676 | if (condition !== 'frameguard') { 677 | await reply.helmet({ frameguard: false }) 678 | } else { 679 | await reply.helmet({ frameguard: true }) 680 | } 681 | return { message: 'ok' } 682 | }) 683 | 684 | const expected = { 685 | 'x-dns-prefetch-control': 'off', 686 | 'x-download-options': 'noopen', 687 | 'x-content-type-options': 'nosniff', 688 | 'x-xss-protection': '0' 689 | } 690 | 691 | const maybeExpected = { 692 | 'x-frame-options': 'SAMEORIGIN' 693 | } 694 | 695 | { 696 | const response = await fastify.inject({ 697 | method: 'GET', 698 | path: '/no-frameguard' 699 | }) 700 | 701 | const actualResponseHeaders = { 702 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 703 | 'x-download-options': response.headers['x-download-options'], 704 | 'x-content-type-options': response.headers['x-content-type-options'], 705 | 'x-xss-protection': response.headers['x-xss-protection'] 706 | } 707 | 708 | const actualMaybeExpectedHeaders = { 709 | 'x-frame-options': response.headers['x-frame-options'] 710 | } 711 | 712 | t.assert.strictEqual(response.statusCode, 200) 713 | t.assert.notDeepStrictEqual(actualMaybeExpectedHeaders, maybeExpected) 714 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 715 | } 716 | 717 | const response = await fastify.inject({ 718 | method: 'GET', 719 | path: '/frameguard' 720 | }) 721 | 722 | const actualResponseHeaders = { 723 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 724 | 'x-download-options': response.headers['x-download-options'], 725 | 'x-content-type-options': response.headers['x-content-type-options'], 726 | 'x-xss-protection': response.headers['x-xss-protection'] 727 | } 728 | 729 | const actualMaybeExpectedHeaders = { 730 | 'x-frame-options': response.headers['x-frame-options'] 731 | } 732 | 733 | t.assert.strictEqual(response.statusCode, 200) 734 | t.assert.deepStrictEqual(actualMaybeExpectedHeaders, maybeExpected) 735 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 736 | }) 737 | 738 | test('It should apply helmet headers when returning error messages', async (t) => { 739 | t.plan(6) 740 | 741 | const fastify = Fastify() 742 | await fastify.register(helmet, { enableCSPNonces: true }) 743 | 744 | fastify.get( 745 | '/', 746 | { 747 | onRequest: async (_request, reply) => { 748 | reply.code(401) 749 | reply.send({ message: 'Unauthorized' }) 750 | } 751 | }, 752 | async () => { 753 | return { message: 'ok' } 754 | } 755 | ) 756 | 757 | fastify.get('/error-handler', {}, async () => { 758 | return Promise.reject(new Error('error handler triggered')) 759 | }) 760 | 761 | const expected = { 762 | 'x-dns-prefetch-control': 'off', 763 | 'x-frame-options': 'SAMEORIGIN', 764 | 'x-download-options': 'noopen', 765 | 'x-content-type-options': 'nosniff', 766 | 'x-xss-protection': '0' 767 | } 768 | 769 | { 770 | const response = await fastify.inject({ 771 | method: 'GET', 772 | path: '/' 773 | }) 774 | 775 | const actualResponseHeaders = { 776 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 777 | 'x-frame-options': response.headers['x-frame-options'], 778 | 'x-download-options': response.headers['x-download-options'], 779 | 'x-content-type-options': response.headers['x-content-type-options'], 780 | 'x-xss-protection': response.headers['x-xss-protection'] 781 | } 782 | 783 | t.assert.deepStrictEqual(response.statusCode, 401) 784 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 785 | } 786 | 787 | { 788 | const response = await fastify.inject({ 789 | method: 'GET', 790 | path: '/error-handler' 791 | }) 792 | 793 | const actualResponseHeaders = { 794 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 795 | 'x-frame-options': response.headers['x-frame-options'], 796 | 'x-download-options': response.headers['x-download-options'], 797 | 'x-content-type-options': response.headers['x-content-type-options'], 798 | 'x-xss-protection': response.headers['x-xss-protection'] 799 | } 800 | 801 | t.assert.deepStrictEqual(response.statusCode, 500) 802 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 803 | } 804 | 805 | { 806 | const response = await fastify.inject({ 807 | method: 'GET', 808 | path: '/404-route' 809 | }) 810 | 811 | const actualResponseHeaders = { 812 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 813 | 'x-frame-options': response.headers['x-frame-options'], 814 | 'x-download-options': response.headers['x-download-options'], 815 | 'x-content-type-options': response.headers['x-content-type-options'], 816 | 'x-xss-protection': response.headers['x-xss-protection'] 817 | } 818 | 819 | t.assert.deepStrictEqual(response.statusCode, 404) 820 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 821 | } 822 | }) 823 | 824 | // To avoid regressions. 825 | // ref.: https://github.com/fastify/fastify-helmet/pull/169#issuecomment-1017413835 826 | test('It should not return a fastify `FST_ERR_REP_ALREADY_SENT - Reply already sent` error', async (t) => { 827 | t.plan(5) 828 | 829 | const logs = [] 830 | const destination = new stream.Writable({ 831 | write: function (chunk, _encoding, next) { 832 | logs.push(JSON.parse(chunk)) 833 | next() 834 | } 835 | }) 836 | 837 | const fastify = Fastify({ logger: { level: 'info', stream: destination } }) 838 | 839 | await fastify.register(helmet) 840 | await fastify.register( 841 | fp( 842 | async (instance, _options) => { 843 | instance.addHook('onRequest', async (request, reply) => { 844 | const unauthorized = new Error('Unauthorized') 845 | 846 | const errorResponse = (err) => { 847 | return { error: err.message } 848 | } 849 | 850 | // We want to crash in the scope of this test 851 | const crash = 852 | request.routeOptions?.config?.fail || request.routeConfig.fail 853 | 854 | Promise.resolve(crash) 855 | .then((fail) => { 856 | if (fail === true) { 857 | reply.code(401) 858 | reply.send(errorResponse(unauthorized)) 859 | return reply 860 | } 861 | }) 862 | .catch(() => undefined) 863 | }) 864 | }, 865 | { 866 | name: 'regression-plugin-test' 867 | } 868 | ) 869 | ) 870 | 871 | fastify.get( 872 | '/fail', 873 | { 874 | config: { fail: true } 875 | }, 876 | async () => { 877 | return { message: 'unreachable' } 878 | } 879 | ) 880 | 881 | const expected = { 882 | 'x-dns-prefetch-control': 'off', 883 | 'x-frame-options': 'SAMEORIGIN', 884 | 'x-download-options': 'noopen', 885 | 'x-content-type-options': 'nosniff', 886 | 'x-xss-protection': '0' 887 | } 888 | 889 | const response = await fastify.inject({ 890 | method: 'GET', 891 | path: '/fail' 892 | }) 893 | 894 | const failure = logs.find( 895 | (entry) => entry.err && entry.err.statusCode === 500 896 | ) 897 | 898 | const actualResponseHeaders = { 899 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 900 | 'x-frame-options': response.headers['x-frame-options'], 901 | 'x-download-options': response.headers['x-download-options'], 902 | 'x-content-type-options': response.headers['x-content-type-options'], 903 | 'x-xss-protection': response.headers['x-xss-protection'] 904 | } 905 | 906 | if (failure) { 907 | t.not(failure.err.message, 'Reply was already sent.') 908 | t.not(failure.err.name, 'FastifyError') 909 | t.not(failure.err.code, 'FST_ERR_REP_ALREADY_SENT') 910 | t.not(failure.err.statusCode, 500) 911 | t.not(failure.msg, 'Reply already sent') 912 | } 913 | 914 | t.assert.deepStrictEqual(failure, undefined) 915 | 916 | t.assert.deepStrictEqual(response.statusCode, 401) 917 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 918 | t.assert.deepStrictEqual(JSON.parse(response.payload).error, 'Unauthorized') 919 | t.assert.notDeepStrictEqual( 920 | JSON.parse(response.payload).message, 921 | 'unreachable' 922 | ) 923 | }) 924 | 925 | test('It should forward `helmet` errors to `fastify-helmet`', async (t) => { 926 | t.plan(3) 927 | 928 | const fastify = Fastify() 929 | await fastify.register(helmet, { 930 | contentSecurityPolicy: { 931 | directives: { 932 | defaultSrc: ["'self'", () => 'bad;value'] 933 | } 934 | } 935 | }) 936 | 937 | fastify.get('/', async () => { 938 | return { message: 'ok' } 939 | }) 940 | 941 | const notExpected = { 942 | 'x-dns-prefetch-control': 'off', 943 | 'x-frame-options': 'SAMEORIGIN', 944 | 'x-download-options': 'noopen', 945 | 'x-content-type-options': 'nosniff', 946 | 'x-xss-protection': '0' 947 | } 948 | 949 | const response = await fastify.inject({ 950 | method: 'GET', 951 | path: '/' 952 | }) 953 | 954 | const actualResponseHeaders = { 955 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 956 | 'x-frame-options': response.headers['x-frame-options'], 957 | 'x-download-options': response.headers['x-download-options'], 958 | 'x-content-type-options': response.headers['x-content-type-options'], 959 | 'x-xss-protection': response.headers['x-xss-protection'] 960 | } 961 | 962 | t.assert.deepStrictEqual(response.statusCode, 500) 963 | t.assert.deepStrictEqual( 964 | JSON.parse(response.payload).message, 965 | 'Content-Security-Policy received an invalid directive value for "default-src"' 966 | ) 967 | t.assert.notDeepStrictEqual(actualResponseHeaders, notExpected) 968 | }) 969 | 970 | test('It should be able to catch `helmet` errors with a fastify `onError` hook', async (t) => { 971 | t.plan(7) 972 | 973 | const errorDetected = [] 974 | 975 | const fastify = Fastify() 976 | await fastify.register(helmet, { 977 | contentSecurityPolicy: { 978 | directives: { 979 | defaultSrc: ["'self'", () => 'bad;value'] 980 | } 981 | } 982 | }) 983 | 984 | fastify.addHook('onError', async (_request, _reply, error) => { 985 | if (error) { 986 | errorDetected.push(error) 987 | t.assert.ok(error) 988 | } 989 | }) 990 | 991 | fastify.get('/', async () => { 992 | return { message: 'ok' } 993 | }) 994 | 995 | const notExpected = { 996 | 'x-dns-prefetch-control': 'off', 997 | 'x-frame-options': 'SAMEORIGIN', 998 | 'x-download-options': 'noopen', 999 | 'x-content-type-options': 'nosniff', 1000 | 'x-xss-protection': '0' 1001 | } 1002 | 1003 | t.assert.deepStrictEqual(errorDetected.length, 0) 1004 | 1005 | const response = await fastify.inject({ 1006 | method: 'GET', 1007 | path: '/' 1008 | }) 1009 | 1010 | const actualResponseHeaders = { 1011 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 1012 | 'x-frame-options': response.headers['x-frame-options'], 1013 | 'x-download-options': response.headers['x-download-options'], 1014 | 'x-content-type-options': response.headers['x-content-type-options'], 1015 | 'x-xss-protection': response.headers['x-xss-protection'] 1016 | } 1017 | 1018 | t.assert.deepStrictEqual(response.statusCode, 500) 1019 | t.assert.deepStrictEqual( 1020 | JSON.parse(response.payload).message, 1021 | 'Content-Security-Policy received an invalid directive value for "default-src"' 1022 | ) 1023 | t.assert.notDeepStrictEqual(actualResponseHeaders, notExpected) 1024 | t.assert.deepStrictEqual(errorDetected.length, 1) 1025 | t.assert.deepStrictEqual( 1026 | errorDetected[0].message, 1027 | 'Content-Security-Policy received an invalid directive value for "default-src"' 1028 | ) 1029 | }) 1030 | -------------------------------------------------------------------------------- /test/routes.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const Fastify = require('fastify') 5 | const helmet = require('..') 6 | 7 | test('It should apply route specific helmet options over the global options', async (t) => { 8 | t.plan(2) 9 | 10 | const fastify = Fastify() 11 | await fastify.register(helmet, { global: true }) 12 | 13 | fastify.get('/', { helmet: { frameguard: false } }, (_request, reply) => { 14 | reply.send({ hello: 'world' }) 15 | }) 16 | 17 | const response = await fastify.inject({ 18 | method: 'GET', 19 | path: '/' 20 | }) 21 | 22 | const notExpected = { 23 | 'x-frame-options': 'SAMEORIGIN' 24 | } 25 | 26 | const expected = { 27 | 'x-dns-prefetch-control': 'off', 28 | 'x-download-options': 'noopen', 29 | 'x-content-type-options': 'nosniff', 30 | 'x-xss-protection': '0' 31 | } 32 | 33 | const actualResponseHeaders = { 34 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 35 | 'x-download-options': response.headers['x-download-options'], 36 | 'x-content-type-options': response.headers['x-content-type-options'], 37 | 'x-xss-protection': response.headers['x-xss-protection'] 38 | } 39 | 40 | t.assert.notDeepStrictEqual( 41 | response.headers['x-frame-options'], 42 | notExpected['x-frame-options'] 43 | ) 44 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 45 | }) 46 | 47 | test('It should disable helmet on specific route when route `helmet` option is set to `false`', async (t) => { 48 | t.plan(2) 49 | 50 | const fastify = Fastify() 51 | await fastify.register(helmet, { global: true }) 52 | 53 | fastify.get('/disabled', { helmet: false }, (_request, reply) => { 54 | reply.send({ hello: 'disabled' }) 55 | }) 56 | 57 | fastify.get('/enabled', (_request, reply) => { 58 | reply.send({ hello: 'enabled' }) 59 | }) 60 | 61 | const helmetHeaders = { 62 | 'x-frame-options': 'SAMEORIGIN', 63 | 'x-dns-prefetch-control': 'off', 64 | 'x-download-options': 'noopen', 65 | 'x-content-type-options': 'nosniff', 66 | 'x-xss-protection': '0' 67 | } 68 | 69 | await fastify 70 | .inject({ 71 | method: 'GET', 72 | path: '/disabled' 73 | }) 74 | .then((response) => { 75 | const actualResponseHeaders = { 76 | 'x-frame-options': response.headers['x-frame-options'], 77 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 78 | 'x-download-options': response.headers['x-download-options'], 79 | 'x-content-type-options': response.headers['x-content-type-options'], 80 | 'x-xss-protection': response.headers['x-xss-protection'] 81 | } 82 | 83 | t.assert.notDeepStrictEqual(actualResponseHeaders, helmetHeaders) 84 | }) 85 | .catch((err) => { 86 | t.assert.fail(err) 87 | }) 88 | 89 | await fastify 90 | .inject({ 91 | method: 'GET', 92 | path: '/enabled' 93 | }) 94 | .then((response) => { 95 | const actualResponseHeaders = { 96 | 'x-frame-options': response.headers['x-frame-options'], 97 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 98 | 'x-download-options': response.headers['x-download-options'], 99 | 'x-content-type-options': response.headers['x-content-type-options'], 100 | 'x-xss-protection': response.headers['x-xss-protection'] 101 | } 102 | t.assert.deepStrictEqual(actualResponseHeaders, helmetHeaders) 103 | }) 104 | .catch((err) => { 105 | t.assert.fail(err) 106 | }) 107 | }) 108 | 109 | test('It should add CSPNonce decorator and hooks when route `enableCSPNonces` option is set to `true`', async (t) => { 110 | t.plan(4) 111 | 112 | const fastify = Fastify() 113 | 114 | await fastify.register(helmet, { 115 | global: false, 116 | enableCSPNonces: false, 117 | contentSecurityPolicy: { 118 | directives: { 119 | 'script-src': ["'self'", "'unsafe-eval'", "'unsafe-inline'"], 120 | 'style-src': ["'self'", "'unsafe-inline'"] 121 | } 122 | } 123 | }) 124 | 125 | fastify.get( 126 | '/', 127 | { 128 | helmet: { 129 | enableCSPNonces: true 130 | } 131 | }, 132 | (_request, reply) => { 133 | t.assert.ok(reply.cspNonce) 134 | reply.send(reply.cspNonce) 135 | } 136 | ) 137 | 138 | const response = await fastify.inject({ method: 'GET', path: '/' }) 139 | const cspCache = response.json() 140 | 141 | const expected = { 142 | 'content-security-policy': `script-src 'self' 'unsafe-eval' 'unsafe-inline' 'nonce-${cspCache.script}';style-src 'self' 'unsafe-inline' 'nonce-${cspCache.style}';default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src-attr 'none';upgrade-insecure-requests` 143 | } 144 | 145 | const actualResponseHeaders = { 146 | 'content-security-policy': response.headers['content-security-policy'] 147 | } 148 | t.assert.ok(cspCache.script) 149 | t.assert.ok(cspCache.style) 150 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 151 | }) 152 | 153 | test('It should add CSPNonce decorator and hooks with default options when route `enableCSPNonces` option is set to `true`', async (t) => { 154 | t.plan(8) 155 | 156 | const fastify = Fastify() 157 | 158 | await fastify.register(helmet, { 159 | global: false, 160 | enableCSPNonces: false 161 | }) 162 | 163 | fastify.get('/no-csp', (_request, reply) => { 164 | t.assert.equal(reply.cspNonce, null) 165 | reply.send({ message: 'no csp' }) 166 | }) 167 | 168 | fastify.get( 169 | '/with-csp', 170 | { 171 | helmet: { 172 | enableCSPNonces: true 173 | } 174 | }, 175 | (_request, reply) => { 176 | t.assert.ok(reply.cspNonce) 177 | reply.send(reply.cspNonce) 178 | } 179 | ) 180 | 181 | fastify.inject({ 182 | method: 'GET', 183 | path: '/no-csp' 184 | }) 185 | 186 | let response 187 | 188 | response = await fastify.inject({ method: 'GET', path: '/with-csp' }) 189 | const cspCache = response.json() 190 | t.assert.ok(cspCache.script) 191 | t.assert.ok(cspCache.style) 192 | 193 | response = await fastify.inject({ method: 'GET', path: '/with-csp' }) 194 | const newCsp = response.json() 195 | t.assert.notEqual(cspCache, newCsp) 196 | t.assert.ok(cspCache.script) 197 | t.assert.ok(cspCache.style) 198 | }) 199 | 200 | test('It should not add CSPNonce decorator when route `enableCSPNonces` option is set to `false`', async (t) => { 201 | t.plan(8) 202 | 203 | const fastify = Fastify() 204 | 205 | await fastify.register(helmet, { 206 | global: true, 207 | enableCSPNonces: true 208 | }) 209 | 210 | fastify.get('/with-csp', (_request, reply) => { 211 | t.assert.ok(reply.cspNonce) 212 | reply.send(reply.cspNonce) 213 | }) 214 | 215 | fastify.get( 216 | '/no-csp', 217 | { helmet: { enableCSPNonces: false } }, 218 | (_request, reply) => { 219 | t.assert.equal(reply.cspNonce, null) 220 | reply.send({ message: 'no csp' }) 221 | } 222 | ) 223 | 224 | fastify.inject({ 225 | method: 'GET', 226 | path: '/no-csp' 227 | }) 228 | 229 | let response 230 | 231 | response = await fastify.inject({ method: 'GET', path: '/with-csp' }) 232 | const cspCache = response.json() 233 | t.assert.ok(cspCache.script) 234 | t.assert.ok(cspCache.style) 235 | 236 | response = await fastify.inject({ method: 'GET', path: '/with-csp' }) 237 | const newCsp = response.json() 238 | t.assert.notEqual(cspCache, newCsp) 239 | t.assert.ok(cspCache.script) 240 | t.assert.ok(cspCache.style) 241 | }) 242 | 243 | test('It should not set default directives when route useDefaults is set to `false`', async (t) => { 244 | t.plan(1) 245 | 246 | const fastify = Fastify() 247 | 248 | await fastify.register(helmet, { 249 | global: false, 250 | enableCSPNonces: false, 251 | contentSecurityPolicy: { 252 | directives: {} 253 | } 254 | }) 255 | 256 | fastify.get( 257 | '/', 258 | { 259 | helmet: { 260 | contentSecurityPolicy: { 261 | useDefaults: false, 262 | directives: { 263 | 'default-src': ["'self'"], 264 | 'script-src': ["'self'", "'unsafe-eval'", "'unsafe-inline'"], 265 | 'style-src': ["'self'", "'unsafe-inline'"] 266 | } 267 | } 268 | } 269 | }, 270 | (_request, reply) => { 271 | reply.send({ hello: 'world' }) 272 | } 273 | ) 274 | 275 | const response = await fastify.inject({ method: 'GET', path: '/' }) 276 | 277 | const expected = { 278 | 'content-security-policy': 279 | "default-src 'self';script-src 'self' 'unsafe-eval' 'unsafe-inline';style-src 'self' 'unsafe-inline'" 280 | } 281 | 282 | const actualResponseHeaders = { 283 | 'content-security-policy': response.headers['content-security-policy'] 284 | } 285 | 286 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 287 | }) 288 | 289 | test('It should not set `content-security-policy` header, if route contentSecurityPolicy is false', async (t) => { 290 | t.plan(1) 291 | 292 | const fastify = Fastify() 293 | 294 | await fastify.register(helmet, { 295 | global: false, 296 | enableCSPNonces: false, 297 | contentSecurityPolicy: { 298 | directives: {} 299 | } 300 | }) 301 | 302 | fastify.get( 303 | '/', 304 | { 305 | helmet: { 306 | contentSecurityPolicy: false 307 | } 308 | }, 309 | (_request, reply) => { 310 | reply.send({ hello: 'world' }) 311 | } 312 | ) 313 | 314 | const response = await fastify.inject({ method: 'GET', path: '/' }) 315 | 316 | const expected = { 317 | 'content-security-policy': undefined 318 | } 319 | 320 | const actualResponseHeaders = { 321 | 'content-security-policy': response.headers['content-security-policy'] 322 | } 323 | 324 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 325 | }) 326 | 327 | test('It should be able to conditionally apply the middlewares through the `helmet` reply decorator', async (t) => { 328 | t.plan(10) 329 | 330 | const fastify = Fastify() 331 | await fastify.register(helmet, { global: false }) 332 | 333 | fastify.get('/:condition', async (request, reply) => { 334 | const { condition } = request.params 335 | 336 | t.assert.ok(reply.helmet) 337 | t.assert.notEqual(reply.helmet, null) 338 | 339 | if (condition !== 'frameguard') { 340 | await reply.helmet({ frameguard: false }) 341 | } else { 342 | await reply.helmet({ frameguard: true }) 343 | } 344 | return { message: 'ok' } 345 | }) 346 | 347 | const expected = { 348 | 'x-dns-prefetch-control': 'off', 349 | 'x-download-options': 'noopen', 350 | 'x-content-type-options': 'nosniff', 351 | 'x-xss-protection': '0' 352 | } 353 | 354 | const maybeExpected = { 355 | 'x-frame-options': 'SAMEORIGIN' 356 | } 357 | 358 | { 359 | const response = await fastify.inject({ 360 | method: 'GET', 361 | path: '/no-frameguard' 362 | }) 363 | 364 | const actualResponseHeaders = { 365 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 366 | 'x-download-options': response.headers['x-download-options'], 367 | 'x-content-type-options': response.headers['x-content-type-options'], 368 | 'x-xss-protection': response.headers['x-xss-protection'] 369 | } 370 | 371 | t.assert.equal(response.statusCode, 200) 372 | t.assert.notDeepStrictEqual( 373 | response.headers['x-frame-options'], 374 | maybeExpected['x-frame-options'] 375 | ) 376 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 377 | } 378 | 379 | const response = await fastify.inject({ 380 | method: 'GET', 381 | path: '/frameguard' 382 | }) 383 | 384 | const actualResponseHeaders = { 385 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 386 | 'x-download-options': response.headers['x-download-options'], 387 | 'x-content-type-options': response.headers['x-content-type-options'], 388 | 'x-xss-protection': response.headers['x-xss-protection'] 389 | } 390 | 391 | t.assert.equal(response.statusCode, 200) 392 | t.assert.deepStrictEqual( 393 | response.headers['x-frame-options'], 394 | maybeExpected['x-frame-options'] 395 | ) 396 | t.assert.deepStrictEqual(actualResponseHeaders, expected) 397 | }) 398 | 399 | test('It should throw an error when route specific helmet options are of an invalid type', async (t) => { 400 | t.plan(2) 401 | 402 | const fastify = Fastify() 403 | await fastify.register(helmet) 404 | 405 | try { 406 | fastify.get('/', { helmet: 'invalid_options' }, () => { 407 | return { message: 'ok' } 408 | }) 409 | } catch (error) { 410 | t.assert.ok(error) 411 | t.assert.equal( 412 | error.message, 413 | 'Unknown value for route helmet configuration' 414 | ) 415 | } 416 | }) 417 | 418 | test('It should forward `helmet` reply decorator and route specific errors to `fastify-helmet`', async (t) => { 419 | t.plan(6) 420 | 421 | const fastify = Fastify() 422 | await fastify.register(helmet, { global: false }) 423 | 424 | fastify.get('/helmet-reply-decorator-error', async (_request, reply) => { 425 | await reply.helmet({ 426 | contentSecurityPolicy: { 427 | directives: { 428 | defaultSrc: ["'self'", () => 'bad;value'] 429 | } 430 | } 431 | }) 432 | 433 | return { message: 'ok' } 434 | }) 435 | 436 | fastify.get( 437 | '/helmet-route-configuration-error', 438 | { 439 | helmet: { 440 | contentSecurityPolicy: { 441 | directives: { 442 | defaultSrc: ["'self'", () => 'bad;value'] 443 | } 444 | } 445 | } 446 | }, 447 | async () => { 448 | return { message: 'ok' } 449 | } 450 | ) 451 | 452 | const notExpected = { 453 | 'x-dns-prefetch-control': 'off', 454 | 'x-frame-options': 'SAMEORIGIN', 455 | 'x-download-options': 'noopen', 456 | 'x-content-type-options': 'nosniff', 457 | 'x-xss-protection': '0' 458 | } 459 | 460 | { 461 | const response = await fastify.inject({ 462 | method: 'GET', 463 | path: '/helmet-reply-decorator-error' 464 | }) 465 | 466 | const actualResponseHeaders = { 467 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 468 | 'x-download-options': response.headers['x-download-options'], 469 | 'x-content-type-options': response.headers['x-content-type-options'], 470 | 'x-xss-protection': response.headers['x-xss-protection'] 471 | } 472 | 473 | t.assert.equal(response.statusCode, 500) 474 | t.assert.equal( 475 | JSON.parse(response.payload).message, 476 | 'Content-Security-Policy received an invalid directive value for "default-src"' 477 | ) 478 | t.assert.notDeepStrictEqual(actualResponseHeaders, notExpected) 479 | } 480 | 481 | const response = await fastify.inject({ 482 | method: 'GET', 483 | path: '/helmet-route-configuration-error' 484 | }) 485 | 486 | const actualResponseHeaders = { 487 | 'x-dns-prefetch-control': response.headers['x-dns-prefetch-control'], 488 | 'x-download-options': response.headers['x-download-options'], 489 | 'x-content-type-options': response.headers['x-content-type-options'], 490 | 'x-xss-protection': response.headers['x-xss-protection'] 491 | } 492 | 493 | t.assert.equal(response.statusCode, 500) 494 | t.assert.equal( 495 | JSON.parse(response.payload).message, 496 | 'Content-Security-Policy received an invalid directive value for "default-src"' 497 | ) 498 | t.assert.notDeepStrictEqual(actualResponseHeaders, notExpected) 499 | }) 500 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync, RawServerBase, RawServerDefault } from 'fastify' 2 | import helmet, { contentSecurityPolicy, HelmetOptions } from 'helmet' 3 | 4 | declare module 'fastify' { 5 | export interface RouteShorthandOptions< 6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | RawServer extends RawServerBase = RawServerDefault 8 | > extends fastifyHelmet.FastifyHelmetRouteOptions { } 9 | 10 | interface FastifyReply { 11 | cspNonce: { 12 | script: string; 13 | style: string; 14 | }, 15 | helmet: (opts?: HelmetOptions) => typeof helmet 16 | } 17 | 18 | export interface RouteOptions extends fastifyHelmet.FastifyHelmetRouteOptions { } 19 | } 20 | 21 | type FastifyHelmet = FastifyPluginAsync & { 22 | contentSecurityPolicy: typeof contentSecurityPolicy; 23 | } 24 | 25 | declare namespace fastifyHelmet { 26 | 27 | export interface FastifyHelmetRouteOptions { 28 | helmet?: Omit | false; 29 | } 30 | 31 | export type FastifyHelmetOptions = { 32 | enableCSPNonces?: boolean, 33 | global?: boolean; 34 | } & NonNullable 35 | 36 | export const fastifyHelmet: FastifyHelmet 37 | export { fastifyHelmet as default } 38 | } 39 | 40 | declare function fastifyHelmet (...params: Parameters): ReturnType 41 | export = fastifyHelmet 42 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyPluginAsync } from 'fastify' 2 | import helmet from 'helmet' 3 | import { expectAssignable, expectError, expectType } from 'tsd' 4 | import fastifyHelmet, { FastifyHelmetOptions, FastifyHelmetRouteOptions } from '..' 5 | 6 | // Plugin registered with no options 7 | const appOne = fastify() 8 | appOne.register(fastifyHelmet) 9 | 10 | // Plugin registered with an empty object option 11 | const appTwo = fastify() 12 | expectAssignable({}) 13 | appTwo.register(fastifyHelmet, {}) 14 | 15 | // Plugin registered with all helmet middlewares disabled 16 | const appThree = fastify() 17 | const helmetOptions = { 18 | contentSecurityPolicy: false, 19 | dnsPrefetchControl: false, 20 | frameguard: false, 21 | hidePoweredBy: false, 22 | hsts: false, 23 | ieNoOpen: false, 24 | noSniff: false, 25 | permittedCrossDomainPolicies: false, 26 | referrerPolicy: false, 27 | xssFilter: false 28 | } 29 | expectAssignable(helmetOptions) 30 | appThree.register(fastifyHelmet, helmetOptions) 31 | 32 | // Plugin registered with helmet middlewares custom settings 33 | const appFour = fastify() 34 | appFour.register(fastifyHelmet, { 35 | contentSecurityPolicy: { 36 | directives: { 37 | 'directive-1': ['foo', 'bar'] 38 | }, 39 | reportOnly: true, 40 | useDefaults: false 41 | }, 42 | dnsPrefetchControl: { 43 | allow: true 44 | }, 45 | frameguard: { 46 | action: 'deny' 47 | }, 48 | hsts: { 49 | maxAge: 1, 50 | includeSubDomains: true, 51 | preload: true 52 | }, 53 | permittedCrossDomainPolicies: { 54 | permittedPolicies: 'master-only' 55 | }, 56 | referrerPolicy: { 57 | policy: 'no-referrer' 58 | } 59 | // these options are false or never 60 | // hidePoweredBy: false 61 | // ieNoOpen: false, 62 | // noSniff: false, 63 | // xssFilter: false 64 | }) 65 | 66 | // Plugin registered with `enableCSPNonces` option and helmet default CSP settings 67 | const appFive = fastify() 68 | appFive.register(fastifyHelmet, { enableCSPNonces: true }) 69 | 70 | appFive.get('/', function (_request, reply) { 71 | expectType<{ 72 | script: string; 73 | style: string; 74 | }>(reply.cspNonce) 75 | }) 76 | 77 | // Plugin registered with `enableCSPNonces` option and custom CSP settings 78 | const appSix = fastify() 79 | appSix.register(fastifyHelmet, { 80 | enableCSPNonces: true, 81 | contentSecurityPolicy: { 82 | directives: { 83 | 'directive-1': ['foo', 'bar'] 84 | }, 85 | reportOnly: true 86 | } 87 | }) 88 | 89 | appSix.get('/', function (_request, reply) { 90 | expectType<{ 91 | script: string; 92 | style: string; 93 | }>(reply.cspNonce) 94 | }) 95 | 96 | const csp = fastifyHelmet.contentSecurityPolicy 97 | expectType(csp) 98 | 99 | // Plugin registered with `global` set to `true` 100 | const appSeven = fastify() 101 | appSeven.register(fastifyHelmet, { global: true }) 102 | 103 | appSeven.get('/route-with-disabled-helmet', { helmet: false }, function (_request, reply) { 104 | expectType(reply.helmet()) 105 | }) 106 | 107 | expectError( 108 | appSeven.get('/route-with-disabled-helmet', { 109 | helmet: 'trigger a typescript error' 110 | }, function (_request, reply) { 111 | expectType(reply.helmet()) 112 | }) 113 | ) 114 | 115 | // Plugin registered with `global` set to `false` 116 | const appEight = fastify() 117 | appEight.register(fastifyHelmet, { global: false }) 118 | 119 | appEight.get('/disabled-helmet', function (_request, reply) { 120 | expectType(reply.helmet(helmetOptions)) 121 | }) 122 | 123 | const routeHelmetOptions = { 124 | helmet: { 125 | enableCSPNonces: true, 126 | contentSecurityPolicy: { 127 | directives: { 128 | 'directive-1': ['foo', 'bar'] 129 | }, 130 | reportOnly: true 131 | }, 132 | dnsPrefetchControl: { 133 | allow: true 134 | }, 135 | frameguard: { 136 | action: 'deny' as const 137 | }, 138 | hsts: { 139 | maxAge: 1, 140 | includeSubDomains: true, 141 | preload: true 142 | }, 143 | permittedCrossDomainPolicies: { 144 | permittedPolicies: 'all' as const 145 | }, 146 | referrerPolicy: { 147 | policy: 'no-referrer' as const 148 | } 149 | } 150 | } 151 | expectAssignable(routeHelmetOptions) 152 | 153 | appEight.get('/enabled-helmet', routeHelmetOptions, function (_request, reply) { 154 | expectType(reply.helmet()) 155 | expectType<{ 156 | script: string; 157 | style: string; 158 | }>(reply.cspNonce) 159 | }) 160 | 161 | appEight.get('/enable-framegard', { 162 | helmet: { frameguard: true } 163 | }, function (_request, reply) { 164 | expectType(reply.helmet()) 165 | expectType<{ 166 | script: string; 167 | style: string; 168 | }>(reply.cspNonce) 169 | }) 170 | 171 | // Plugin registered with an invalid helmet option 172 | const appThatTriggerAnError = fastify() 173 | expectError( 174 | appThatTriggerAnError.register(fastifyHelmet, { 175 | thisOptionDoesNotExist: 'trigger a typescript error' 176 | }) 177 | ) 178 | 179 | // fastify-helmet instance is using the FastifyHelmetOptions options 180 | expectType< 181 | FastifyPluginAsync & { 182 | contentSecurityPolicy: typeof helmet.contentSecurityPolicy; 183 | } 184 | >(fastifyHelmet) 185 | --------------------------------------------------------------------------------