diff --git a/package.json b/package.json index 4bcf0109..720bbd94 100644 --- a/package.json +++ b/package.json @@ -52,26 +52,26 @@ }, "packageManager": "yarn@1.22.19", "dependencies": { - "@nuxt/kit": "^3.11.2", + "@nuxt/kit": "^4.2.1", "basic-auth": "^2.0.1", - "defu": "^6.1.1", + "defu": "^6.1.4", "nuxt-csurf": "^1.6.5", - "pathe": "^1.0.0", + "pathe": "^2.0.3", "unplugin-remove": "^1.0.3", - "xss": "^1.0.14" + "xss": "^1.0.15" }, "devDependencies": { "@nuxt/eslint-config": "^0.3.10", - "@nuxt/module-builder": "^1.0.1", - "@nuxt/schema": "^3.11.2", - "@nuxt/test-utils": "^3.12.0", - "@types/node": "^20.14.8", - "changelogen": "^0.5.7", - "eslint": "^8.50.0", - "nuxi": "^3.26.4", - "nuxt": "^3.11.2", - "typescript": "^5.4.5", - "vitest": "^1.3.1" + "@nuxt/module-builder": "^1.0.2", + "@nuxt/schema": "^4.2.1", + "@nuxt/test-utils": "^3.20.1", + "@types/node": "^24.10.1", + "changelogen": "^0.6.2", + "eslint": "^8.57.0", + "nuxi": "^3.30.0", + "nuxt": "^4.2.1", + "typescript": "^5.9.3", + "vitest": "^4.0.8" }, "stackblitz": { "installDependencies": false, diff --git a/playground/components/ServerComponent.server.vue b/playground/app/components/ServerComponent.server.vue similarity index 100% rename from playground/components/ServerComponent.server.vue rename to playground/app/components/ServerComponent.server.vue diff --git a/playground/pages/about.vue b/playground/app/pages/about.vue similarity index 100% rename from playground/pages/about.vue rename to playground/app/pages/about.vue diff --git a/playground/app/pages/csrf.vue b/playground/app/pages/csrf.vue new file mode 100644 index 00000000..7a5a7b8d --- /dev/null +++ b/playground/app/pages/csrf.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/playground/pages/index.vue b/playground/app/pages/index.vue similarity index 100% rename from playground/pages/index.vue rename to playground/app/pages/index.vue diff --git a/playground/pages/island.vue b/playground/app/pages/island.vue similarity index 100% rename from playground/pages/island.vue rename to playground/app/pages/island.vue diff --git a/playground/pages/preserve.vue b/playground/app/pages/preserve.vue similarity index 100% rename from playground/pages/preserve.vue rename to playground/app/pages/preserve.vue diff --git a/playground/pages/rateLimit.vue b/playground/app/pages/rateLimit.vue similarity index 100% rename from playground/pages/rateLimit.vue rename to playground/app/pages/rateLimit.vue diff --git a/playground/pages/runtime.vue b/playground/app/pages/runtime.vue similarity index 100% rename from playground/pages/runtime.vue rename to playground/app/pages/runtime.vue diff --git a/playground/pages/runtime2.vue b/playground/app/pages/runtime2.vue similarity index 100% rename from playground/pages/runtime2.vue rename to playground/app/pages/runtime2.vue diff --git a/playground/pages/secret.vue b/playground/app/pages/secret.vue similarity index 100% rename from playground/pages/secret.vue rename to playground/app/pages/secret.vue diff --git a/playground/pages/swr.vue b/playground/app/pages/swr.vue similarity index 100% rename from playground/pages/swr.vue rename to playground/app/pages/swr.vue diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 9c5e8c46..91f8587b 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -35,6 +35,9 @@ export default defineNuxtConfig({ rateLimiter: false } }, + '/api/test-no-csrf': { + csurf: false + }, '/preserve': { security: { headers: { @@ -64,6 +67,7 @@ export default defineNuxtConfig({ interval: 30000, headers: true }, + csrf: true, }, hooks: { diff --git a/playground/server/api/test-csrf.post.ts b/playground/server/api/test-csrf.post.ts new file mode 100644 index 00000000..c23a06b6 --- /dev/null +++ b/playground/server/api/test-csrf.post.ts @@ -0,0 +1,12 @@ +export default defineEventHandler(async (event) => { + console.log('CSRF protected endpoint called', event.path) + + const body = await readBody(event) + const time = new Date().toISOString() + + return { + message: 'Success! CSRF protected endpoint accessed successfully.', + timestamp: time, + data: body + } +}) diff --git a/playground/server/api/test-no-csrf.post.ts b/playground/server/api/test-no-csrf.post.ts new file mode 100644 index 00000000..8faecae3 --- /dev/null +++ b/playground/server/api/test-no-csrf.post.ts @@ -0,0 +1,12 @@ +export default defineEventHandler(async (event) => { + console.log('CSRF excluded endpoint called', event.path) + + const body = await readBody(event) + const time = new Date().toISOString() + + return { + message: 'Success! CSRF excluded endpoint accessed without token.', + timestamp: time, + data: body + } +}) diff --git a/playground/server/tsconfig.json b/playground/server/tsconfig.json deleted file mode 100644 index d84e4918..00000000 --- a/playground/server/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../.nuxt/tsconfig.server.json" -} \ No newline at end of file diff --git a/playground/tsconfig.json b/playground/tsconfig.json index eb97e3f0..cae87c00 100644 --- a/playground/tsconfig.json +++ b/playground/tsconfig.json @@ -1,3 +1,9 @@ { - "extends": "./.nuxt/tsconfig.json" -} \ No newline at end of file + "files": [], + "references": [ + { "path": "./.nuxt/tsconfig.app.json" }, + { "path": "./.nuxt/tsconfig.server.json" }, + { "path": "./.nuxt/tsconfig.shared.json" }, + { "path": "./.nuxt/tsconfig.node.json" } + ] +} diff --git a/src/module.ts b/src/module.ts index 674168d0..507892ab 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,4 +1,4 @@ -import { defineNuxtModule, addServerHandler, installModule, addVitePlugin, addServerPlugin, createResolver, addImportsDir, useNitro, addServerImports } from '@nuxt/kit' +import { defineNuxtModule, addServerHandler, installModule, addVitePlugin, addServerPlugin, createResolver, addImportsDir, useNitro, addServerImports, addTypeTemplate } from '@nuxt/kit' import { existsSync } from 'node:fs' import { readFile, readdir } from 'node:fs/promises' import { join, isAbsolute } from 'pathe' @@ -23,7 +23,7 @@ export default defineNuxtModule({ }, async setup (options, nuxt) { const resolver = createResolver(import.meta.url) - + nuxt.options.build.transpile.push(resolver.resolve('./runtime')) // First merge module options with default options @@ -79,7 +79,7 @@ export default defineNuxtModule({ } else { // In case of esbuild, set the drop option nuxt.options.vite.esbuild = defu( - { + { drop: ['console', 'debugger'] as ('console' | 'debugger')[], }, nuxt.options.vite.esbuild @@ -100,7 +100,7 @@ export default defineNuxtModule({ // Then insert route specific security headers for (const route in nuxt.options.nitro.routeRules) { const rule = nuxt.options.nitro.routeRules[route] - if (rule.security && rule.security.headers) { + if (rule && rule.security && rule.security.headers) { const { security : { headers } } = rule const routeSecurityHeaders = getHeadersApplicableToAllResources(headers) nuxt.options.nitro.routeRules[route] = defuReplaceArray( @@ -109,7 +109,7 @@ export default defineNuxtModule({ ) } } - + // Register nitro plugin to manage security rules at the level of each route addServerPlugin(resolver.resolve('./runtime/nitro/plugins/00-routeRules')) @@ -160,12 +160,12 @@ export default defineNuxtModule({ addServerHandler({ handler: resolver.resolve('./runtime/server/middleware/rateLimiter') }) - + // Register XSS validator middleware addServerHandler({ handler: resolver.resolve('./runtime/server/middleware/xssValidator') }) - + // Register basicAuth middleware that is disabled by default const basicAuthConfig = nuxt.options.runtimeConfig.private.basicAuth if (basicAuthConfig && (basicAuthConfig.enabled || (basicAuthConfig as any)?.value?.enabled)) { @@ -198,13 +198,102 @@ export default defineNuxtModule({ sriHashes = await hashBundledAssets(nitro) }) + addTypeTemplate({ + filename: "types/nuxt-security.d.ts", + getContents: () => `// Generated by nuxt-security +import type { HookResult } from '@nuxt/schema' +import type { ModuleOptions, BasicAuth } from 'nuxt-security' + +declare module 'nuxt/schema' { + interface NuxtOptions { + security: ModuleOptions + } + interface RuntimeConfig { + security: ModuleOptions, + private: { basicAuth: BasicAuth | false, [key: string]: any } + } + interface NuxtHooks { + 'nuxt-security:prerenderedHeaders': (prerenderedHeaders: Record>) => HookResult + } +} + +export {} +` + }, { nitro: true, nuxt: true }); + + addTypeTemplate({ + filename: "types/nuxt-security-nitro.d.ts", + getContents: () => `// Generated by nuxt-security +import type { NuxtSecurityRouteRules } from 'nuxt-security' + +declare module 'nitropack/types' { + interface NitroRouteConfig { + security?: NuxtSecurityRouteRules; + } + interface NitroRuntimeHooks { + /** + * @deprecated + */ + 'nuxt-security:headers': (config: { + /** + * The route for which the headers are being configured + */ + route: string, + /** + * The headers configuration for the route + */ + headers: NuxtSecurityRouteRules['headers'] + }) => void + /** + * @deprecated + */ + 'nuxt-security:ready': () => void + /** + * Runtime hook to configure security rules for each route + */ + 'nuxt-security:routeRules': (routeRules: Record) => void + } +} +declare module 'nitropack' { + interface NitroRouteConfig { + security?: NuxtSecurityRouteRules; + } + interface NitroRuntimeHooks { + /** + * @deprecated + */ + 'nuxt-security:headers': (config: { + /** + * The route for which the headers are being configured + */ + route: string, + /** + * The headers configuration for the route + */ + headers: NuxtSecurityRouteRules['headers'] + }) => void + /** + * @deprecated + */ + 'nuxt-security:ready': () => void + /** + * Runtime hook to configure security rules for each route + */ + 'nuxt-security:routeRules': (routeRules: Record) => void + } +} + +export {} +` + }, { nitro: true, nuxt: false }); + // Register init hook to add pre-rendered headers to responses - nuxt.hook('nitro:init', nitro => { + nuxt.hook('nitro:init', nitro => { nitro.hooks.hook('prerender:done', async() => { // Add the prenredered headers to the Nitro server assets - nitro.options.serverAssets.push({ - baseName: 'nuxt-security', - dir: createResolver(nuxt.options.buildDir).resolve('./nuxt-security') + nitro.options.serverAssets.push({ + baseName: 'nuxt-security', + dir: createResolver(nuxt.options.buildDir).resolve('./nuxt-security') }) // In some Nitro presets (e.g. Vercel), the header rules are generated for the static server @@ -228,7 +317,7 @@ export default defineNuxtModule({ }) /** - * + * * Register storage driver for the rate limiter */ function registerRateLimiterStorage(nuxt: Nuxt, securityOptions: ModuleOptions) { @@ -254,8 +343,8 @@ function registerRateLimiterStorage(nuxt: Nuxt, securityOptions: ModuleOptions) * Make sure our nitro plugins will be applied last, * After all other third-party modules that might have loaded their own nitro plugins */ -function reorderNitroPlugins(nuxt: Nuxt) { - nuxt.hook('nitro:init', nitro => { +function reorderNitroPlugins(nuxt: Nuxt) { + nuxt.hook('nitro:init', nitro => { const resolver = createResolver(import.meta.url) const securityPluginsPrefix = resolver.resolve('./runtime/nitro/plugins') diff --git a/src/runtime/nitro/context/index.ts b/src/runtime/nitro/context/index.ts index f73f20f9..50a761d8 100644 --- a/src/runtime/nitro/context/index.ts +++ b/src/runtime/nitro/context/index.ts @@ -23,7 +23,8 @@ export function resolveSecurityRules(event: H3Event): NuxtSecurityRouteRules { if (!event.context.security.rules) { const router = createRouter({ routes: structuredClone(nitroAppSecurityOptions) }) const matcher = toRouteMatcher(router) - const matches = matcher.matchAll(event.path.split('?')[0]) + const eventPathNoQuery = event.path.split('?')[0] + const matches = eventPathNoQuery ? matcher.matchAll(eventPathNoQuery) : [] const rules: NuxtSecurityRouteRules = defuReplaceArray({}, ...matches.reverse()) event.context.security.rules = rules } @@ -31,7 +32,7 @@ export function resolveSecurityRules(event: H3Event): NuxtSecurityRouteRules { } /** - * Returns the security route that was matched for a specific request + * Returns the security route that was matched for a specific request */ export function resolveSecurityRoute(event: H3Event) { if (!event.context.security) { @@ -40,12 +41,10 @@ export function resolveSecurityRoute(event: H3Event) { if (!event.context.security.route) { const routeNames = Object.fromEntries(Object.entries(nitroAppSecurityOptions).map(([name]) => [name, { name }])) const router = createRouter<{ name: string }>({ routes: routeNames}) - const match = router.lookup(event.path.split('?')[0]) + const eventPathNoQuery = event.path.split('?')[0] + const match = eventPathNoQuery ? router.lookup(eventPathNoQuery) : undefined const route = match?.name ?? '' event.context.security.route = route } return event.context.security.route } - - - diff --git a/src/runtime/nitro/plugins/00-routeRules.ts b/src/runtime/nitro/plugins/00-routeRules.ts index 007a9781..0f80ab11 100644 --- a/src/runtime/nitro/plugins/00-routeRules.ts +++ b/src/runtime/nitro/plugins/00-routeRules.ts @@ -1,4 +1,4 @@ -import { defineNitroPlugin, useRuntimeConfig } from "#imports" +import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime" import { getAppSecurityOptions } from '../context' import { defuReplaceArray } from '../../../utils/merge' import { standardToSecurity, backwardsCompatibleSecurity } from '../../../utils/headers' @@ -13,6 +13,7 @@ export default defineNitroPlugin(async(nitroApp) => { // First insert standard route rules headers for (const route in runtimeConfig.nitro.routeRules) { const rule = runtimeConfig.nitro.routeRules[route] + if (!rule) continue const { headers } = rule const securityHeaders = standardToSecurity(headers) if (securityHeaders) { @@ -36,6 +37,7 @@ export default defineNitroPlugin(async(nitroApp) => { // Then insert route specific security headers for (const route in runtimeConfig.nitro.routeRules) { const rule = runtimeConfig.nitro.routeRules[route] + if (!rule) continue const { security } = rule if (security) { const { headers } = security @@ -63,4 +65,3 @@ export default defineNitroPlugin(async(nitroApp) => { await nitroApp.hooks.callHook('nuxt-security:ready') }) - diff --git a/src/runtime/nitro/plugins/20-subresourceIntegrity.ts b/src/runtime/nitro/plugins/20-subresourceIntegrity.ts index cd836c07..e923c065 100644 --- a/src/runtime/nitro/plugins/20-subresourceIntegrity.ts +++ b/src/runtime/nitro/plugins/20-subresourceIntegrity.ts @@ -1,4 +1,4 @@ -import { defineNitroPlugin } from '#imports' +import { defineNitroPlugin } from 'nitropack/runtime' //@ts-expect-error : we are importing from the virtual file system import sriHashes from '#sri-hashes' import { resolveSecurityRules } from '../context' @@ -28,7 +28,7 @@ export default defineNitroPlugin((nitroApp) => { if (typeof element !== 'string') { return element; } - + element = element.replace(SCRIPT_RE, (match, rest: string, src: string) => { const hash = sriHashes[src] if (hash) { diff --git a/src/runtime/nitro/plugins/30-cspSsgHashes.ts b/src/runtime/nitro/plugins/30-cspSsgHashes.ts index e71138e2..fb7c1763 100644 --- a/src/runtime/nitro/plugins/30-cspSsgHashes.ts +++ b/src/runtime/nitro/plugins/30-cspSsgHashes.ts @@ -1,4 +1,4 @@ -import { defineNitroPlugin } from '#imports' +import { defineNitroPlugin } from 'nitropack/runtime' import { resolveSecurityRules } from '../context' import { generateHash } from '../../../utils/crypto' import type { Section } from '../../../types/module' @@ -48,6 +48,7 @@ export default defineNitroPlugin((nitroApp) => { // Parse all script tags const inlineScriptMatches = element.matchAll(INLINE_SCRIPT_RE) for (const [, scriptText] of inlineScriptMatches) { + if (!scriptText) continue const hash = await generateHash(scriptText, hashAlgorithm) scriptHashes.add(`'${hash}'`) } @@ -61,6 +62,7 @@ export default defineNitroPlugin((nitroApp) => { if (hashStyles) { const styleMatches = element.matchAll(STYLE_RE) for (const [, styleText] of styleMatches) { + if (!styleText) continue const hash = await generateHash(styleText, hashAlgorithm) styleHashes.add(`'${hash}'`) } @@ -100,4 +102,4 @@ export default defineNitroPlugin((nitroApp) => { } } }) -}) \ No newline at end of file +}) diff --git a/src/runtime/nitro/plugins/40-cspSsrNonce.ts b/src/runtime/nitro/plugins/40-cspSsrNonce.ts index d6081257..95b75ec3 100644 --- a/src/runtime/nitro/plugins/40-cspSsrNonce.ts +++ b/src/runtime/nitro/plugins/40-cspSsrNonce.ts @@ -1,12 +1,49 @@ -import { defineNitroPlugin } from '#imports' +import { defineNitroPlugin } from 'nitropack/runtime' import { resolveSecurityRules } from '../context' import { generateRandomNonce } from '../../../utils/crypto' -const LINK_RE = /]*?>)/gi +const LINK_RE = /]*?>)/gi const NONCE_RE = /nonce="[^"]+"/i -const SCRIPT_RE = /]*?>)/gi -const STYLE_RE = /]*?>)/gi +const SCRIPT_RE = /]*?>)/gi +const STYLE_RE = /]*?>)/gi +const QUOTE_MASK_RE = /"([^"]*)"/g +const QUOTE_RESTORE_RE = /__QUOTE_PLACEHOLDER_(\d+)__/g +function injectNonceToTags(element: string, nonce: string) { + // Skip non-string elements + if (typeof element !== 'string') { + return element; + } + const quotes: string[] = []; + + // Mask attributes to avoid manipulating stringified elements + let maskedElement = element.replace(QUOTE_MASK_RE, (match) => { + quotes.push(match); + return `__QUOTE_PLACEHOLDER_${quotes.length - 1}__`; + }); + // Add nonce to all link tags + maskedElement = maskedElement.replace(LINK_RE, (match, rest) => { + if (NONCE_RE.test(rest)) { + return match.replace(NONCE_RE, `nonce="${nonce}"`); + } + return ` { + return ` diff --git a/test/fixtures/csrf/nuxt.config.ts b/test/fixtures/csrf/nuxt.config.ts new file mode 100644 index 00000000..963a737f --- /dev/null +++ b/test/fixtures/csrf/nuxt.config.ts @@ -0,0 +1,13 @@ +export default defineNuxtConfig({ + modules: [ + '../../../src/module' + ], + security: { + csrf: true + }, + routeRules: { + '/api/test-no-csrf': { + csurf: false + } + } +}) diff --git a/test/fixtures/csrf/package.json b/test/fixtures/csrf/package.json new file mode 100644 index 00000000..decd4334 --- /dev/null +++ b/test/fixtures/csrf/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "basic", + "type": "module" +} diff --git a/test/fixtures/csrf/server/api/test-csrf.post.ts b/test/fixtures/csrf/server/api/test-csrf.post.ts new file mode 100644 index 00000000..24c6aba2 --- /dev/null +++ b/test/fixtures/csrf/server/api/test-csrf.post.ts @@ -0,0 +1,12 @@ +export default defineEventHandler(async (event) => { + console.log('CSRF protected endpoint called', event.path) + + const body = await readBody(event) + const time = new Date().toISOString() + + return { + message: 'Success! CSRF protected endpoint accessed successfully.', + timestamp: time, + data: body + } +}) \ No newline at end of file diff --git a/test/fixtures/csrf/server/api/test-no-csrf.post.ts b/test/fixtures/csrf/server/api/test-no-csrf.post.ts new file mode 100644 index 00000000..8faecae3 --- /dev/null +++ b/test/fixtures/csrf/server/api/test-no-csrf.post.ts @@ -0,0 +1,12 @@ +export default defineEventHandler(async (event) => { + console.log('CSRF excluded endpoint called', event.path) + + const body = await readBody(event) + const time = new Date().toISOString() + + return { + message: 'Success! CSRF excluded endpoint accessed without token.', + timestamp: time, + data: body + } +}) diff --git a/test/fixtures/ssgHashes/nuxt.config.ts b/test/fixtures/ssgHashes/nuxt.config.ts index 11c9a434..0f15edb9 100644 --- a/test/fixtures/ssgHashes/nuxt.config.ts +++ b/test/fixtures/ssgHashes/nuxt.config.ts @@ -6,6 +6,19 @@ export default defineNuxtConfig({ '/': { prerender: true }, + '/inline-elem': { + prerender: true, + security: { + headers: { + contentSecurityPolicy: { + "script-src": false, + "style-src": false, + "script-src-elem": [], + "style-src-elem": [] + } + } + } + }, '/inline-script': { prerender: true }, diff --git a/test/fixtures/ssgHashes/pages/inline-elem.vue b/test/fixtures/ssgHashes/pages/inline-elem.vue new file mode 100644 index 00000000..c2396987 --- /dev/null +++ b/test/fixtures/ssgHashes/pages/inline-elem.vue @@ -0,0 +1,15 @@ + + diff --git a/test/fixtures/ssrNonce/pages/string-script.vue b/test/fixtures/ssrNonce/pages/string-script.vue new file mode 100644 index 00000000..06930535 --- /dev/null +++ b/test/fixtures/ssrNonce/pages/string-script.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/ssrNonce/pages/with-custom-element.vue b/test/fixtures/ssrNonce/pages/with-custom-element.vue new file mode 100644 index 00000000..8a47e12b --- /dev/null +++ b/test/fixtures/ssrNonce/pages/with-custom-element.vue @@ -0,0 +1,6 @@ + diff --git a/test/perRoute.test.ts b/test/perRoute.test.ts index faf80633..687ccb84 100644 --- a/test/perRoute.test.ts +++ b/test/perRoute.test.ts @@ -851,7 +851,7 @@ describe('[nuxt-security] Per-route Configuration', async () => { const nonceMatch = `nonce="${nonce}"`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const elementsWithNonce = text.match(new RegExp(nonceMatch, 'g')) - expect(elementsWithNonce).toHaveLength(9) + expect(elementsWithNonce).toHaveLength(6) }) it('does not inject CSP hashes on a deeply-disabled route', async () => { diff --git a/test/sri.test.ts b/test/sri.test.ts index 7c0d885a..6a13a395 100644 --- a/test/sri.test.ts +++ b/test/sri.test.ts @@ -43,7 +43,7 @@ describe('[nuxt-security] Subresource Integrity', async () => { expect(res).toBeDefined() expect(res).toBeTruthy() expect(text).toBeDefined() - expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes + 2) // + 1 image + vue head + expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes + 1) // + 1 image }) it('does not modify `integrity` attributes when manually provided', async () => { @@ -55,6 +55,6 @@ describe('[nuxt-security] Subresource Integrity', async () => { expect(res).toBeDefined() expect(res).toBeTruthy() expect(text).toBeDefined() - expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes + 3) // + 2 Bootstrap + vue head + expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes + 2) // + 2 Bootstrap }) }) diff --git a/test/ssgHashes.test.ts b/test/ssgHashes.test.ts index f5968312..336c5f69 100644 --- a/test/ssgHashes.test.ts +++ b/test/ssgHashes.test.ts @@ -7,7 +7,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { rootDir: fileURLToPath(new URL('./fixtures/ssgHashes', import.meta.url)) }) - const expectedIntegrityAttributes = 5 // 4 links (entry, page, vue, build meta), 1 script (entry) + const expectedIntegrityAttributes = 4 // 3 links (entry, page, build meta), 1 script (entry) const expectedInlineScriptHashes = 2 // 1 Hydration data, 1 Nuxt global const expectedExternalScriptHashes = 2 // 1 entry (modulepreload + script), 1 index (modulepreload) const expectedInlineStyleHashes = 0 @@ -40,7 +40,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(body).toBeDefined() expect(metaTag).toBeDefined() expect(csp).toBeDefined() - expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes - 1) // No vue on home page + expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes) expect(inlineScriptHashes).toBe(expectedInlineScriptHashes) expect(externalScriptHashes).toBe(expectedExternalScriptHashes) expect(inlineStyleHashes).toBe(expectedInlineStyleHashes) @@ -66,6 +66,16 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(strippedHeaderCsp).toBe(metaCsp) }) + it('sets script- and style-src-elem for inline scripts and styles', async () => { + const res = await fetch('/inline-elem') + + const body = await res.text() + const { csp } = extractDataFromBody(body) + + expect(csp).toMatch(/script-src-elem[^;]+'sha256-/) + expect(csp).toMatch(/style-src-elem[^;]+'sha256-/) + }) + it('sets script-src for inline scripts', async () => { const res = await fetch('/inline-script') @@ -79,7 +89,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(csp).toBeDefined() expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes) expect(inlineScriptHashes).toBe(expectedInlineScriptHashes + 1) // Inlined script in head - expect(externalScriptHashes).toBe(expectedExternalScriptHashes + 1) // + 1 vue modulepreload + expect(externalScriptHashes).toBe(expectedExternalScriptHashes) expect(inlineStyleHashes).toBe(expectedInlineStyleHashes) expect(externalStyleHashes).toBe(expectedExternalStyleHashes) }) @@ -88,7 +98,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { const res = await fetch('/inline-script-with-linebreak') const body = await res.text() const { metaTag, csp, elementsWithIntegrity, inlineScriptHashes, externalScriptHashes, inlineStyleHashes, externalStyleHashes } = extractDataFromBody(body) - + expect(res).toBeDefined() expect(res).toBeTruthy() expect(body).toBeDefined() @@ -96,7 +106,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(csp).toBeDefined() expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes) expect(inlineScriptHashes).toBe(expectedInlineScriptHashes + 1) // Inlined script in head - expect(externalScriptHashes).toBe(expectedExternalScriptHashes + 1) // + 1 vue modulepreload + expect(externalScriptHashes).toBe(expectedExternalScriptHashes) expect(inlineStyleHashes).toBe(expectedInlineStyleHashes) expect(externalStyleHashes).toBe(expectedExternalStyleHashes) }) @@ -114,7 +124,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(csp).toBeDefined() expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes) expect(inlineScriptHashes).toBe(expectedInlineScriptHashes) - expect(externalScriptHashes).toBe(expectedExternalScriptHashes + 1) // + 1 vue modulepreload + expect(externalScriptHashes).toBe(expectedExternalScriptHashes) expect(inlineStyleHashes).toBe(expectedInlineStyleHashes + 1) // Inlined style expect(externalStyleHashes).toBe(expectedExternalStyleHashes) }) @@ -132,7 +142,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(csp).toBeDefined() expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes) expect(inlineScriptHashes).toBe(expectedInlineScriptHashes) - expect(externalScriptHashes).toBe(expectedExternalScriptHashes + 1) // + 1 vue modulepreload + expect(externalScriptHashes).toBe(expectedExternalScriptHashes) expect(inlineStyleHashes).toBe(expectedInlineStyleHashes + 1) // Inlined style expect(externalStyleHashes).toBe(expectedExternalStyleHashes) }) @@ -151,7 +161,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(csp).toBeDefined() expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes + 1) // + 1 External script expect(inlineScriptHashes).toBe(expectedInlineScriptHashes) - expect(externalScriptHashes).toBe(expectedExternalScriptHashes + 2) // External script + 1 vue modulepreload + expect(externalScriptHashes).toBe(expectedExternalScriptHashes + 1) // External script expect(inlineStyleHashes).toBe(expectedInlineStyleHashes) expect(externalStyleHashes).toBe(expectedExternalStyleHashes) }) @@ -170,7 +180,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(csp).toBeDefined() expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes + 1) // + 1 External style expect(inlineScriptHashes).toBe(expectedInlineScriptHashes) - expect(externalScriptHashes).toBe(expectedExternalScriptHashes + 1) // + 1 vue modulepreload + expect(externalScriptHashes).toBe(expectedExternalScriptHashes) expect(inlineStyleHashes).toBe(expectedInlineStyleHashes) expect(externalStyleHashes).toBe(expectedExternalStyleHashes + 1) // External style }) @@ -189,7 +199,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(csp).toBeDefined() expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes + 1) // + 1 External link on image expect(inlineScriptHashes).toBe(expectedInlineScriptHashes) - expect(externalScriptHashes).toBe(expectedExternalScriptHashes + 1) // + 1 vue modulepreload + expect(externalScriptHashes).toBe(expectedExternalScriptHashes) expect(inlineStyleHashes).toBe(expectedInlineStyleHashes) expect(externalStyleHashes).toBe(expectedExternalStyleHashes) }) @@ -223,7 +233,7 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(body).toBeDefined() expect(metaTag).toBeNull() expect(csp).toBeUndefined() - expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes - 1) // No vue on no-meta-tag page + expect(elementsWithIntegrity).toBe(expectedIntegrityAttributes) expect(inlineScriptHashes).toBe(0) expect(externalScriptHashes).toBe(0) expect(inlineStyleHashes).toBe(0) @@ -263,4 +273,4 @@ describe('[nuxt-security] SSG support of CSP', async () => { expect(body).toBeDefined() expect(body).toMatch(/^ { rootDir: fileURLToPath(new URL('./fixtures/ssrNonce', import.meta.url)) }) - const expectedNonceElements = 9 // 1 from app.vue/useHead, 7 for nuxt, 1 for plugin vue export helper + const expectedNonceElements = 7 it('injects `nonce` attribute in response', async () => { const res = await fetch('/') @@ -98,6 +98,28 @@ describe('[nuxt-security] Nonce', async () => { expect(cspNonces).toBe(null) }) + /*it('does not modify custom elements', async () => { + const res = await fetch('/with-custom-element') + + const body = //.test(await res.text()) + + expect(res).toBeDefined() + expect(res).toBeTruthy() + expect(body).toBe(true) + })*/ + + it('does not modify stringified elements', async () => { + const res = await fetch('/string-script').then(res=>res.text()) + + const body = res.match(/