Skip to content

feat: add CSP and some security headers to HTML pages#2075

Open
serhalp wants to merge 7 commits intomainfrom
serhalp/security-audit
Open

feat: add CSP and some security headers to HTML pages#2075
serhalp wants to merge 7 commits intomainfrom
serhalp/security-audit

Conversation

@serhalp
Copy link
Member

@serhalp serhalp commented Mar 14, 2026

🔗 Linked issue

N/A but discussed on Discord here: https://discord.com/channels/1464542801676206113/1465055739293991033/1481201825242681394

🧭 Context

We're missing a few basic security protections such as a Content Security Policy on HTML responses, X-Frame-Options, etc.

You can see the result of a basic scan here: https://developer.mozilla.org/en-US/observatory/analyze?host=main.npmx.dev.

📚 Description

Add a CSP to the HTML layout and a few security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) on server responses.

Adding the CSP to the HTML layout directly with a <meta> tag is way simpler than trying to configure a response header conditionally only on html responses while excluding API responses, static assets, special internal paths, and whatever else, and then setting the CSP on pre-rendered pages also by either configuring static headers for those or using the <meta> approach only for those 😓.

The CSP img-src directive imports TRUSTED_IMAGE_DOMAINS from the image proxy module so the two stay in sync automatically, and similarly for supported git hosts.

Warning

It's unlikely I found all the hosts we need to allowlist. Please help me find any missing ones!

This PR also adds an e2e test that should fail on future PRs if a request is blocked by a CSP violation in the app's main happy path.

Add a global Nitro middleware that sets CSP and security headers (X-Content-Type-Options,
X-Frame-Options, Referrer-Policy) on page responses. API routes and internal paths (/_nuxt/, /_v/,
etc.) are skipped.

The CSP img-src directive imports TRUSTED_IMAGE_DOMAINS from the image proxy module so the two stay
in sync automatically.
@vercel
Copy link

vercel bot commented Mar 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 14, 2026 9:31pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 14, 2026 9:31pm
npmx-lunaria Ignored Ignored Mar 14, 2026 9:31pm

Request Review

@codecov
Copy link

codecov bot commented Mar 14, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@serhalp serhalp marked this pull request as ready for review March 14, 2026 22:03
@serhalp serhalp changed the title feat: add CSP and other security headers to HTML responses feat: add CSP and some security headers to HTML pages Mar 14, 2026
@serhalp serhalp requested a review from danielroe March 14, 2026 22:04
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 14, 2026

📝 Walkthrough

Walkthrough

This pull request centralises security header management and provider API origins across the application. A new Nuxt module enforces security headers including Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy. Provider API origins are extracted into a shared utility module with GIT_PROVIDER_API_ORIGINS and ALL_KNOWN_GIT_API_ORIGINS exports, allowing components to reference centralised constants instead of hardcoded URLs. TRUSTED_IMAGE_DOMAINS is exported as a public constant. End-to-end tests validate security header delivery and detect CSP violations.

Possibly related PRs

Suggested reviewers

  • ghostdevv
  • 43081j
  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly relates to the changeset, detailing security header additions (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) with context, implementation approach, and testing strategy.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch serhalp/security-audit
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
modules/security-headers.ts (1)

35-37: Consider scoping localhost connect-src to dev or a feature flag.

Allowing http://127.0.0.1:* on all builds broadens the outbound policy surface. If this is only needed for local CLI integration in specific scenarios, gate it to reduce exposure.

Possible narrowing diff
+    const localhostConnectSrc = nuxt.options.dev ? ['http://127.0.0.1:*'] : []
+
     const connectSrc = [
       "'self'",
       'https://*.algolia.net',
       'https://registry.npmjs.org',
       'https://api.npmjs.org',
       'https://npm.antfu.dev',
       ...ALL_KNOWN_GIT_API_ORIGINS,
       // Local CLI connector (npmx CLI communicates via localhost)
-      'http://127.0.0.1:*',
+      ...localhostConnectSrc,
     ].join(' ')
server/utils/image-proxy.ts (1)

32-32: Harden TRUSTED_IMAGE_DOMAINS as read-only constant to prevent accidental mutation.

TRUSTED_IMAGE_DOMAINS is a security-sensitive constant shared across modules. Although current usage is read-only, exporting as a mutable array risks future code accidentally widening the trust boundary via push(), splice(), or similar operations. Apply as const or Object.freeze() to enforce immutability.

Suggested hardening
-export const TRUSTED_IMAGE_DOMAINS = [
+export const TRUSTED_IMAGE_DOMAINS = [
   // First-party
   'npmx.dev',
   ...
-]
+] as const

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bf5a1962-4e1b-45ce-82ba-9996da979595

📥 Commits

Reviewing files that changed from the base of the PR and between a170292 and 49438d8.

📒 Files selected for processing (6)
  • app/composables/useRepoMeta.ts
  • modules/security-headers.ts
  • server/utils/image-proxy.ts
  • shared/utils/git-providers.ts
  • test/e2e/security-headers.spec.ts
  • test/e2e/test-utils.ts

Comment on lines +67 to +73
nuxt.options.routeRules['/**'] = {
...nuxt.options.routeRules['/**'],
headers: {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether other catch-all headers exist and could be overwritten.
rg -n --type=ts -C4 "routeRules"
rg -n --type=ts -C4 "['\"]/\\*\\*['\"]\\s*:"
rg -n --type=ts -C3 "headers\\s*:"

Repository: npmx-dev/npmx.dev

Length of output: 29908


Merge existing headers instead of clobbering them when setting catch-all route rules.

The current code spreads ...nuxt.options.routeRules['/**'] at the object level but then immediately overwrites the headers property, discarding any pre-existing headers. Use optional chaining to safely merge the new headers with any existing ones.

Proposed merge-safe fix
-    nuxt.options.routeRules['/**'] = {
-      ...nuxt.options.routeRules['/**'],
-      headers: {
-        'X-Content-Type-Options': 'nosniff',
-        'X-Frame-Options': 'DENY',
-        'Referrer-Policy': 'strict-origin-when-cross-origin',
-      },
-    }
+    const existingCatchAll = nuxt.options.routeRules['/**']
+    nuxt.options.routeRules['/**'] = {
+      ...existingCatchAll,
+      headers: {
+        ...(existingCatchAll?.headers ?? {}),
+        'X-Content-Type-Options': 'nosniff',
+        'X-Frame-Options': 'DENY',
+        'Referrer-Policy': 'strict-origin-when-cross-origin',
+      },
+    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant