-
Notifications
You must be signed in to change notification settings - Fork 76
Description
Hi Team,
thanks for all the work you put into this module! We’re running OWASP ZAP (2.17.0) against a Nuxt app (Nuxt + Nitro, started via nuxi build && nuxi preview) in GitHub Actions and noticed that several security headers seem to be missing / inconsistent, even though they are configured via nuxt-security.
Observed behavior (from ZAP full scan)
- Unexpected CORS wildcard on normal HTML pages
ZAP reportsAccess-Control-Allow-Origin: *on multiple non-API routes like/legal/,/media/,/portals/,/press-kit/,/solutions/(request includedOrigin: null) which triggers “CORS Misconfiguration” and “Cross-Domain Misconfiguration”. - CSP missing on
/(root) but present on other routes
ZAP flags “Content Security Policy (CSP) Header Not Set” forhttp://localhost:3000andhttp://localhost:3000/, while other routes like/maintenancedo return a CSP header. - Permissions Policy and Spectre isolation headers missing (systemic)
ZAP reports “Permissions Policy Header Not Set” and also missingCross-Origin-Opener-Policy,Cross-Origin-Embedder-Policy,Cross-Origin-Resource-Policyon/and also on_nuxt/*.jsassets (flagged as “Insufficient Site Isolation Against Spectre Vulnerability”). x-powered-by: Nuxtstill present
ZAP still detectsx-powered-by: Nuxt(e.g. on/maintenance).
What we think is happening
It looks like some responses (especially / and potentially pre-rendered/static responses + _nuxt assets) may bypass the header injection path (middleware/hook) in nuxi preview, leading to missing headers.
Separately, the corsHandler behavior appears to fall back to Access-Control-Allow-Origin: * when Origin is null/missing, even though we expect “omit CORS headers entirely” unless explicitly allowed.
Questions / recommended approach?
- Is it expected that
corsHandlercan emitAccess-Control-Allow-Origin: *for HTML pages (esp. withOrigin: null), and what’s the recommended safe configuration to prevent that? - For CSP / Permissions-Policy / COOP+COEP+CORP: is there a known limitation when serving prerendered/static content or when using
nuxi preview? - What’s the “correct” way to ensure these headers are applied consistently to
/and_nuxt/*in Nuxt/Nitro?- Should we rely on Nitro
routeRules.headersas a fallback for those paths? - Or does nuxt-security provide a preferred mechanism for this case?
- Should we rely on Nitro
Our current nuxt.config.ts security and routeRules blocks:
security: {
strict: false,
nonce: true, // Enables HTML nonce support in SSR mode
sri: true, // Enable Subresource Integrity
headers: {
contentSecurityPolicy: {
'default-src': ['\'self\'', `*.${appDomain}`, 'https://*.supabase.co'],
'script-src': [
'\'self\'',
'\'nonce-{{nonce}}\'',
'\'strict-dynamic\'',
'https://static.cloudflareinsights.com',
'https://challenges.cloudflare.com',
`*.${appDomain}`,
'https://*.supabase.co',
'https://www.googletagmanager.com',
'https://tagmanager.google.com',
'https://js.stripe.com',
'https://m.stripe.network',
],
'worker-src': ['\'self\'', 'blob:'],
'frame-src': [
'\'self\'',
'https://challenges.cloudflare.com',
'https://js.stripe.com',
'https://hooks.stripe.com',
],
'img-src': [
'\'self\'',
'blob:',
'data:',
`https://*.${appDomain}`,
'https://*.supabase.co',
'https://www.googletagmanager.com',
'https://q.stripe.com',
],
'connect-src': [
'\'self\'',
'https://*.cloudflare.com',
'https://cloudflareinsights.com',
`https://*.${appDomain}`,
'https://*.supabase.co',
'wss://*.supabase.co',
'https://*.sentry.io',
'https://www.googletagmanager.com',
'https://*.google.com',
'https://*.google-analytics.com',
'https://api.stripe.com',
'https://q.stripe.com',
'https://m.stripe.network',
'https://hooks.stripe.com',
],
'style-src': [
'\'self\'',
'\'unsafe-inline\'',
],
'font-src': [
'\'self\'',
'data:',
],
'object-src': ['\'none\''],
'script-src-attr': ['\'none\''],
'base-uri': ['\'none\''],
'form-action': ['\'self\''],
'upgrade-insecure-requests': true,
},
permissionsPolicy: {
'camera': [],
'microphone': [],
'geolocation': [],
'display-capture': [],
'fullscreen': ['self'],
'web-share': ['self'],
'payment': [],
'publickey-credentials-get': ['self'],
},
referrerPolicy: 'strict-origin-when-cross-origin',
strictTransportSecurity: {
maxAge: 31536000,
includeSubdomains: true,
preload: true,
},
},
requestSizeLimiter: {
maxRequestSizeInBytes: 2000000,
maxUploadFileRequestInBytes: 8000000,
throwError: true,
},
rateLimiter: {
ipHeader: 'cf-connecting-ip',
headers: true,
tokensPerInterval: 300,
interval: 300000,
throwError: true,
driver: rateLimitStorage === 'cloudflare-kv-binding'
? {
name: 'cloudflare-kv-binding',
options: {
binding: kvBindingName,
base: 'ourCustomNameHidden:security:rate-limiter:',
},
}
: {
name: 'lruCache',
},
whiteList: ['127.0.0.1'],
},
xssValidator: {
throwError: true,
},
corsHandler: {
origin: [`https://${appDomain}`, `https://www.${appDomain}`, `https://api.${appDomain}`],
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
preflight: {
statusCode: 204,
},
},
allowedMethodsRestricter: {
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
throwError: true,
},
hidePoweredBy: true,
basicAuth: false,
enabled: true,
csrf: false,
removeLoggers: {
external: [],
consoleType: ['log', 'debug'],
include: [/\.[jt]sx?$/, /\.vue\??/],
exclude: [/node_modules/, /\.git/],
},
ssg: {
meta: true,
hashScripts: true,
hashStyles: false,
nitroHeaders: true,
exportToPresets: false,
},
},
routeRules: {
'/': { prerender: true },
'/coming-soon': { prerender: true, cache: { maxAge: 60 * 60 * 24 } }, // Cache for a day
'/maintenance': { prerender: true, cache: { maxAge: 60 * 60 * 24 } }, // Cache for a day
'/about': { prerender: true },
'/about/**': { prerender: true },
'/support': { prerender: true },
'/support/**': { ssr: false },
'/auth/**': { ssr: false },
'/career': { ssr: false },
'/career/jobs': { ssr: false },
'/career/jobs/**': { ssr: false },
'/contact': { ssr: true },
'/legal/**': { prerender: false, cache: { maxAge: 60 * 60 * 24 } }, // Cache for a day
'/media': { prerender: true, cache: { maxAge: 60 * 60 * 3 } }, // Cache for 3 hours
'/media/**': { ssr: true, cache: { maxAge: 60 * 60 * 3 } }, // Cache for 3 hours
'/images/**': { ssr: true, cache: { maxAge: 60 * 60 * 3 } }, // Cache for 3 hours
'/press-kit/**': { ssr: true, cache: { maxAge: 60 * 60 * 3 } }, // Cache for 3 hours
'/solutions/**': { prerender: true, cache: { maxAge: 60 * 60 } }, // Cache for 1 hour and prerender
'/solutions/custom-product': { ssr: true },
'/api/v1/careers/jobs': {
cache: { maxAge: 60 * 60 }, // Cache for 1 hour
},
},I have also attatched the entire ZAP report in case you want to grab some details!
Thanks!