Skip to content

security headers not consistently applied (CSP/Permissions/COOP+COEP+CORP) + unexpected Access-Control-Allow-Origin: * during nuxi preview #677

@v0develop

Description

@v0develop

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)

  1. Unexpected CORS wildcard on normal HTML pages
    ZAP reports Access-Control-Allow-Origin: * on multiple non-API routes like /legal/, /media/, /portals/, /press-kit/, /solutions/ (request included Origin: null) which triggers “CORS Misconfiguration” and “Cross-Domain Misconfiguration”.
  2. CSP missing on / (root) but present on other routes
    ZAP flags “Content Security Policy (CSP) Header Not Set” for http://localhost:3000 and http://localhost:3000/, while other routes like /maintenance do return a CSP header.
  3. Permissions Policy and Spectre isolation headers missing (systemic)
    ZAP reports “Permissions Policy Header Not Set” and also missing Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, Cross-Origin-Resource-Policy on / and also on _nuxt/*.js assets (flagged as “Insufficient Site Isolation Against Spectre Vulnerability”).
  4. x-powered-by: Nuxt still present
    ZAP still detects x-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 corsHandler can emit Access-Control-Allow-Origin: * for HTML pages (esp. with Origin: 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.headers as a fallback for those paths?
    • Or does nuxt-security provide a preferred mechanism for this case?

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!

report_md_hidden.md

Thanks!

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionFurther information is requested

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions