diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..81c51f98 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "none", + "semi": false +} diff --git a/.stackblitz/package.json b/.stackblitz/package.json index 17f812c9..56ee1253 100644 --- a/.stackblitz/package.json +++ b/.stackblitz/package.json @@ -11,6 +11,6 @@ "nuxt": "3.9.3" }, "dependencies": { - "nuxt-security": "^1.1.2" + "nuxt-security": "^1.2.0" } } diff --git a/.stackblitz/yarn.lock b/.stackblitz/yarn.lock index a14d1088..d90c5104 100644 --- a/.stackblitz/yarn.lock +++ b/.stackblitz/yarn.lock @@ -4101,10 +4101,10 @@ nuxt-csurf@^1.3.1: defu "^6.1.1" uncsrf "^1.1.1" -nuxt-security@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/nuxt-security/-/nuxt-security-1.1.2.tgz#08079f5cf55dc3f479be664c93195cd9c3b68c9f" - integrity sha512-Cdn8Cg5gJy+QO/QjlJgb1Gv03mtSZ6NP8Fb2LEjtsRqmWci6DDYI0D5TJYoj9SpVCYOiw9YVsawGX8HPK/YRMg== +nuxt-security@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/nuxt-security/-/nuxt-security-1.2.0.tgz#7b98bca275f45cb652466849413eee5bdc74657c" + integrity sha512-pAXW1vqMeMC6PnwzgE/Gf75BMOSQpSLbrgnZh9qmNf1Ytj7YI+Z180KZbkSXu944cFt6TGO0BDwlzc5Ij8uxPw== dependencies: "@nuxt/kit" "^3.8.0" basic-auth "^2.0.1" diff --git a/docs/content/1.documentation/1.getting-started/2.configuration.md b/docs/content/1.documentation/1.getting-started/2.configuration.md index c17ebae7..db0778e5 100644 --- a/docs/content/1.documentation/1.getting-started/2.configuration.md +++ b/docs/content/1.documentation/1.getting-started/2.configuration.md @@ -115,6 +115,7 @@ security: { exclude: [/node_modules/, /\.git/] }, ssg: { + meta: true, hashScripts: true, hashStyles: false }, diff --git a/docs/content/1.documentation/2.headers/1.csp.md b/docs/content/1.documentation/2.headers/1.csp.md index e2d60166..95a900ac 100644 --- a/docs/content/1.documentation/2.headers/1.csp.md +++ b/docs/content/1.documentation/2.headers/1.csp.md @@ -155,6 +155,7 @@ export default defineNuxtConfig({ security: { nonce: true, // Enables HTML nonce support in SSR mode ssg: { + meta: true, // Enables CSP as a meta tag in SSG mode hashScripts: true, // Enables CSP hash support for scripts in SSG mode hashStyles: false // Disables CSP hash support for styles in SSG mode (recommended) }, @@ -263,6 +264,7 @@ export default defineNuxtConfig({ // Global security: { ssg: { + meta: true, // Enables CSP as a meta tag in SSG mode hashScripts: true, // Enables CSP hash support for scripts in SSG mode hashStyles: false // Disables CSP hash support for styles in SSG mode (recommended) }, diff --git a/docs/content/1.documentation/3.middleware/3.xss-validator.md b/docs/content/1.documentation/3.middleware/3.xss-validator.md index 61b45f3f..52b8ea24 100644 --- a/docs/content/1.documentation/3.middleware/3.xss-validator.md +++ b/docs/content/1.documentation/3.middleware/3.xss-validator.md @@ -6,7 +6,7 @@ :ellipsis{right=0px width=75% blur=150px} -This middleware works for both `GET`, `POST` methods and will throw an `400 Bad Request` error when the either body or query params will contain unsecure code. Based on +This middleware works by default for both `GET` and `POST` methods, and will throw a `400 Bad Request` error when either the body or the query params contain unsafe code. Based on ::alert{type="info"} ℹ Read more about performing output escaping [here](https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html#perform-output-escaping). @@ -43,11 +43,13 @@ You can also disable the middleware globally or per route by setting `xssValidat ## Options -XSS validator accepts following configuration options: +XSS validator accepts the following configuration options: ```ts type XssValidator = { + methods: Array>; whiteList: Record; + escapeHtml: boolean; stripIgnoreTag: boolean; stripIgnoreTagBody: boolean; css: Record | boolean; @@ -55,6 +57,11 @@ type XssValidator = { } | {}; ``` +### `methods` +- Default: `['GET', 'POST']` + +List of methods for which the validator will be invoked. + ### `whiteList` - Default: `-` @@ -73,6 +80,12 @@ Filter out tags not in the whitelist Filter out tags and tag bodies not in the whitelist +### `escapeHtml` + +- Default: `-` + +Disable html escaping (by default `<` is replaced by `<` and `>` by `>`) + ### `css` - Default: `-` diff --git a/docs/content/1.documentation/5.advanced/3.strict-csp.md b/docs/content/1.documentation/5.advanced/3.strict-csp.md index a9392a80..b046dbc1 100644 --- a/docs/content/1.documentation/5.advanced/3.strict-csp.md +++ b/docs/content/1.documentation/5.advanced/3.strict-csp.md @@ -569,7 +569,7 @@ Nuxt Security supports CSP via HTTP headers for Nitro Presets that output HTTP h ::alert{type="info"} If you deploy your SSG site on Vercel or Netlify, you will benefit automatically from CSP Headers.
-CSP will be delivered via HTTP headers, in addition to the standard `` approach. +CSP will be delivered via HTTP headers, in addition to the standard `` approach. If you want to disable the meta tag, so that only the HTTP headers are used, you can do so with the `ssg: meta` option. :: ### Per Route CSP @@ -608,6 +608,7 @@ export default defineNuxtConfig({ security: { nonce: true, // Enables HTML nonce support in SSR mode ssg: { + meta: true, // Enables CSP as a meta tag in SSG mode hashScripts: true, // Enables CSP hash support for scripts in SSG mode hashStyles: false // Disables CSP hash support for styles in SSG mode (recommended) }, diff --git a/package.json b/package.json index cf5a67e2..1efb9e79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nuxt-security", - "version": "1.1.2", + "version": "1.2.0", "license": "MIT", "type": "module", "homepage": "https://nuxt-security.vercel.app", diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 7e277481..a9b29864 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -26,4 +26,4 @@ export default defineNuxtConfig({ }, runtimeHooks: true } -}) \ No newline at end of file +}) diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index 339bbb91..b1105371 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -55,6 +55,7 @@ export const defaultSecurityConfig = (serverlUrl: string): Partial({ } if (nuxt.options.security.xssValidator) { + // Remove potential duplicates + nuxt.options.security.xssValidator.methods = Array.from(new Set(nuxt.options.security.xssValidator.methods)) addServerHandler({ handler: normalize( resolve(runtimeDir, 'server/middleware/xssValidator') diff --git a/src/runtime/nitro/plugins/04-cspSsgHashes.ts b/src/runtime/nitro/plugins/04-cspSsgHashes.ts index 98b76618..4b28805b 100644 --- a/src/runtime/nitro/plugins/04-cspSsgHashes.ts +++ b/src/runtime/nitro/plugins/04-cspSsgHashes.ts @@ -101,8 +101,10 @@ export default defineNitroPlugin((nitroApp) => { // Generate CSP rules const csp = security.headers.contentSecurityPolicy const headerValue = generateCspRules(csp, scriptHashes, styleHashes) - // Insert CSP in the http meta tag - cheerios.head.push(cheerio.load(``)) + // Insert CSP in the http meta tag if meta is true + if (security.ssg?.meta) { + cheerios.head.push(cheerio.load(``)) + } // Update rules in HTTP header setResponseHeader(event, 'Content-Security-Policy', headerValue) }) diff --git a/src/runtime/server/middleware/xssValidator.ts b/src/runtime/server/middleware/xssValidator.ts index b189870a..2833ffd4 100644 --- a/src/runtime/server/middleware/xssValidator.ts +++ b/src/runtime/server/middleware/xssValidator.ts @@ -1,14 +1,19 @@ -import { FilterXSS } from 'xss' +import { FilterXSS, IFilterXSSOptions } from 'xss' import { defineEventHandler, createError, getQuery, readBody, getRouteRules } from '#imports' +import { HTTPMethod } from '~/src/module' export default defineEventHandler(async (event) => { const { security } = getRouteRules(event) if (security?.xssValidator) { - const xssValidator = new FilterXSS(security.xssValidator) + const filterOpt: IFilterXSSOptions = { ...security.xssValidator, escapeHtml: undefined } + if (security.xssValidator.escapeHtml === false) { // No html escaping (by default "<" is replaced by "<" and ">" by ">") + filterOpt.escapeHtml = (value: string) => value + } + const xssValidator = new FilterXSS(filterOpt) if (event.node.req.socket.readyState !== 'readOnly') { - if (['POST', 'GET'].includes(event.node.req.method!)) { + if (security.xssValidator.methods && security.xssValidator.methods.includes(event.node.req.method! as HTTPMethod)) { const valueToFilter = event.node.req.method === 'GET' ? getQuery(event) diff --git a/src/types/index.ts b/src/types/index.ts index 5efe1886..eb2405f6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,7 @@ import type { SecurityHeaders } from './headers' import type { AllowedHTTPMethods, BasicAuth, RateLimiter, RequestSizeLimiter, XssValidator, CorsOptions } from './middlewares' export type Ssg = { + meta?: boolean; hashScripts?: boolean; hashStyles?: boolean; }; diff --git a/src/types/middlewares.ts b/src/types/middlewares.ts index 2a859237..65f85893 100644 --- a/src/types/middlewares.ts +++ b/src/types/middlewares.ts @@ -16,7 +16,12 @@ export type RateLimiter = { }; export type XssValidator = { + /** Array of methods for which the validator will be invoked. + @default ['GET', 'POST'] + */ + methods?: Array; whiteList?: Record; + escapeHtml?: boolean; stripIgnoreTag?: boolean; stripIgnoreTagBody?: boolean; css?: Record | boolean; @@ -32,7 +37,7 @@ export type BasicAuth = { message: string; } -export type HTTPMethod = 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'POST' | string; +export type HTTPMethod = 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT' | 'TRACE' | 'OPTIONS' | 'CONNECT' | 'HEAD'; // Cannot use the H3CorsOptions from `h3` as it breaks the build process for some reason :( export type CorsOptions = { diff --git a/test/fixtures/perRoute/nuxt.config.ts b/test/fixtures/perRoute/nuxt.config.ts index 757dba3c..a72b74f3 100644 --- a/test/fixtures/perRoute/nuxt.config.ts +++ b/test/fixtures/perRoute/nuxt.config.ts @@ -1,11 +1,9 @@ export default defineNuxtConfig({ - modules: [ - '../../../src/module' - ], + modules: ['../../../src/module'], routeRules: { '/**': { headers: { - 'foo': 'bar', + foo: 'bar', 'Referrer-Policy': 'no-referrer-when-downgrade' } }, @@ -19,7 +17,7 @@ export default defineNuxtConfig({ headers: { crossOriginOpenerPolicy: 'same-origin-allow-popups' } - }, + } }, '/ignore-specific/**': { security: { @@ -34,7 +32,7 @@ export default defineNuxtConfig({ headers: { crossOriginResourcePolicy: false, crossOriginOpenerPolicy: undefined, - crossOriginEmbedderPolicy: 'credentialless', + crossOriginEmbedderPolicy: 'credentialless' } } }, @@ -42,7 +40,7 @@ export default defineNuxtConfig({ security: { headers: { crossOriginResourcePolicy: 'same-site', - crossOriginOpenerPolicy: false, + crossOriginOpenerPolicy: false } } }, @@ -51,9 +49,10 @@ export default defineNuxtConfig({ 'Cross-Origin-Resource-Policy': 'cross-origin', 'Strict-Transport-Security': 'max-age=1; preload;', 'Permissions-Policy': 'fullscreen=*, camera=(self)', - 'Content-Security-Policy': "script-src 'self' https:; media-src 'none';", - 'foo': 'baz', - 'foo2': 'baz2' + 'Content-Security-Policy': + "script-src 'self' https:; media-src 'none';", + foo: 'baz', + foo2: 'baz2' } }, '/resolve-conflict/**': { @@ -63,8 +62,8 @@ export default defineNuxtConfig({ 'Cross-Origin-Embedder-Policy': 'unsafe-none', 'Strict-Transport-Security': 'max-age=1; preload;', 'Permissions-Policy': 'fullscreen=*', - 'foo': 'baz', - 'foo2': 'baz2' + foo: 'baz', + foo2: 'baz2' }, security: { headers: { @@ -118,9 +117,9 @@ export default defineNuxtConfig({ }, //@ts-ignore - Intentional as we test backwards compatibility with a deprecated syntax 'Content-Security-Policy': { - "base-uri": false, - "script-src": "'self'", - "img-src": ['https:'] + 'base-uri': false, + 'script-src': "'self'", + 'img-src': ['https:'] } } }, @@ -198,7 +197,7 @@ export default defineNuxtConfig({ nonce: false, headers: { contentSecurityPolicy: { - "script-src": ["'nonce-{{nonce}}'"] + 'script-src': ["'nonce-{{nonce}}'"] } } } @@ -214,16 +213,44 @@ export default defineNuxtConfig({ } }, '/csp-hash/deep/disabled': { - prerender: true + prerender: true, + security: { + ssg: { + meta: true, + hashScripts: false + } + } }, '/csp-hash/deep/enabled': { prerender: true, security: { ssg: { + meta: true, hashScripts: true } } }, + '/csp-meta/**': { + security: { + ssg: false + } + }, + '/csp-meta/deep/disabled': { + prerender: true, + security: { + ssg: { + meta: false, + } + } + }, + '/csp-meta/deep/enabled': { + prerender: true, + security: { + ssg: { + meta: true, + } + } + }, '/sri-attribute/**': { security: { sri: false @@ -254,15 +281,20 @@ export default defineNuxtConfig({ }, security: { headers: { - contentSecurityPolicy: false, + contentSecurityPolicy: false } } - }, + } }, security: { headers: { contentSecurityPolicy: { - "script-src": ["'self'", 'https:', "'unsafe-inline'", "'strict-dynamic'"] + 'script-src': [ + "'self'", + 'https:', + "'unsafe-inline'", + "'strict-dynamic'" + ] } } } diff --git a/test/fixtures/perRoute/pages/csp-meta/deep/disabled.vue b/test/fixtures/perRoute/pages/csp-meta/deep/disabled.vue new file mode 100644 index 00000000..f8d2c744 --- /dev/null +++ b/test/fixtures/perRoute/pages/csp-meta/deep/disabled.vue @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/test/fixtures/perRoute/pages/csp-meta/deep/enabled.vue b/test/fixtures/perRoute/pages/csp-meta/deep/enabled.vue new file mode 100644 index 00000000..f8d2c744 --- /dev/null +++ b/test/fixtures/perRoute/pages/csp-meta/deep/enabled.vue @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/test/fixtures/ssgHashes/nuxt.config.ts b/test/fixtures/ssgHashes/nuxt.config.ts index 5bbce627..557646d5 100644 --- a/test/fixtures/ssgHashes/nuxt.config.ts +++ b/test/fixtures/ssgHashes/nuxt.config.ts @@ -1,5 +1,4 @@ export default defineNuxtConfig({ - modules: ['../../../src/module'], // Per route configuration @@ -8,7 +7,7 @@ export default defineNuxtConfig({ prerender: true }, '/inline-script': { - prerender: true, + prerender: true }, '/inline-style': { prerender: true @@ -24,6 +23,14 @@ export default defineNuxtConfig({ }, '/not-ssg': { prerender: false + }, + '/no-meta-tag': { + prerender: true, + security: { + ssg: { + meta: false + } + } } }, @@ -32,9 +39,9 @@ export default defineNuxtConfig({ rateLimiter: false, sri: true, ssg: { + meta: true, hashScripts: true, hashStyles: true } - }, - + } }) diff --git a/test/fixtures/ssgHashes/pages/no-meta-tag.vue b/test/fixtures/ssgHashes/pages/no-meta-tag.vue new file mode 100644 index 00000000..434562b0 --- /dev/null +++ b/test/fixtures/ssgHashes/pages/no-meta-tag.vue @@ -0,0 +1,5 @@ + diff --git a/test/perRoute.test.ts b/test/perRoute.test.ts index a2cd6cae..59e26da8 100644 --- a/test/perRoute.test.ts +++ b/test/perRoute.test.ts @@ -871,6 +871,42 @@ describe('[nuxt-security] Per-route Configuration', async () => { expect(hashes).toHaveLength(2) }) + it('does not inject CSP meta on a deeply-disabled route', async () => { + const res = await fetch('/csp-meta/deep/disabled') + // DISABLING THIS PART OF THE TEST AFTER PATCH #348 THAT REMOVES CSP SSG PRESETS + /* + const cspHeaderValue = res.headers.get('content-security-policy') + expect(cspHeaderValue).toBeDefined() + */ + + const text = await res.text() + const head = text.match(/(.*?)<\/head>/s)?.[1] + expect(head).toBeDefined() + + const content = head!.match( + // + )?.[1] + expect(content).toBeUndefined() + }) + + it('injects CSP meta on a deeply-enabled route', async () => { + const res = await fetch('/csp-meta/deep/enabled') + // DISABLING THIS PART OF THE TEST AFTER PATCH #348 THAT REMOVES CSP SSG PRESETS + /* + const cspHeaderValue = res.headers.get('content-security-policy') + expect(cspHeaderValue).toBeDefined() + */ + + const text = await res.text() + const head = text.match(/(.*?)<\/head>/s)?.[1] + expect(head).toBeDefined() + + const content = head!.match( + // + )?.[1] + expect(content).toBeDefined() + }) + it('does not inject SRI attributes on a deeply-disabled route', async () => { const res = await fetch('/sri-attribute/deep/disabled') @@ -892,7 +928,7 @@ describe('[nuxt-security] Per-route Configuration', async () => { it ('does not overwrite middleware headers when false', async () => { const res = await fetch('/preserve-middleware') expect(res.status).toBe(200) - + const { headers } = res const csp = headers.get('content-security-policy') expect(csp).toBeDefined() @@ -905,7 +941,7 @@ describe('[nuxt-security] Per-route Configuration', async () => { it ('overwrites middleware headers when not false', async () => { const res = await fetch('/preserve-middleware/deep/page') expect(res.status).toBe(200) - + const { headers } = res const csp = headers.get('content-security-policy') expect(csp).toBeDefined() @@ -918,7 +954,7 @@ describe('[nuxt-security] Per-route Configuration', async () => { it ('removes deprecated standard headers when false', async () => { const res = await fetch('/remove-deprecated') expect(res.status).toBe(200) - + const { headers } = res const csp = headers.get('content-security-policy') expect(csp).toBeDefined() diff --git a/test/ssgHashes.test.ts b/test/ssgHashes.test.ts index 50f34dbe..2a7362c5 100644 --- a/test/ssgHashes.test.ts +++ b/test/ssgHashes.test.ts @@ -114,7 +114,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(body).toBeDefined() expect(metaTag).toBeDefined() expect(csp).toBeDefined() - expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes + 1) // + 1 External style + expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes + 1) // + 1 External style expect(inlineScriptHashes).toBe(expectedInlineScriptHashes) expect(externalScriptHashes).toBe(expectedExternalScriptHashes + 1) // + 1 vue modulepreload expect(inlineStyleHashes).toBe(expectedInlineStyleHashes) @@ -157,4 +157,22 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(inlineStyleHashes).toBe(0) expect(externalStyleHashes).toBe(0) }) + + it('does not set CSP via meta when disabled', async () => { + const res = await fetch('/no-meta-tag') + + const body = await res.text() + const { metaTag, csp, elementsWithIntegrity, inlineScriptHashes, externalScriptHashes, inlineStyleHashes, externalStyleHashes } = extractDataFromBody(body) + + expect(res).toBeDefined() + expect(res).toBeTruthy() + expect(body).toBeDefined() + expect(metaTag).toBeNull() + expect(csp).toBeUndefined() + expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes - 1) // No vue on no-meta-tag page + expect(inlineScriptHashes).toBe(0) + expect(externalScriptHashes).toBe(0) + expect(inlineStyleHashes).toBe(0) + expect(externalStyleHashes).toBe(0) + }) })