Skip to content

fix: make SSR auth middleware cache-aware to prevent user leak in __NUXT__ payload (#52)#57

Open
frederikprijck wants to merge 22 commits into
mainfrom
issue-52
Open

fix: make SSR auth middleware cache-aware to prevent user leak in __NUXT__ payload (#52)#57
frederikprijck wants to merge 22 commits into
mainfrom
issue-52

Conversation

@frederikprijck

@frederikprijck frederikprijck commented Jun 23, 2026

Copy link
Copy Markdown
Member

Problem

The global SSR auth middleware wrote the authenticated user into useState('auth0_user'), which Nuxt serializes into the __NUXT__ payload of the server-rendered HTML. On any shared-cacheable route (CDN / s-maxage / swr / isr), that HTML — with one user's PII baked in — could be stored and served to other visitors.

Fix

The middleware now consults the Nitro route rules resolved for the request and skips the SSR user write when the response is shared-cacheable. The user is instead hydrated client-side from a no-store /auth/profile endpoint, so authenticated UI still works without ever placing PII into a cacheable payload.

  • isSharedCacheable() predicate detects cache/swr/isr rules and public/s-maxage Cache-Control headers.
  • Route rules are read via Nitro's supported getRouteRules server util (nitropack/runtime).
  • A new no-store /auth/profile endpoint + .client plugin re-hydrate the user after load.
  • Hardened against the route-rule case-sensitivity matcher bypass (CVE-2026-53721): the guard re-checks the lowercased path, since vue-router matches case-insensitively while Nitro's route-rule matcher is case-sensitive.

Closes #52.

Tests

  • Unit: isSharedCacheable predicate, middleware cache guard (incl. fail-closed + CVE case-variants), profile endpoint, client plugin.
  • E2E: caching fixture asserting the user is server-rendered on a no-store route but absent from a shared-cacheable route's raw HTML.

Summary by CodeRabbit

  • New Features
    • Added an authenticated-user profile endpoint and client-side hydration to restore user state after shared-cacheable SSR.
    • Introduced a default /auth/profile route (and optional customization) for profile fetching with no-store caching behavior.
  • Bug Fixes
    • Prevented user claims from appearing in shared-cached HTML, including protection against route-case variations and missing route-rule info.
  • Documentation
    • Documented SSR shared-cacheable behavior, CDN forwarding notes, “fails closed” behavior, and custom profile route setup.

The Nuxt app-level getRouteRules() composable reads a build-time manifest that
omits bare Cache-Control header route rules, so the guard never detected the
shared-cacheable case and PII still leaked into the payload. Read the resolved
rules from h3Event.context._nitro.routeRules instead.

Adds an end-to-end test (offline, stateless cookie session) demonstrating the
leak on a non-cacheable route and its absence on a shared-cacheable route. (#52)
…ernal

Replace the `h3Event.context._nitro.routeRules` private-field access with Nitro's
supported `getRouteRules(event)` server util (dynamically imported from
`nitropack/runtime`, server-only to keep it out of the client bundle). Same resolved
rules, no private API. (#52)
CVE-2026-53721)

Nitro's route-rule matcher is case-sensitive, but vue-router matches routes
case-insensitively. A mixed-case request (e.g. /Cacheable) therefore renders
the /cacheable page while resolving NO route rule — bypassing the
shared-cacheable cache guard and writing the authenticated user into SSR HTML
that the consumer marked publicly cacheable.

Nuxt's own fix (4.4.7 / 3.21.7) lowercases the path only in the app-level and
nitro-server matchers; the 'nitropack/runtime' getRouteRules path this
middleware uses is not covered, so the bypass still reproduces on patched Nuxt.

Harden in our own code: after checking the request path as-is, re-resolve the
route rules for the lowercased path (via a synthetic event with a fresh context
so getRouteRules recomputes) and skip the write if either is shared-cacheable.
Skipping is always fail-safe — the client hydrates the user.

- middleware: dual lookup (request path + lowercased path)
- unit test: shared-cacheable bypass via casing skips the write
- e2e: /Cacheable renders anonymous SSR HTML (leaked before the fix)
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 54f76b46-16de-4195-ac39-845c83bb46d3

📥 Commits

Reviewing files that changed from the base of the PR and between add692b and 4760348.

📒 Files selected for processing (4)
  • packages/auth0-nuxt/src/runtime/server/utils/is-request-shared-cacheable.spec.ts
  • packages/auth0-nuxt/src/runtime/server/utils/is-shared-cacheable.spec.ts
  • packages/auth0-nuxt/src/runtime/server/utils/is-shared-cacheable.ts
  • packages/auth0-nuxt/test/caching.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/auth0-nuxt/src/runtime/server/utils/is-shared-cacheable.spec.ts
  • packages/auth0-nuxt/src/runtime/server/utils/is-request-shared-cacheable.spec.ts
  • packages/auth0-nuxt/test/caching.test.ts
  • packages/auth0-nuxt/src/runtime/server/utils/is-shared-cacheable.ts

📝 Walkthrough

Walkthrough

The module now detects shared-cacheable SSR requests, omits authenticated users from their Nuxt payloads, and hydrates users client-side through a no-store /auth/profile endpoint. It adds route/plugin wiring, cache regression tests, and documentation.

Changes

Cache-aware authentication

Layer / File(s) Summary
Shared-cache detection contracts
packages/auth0-nuxt/src/runtime/server/utils/*
Adds cacheability predicates for Nitro cache rules, Cache-Control directives, missing route rules, and case-normalized request paths.
SSR guard and client profile hydration
packages/auth0-nuxt/src/runtime/middleware/auth.server.ts, packages/auth0-nuxt/src/runtime/plugins/auth.client.ts, packages/auth0-nuxt/src/runtime/server/api/auth/profile.get.ts, packages/auth0-nuxt/src/runtime/plugins/*.spec.ts, packages/auth0-nuxt/src/runtime/server/api/auth/*.spec.ts
Skips SSR user serialization for shared-cacheable requests, adds client-side profile hydration, and validates profile fetching and anonymous fallback behavior.
Module route and plugin wiring
packages/auth0-nuxt/src/module.ts, packages/auth0-nuxt/src/types.ts, packages/auth0-nuxt/src/module.spec.ts, packages/auth0-nuxt/src/runtime/helpers/import-meta.ts
Registers the client plugin, exposes and mounts the profile route, extends route configuration, and updates module registration tests.
SSR caching regression coverage
packages/auth0-nuxt/test/caching.test.ts, packages/auth0-nuxt/test/fixtures/caching/*
Verifies user claims appear for non-cacheable SSR and remain absent for shared-cacheable and case-variant routes.
SSR caching documentation
packages/auth0-nuxt/README.md, packages/auth0-nuxt/EXAMPLES.md
Documents shared-cacheable route behavior, client hydration, Cache-Control handling, fail-closed behavior, and manual profile route mounting.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant SSRMiddleware
  participant Auth0Client
  participant ProfileHandler
  Browser->>SSRMiddleware: request shared-cacheable page
  SSRMiddleware->>SSRMiddleware: detect shared cacheability
  SSRMiddleware-->>Browser: anonymous SSR payload
  Browser->>ProfileHandler: fetch /auth/profile after suspense
  ProfileHandler->>Auth0Client: getUser()
  Auth0Client-->>ProfileHandler: user or null
  ProfileHandler-->>Browser: profile response
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: making SSR auth middleware cache-aware to prevent payload leaks.
Linked Issues check ✅ Passed The PR implements cache-aware SSR auth handling, client-side hydration, no-store profile endpoint, fail-closed behavior, and tests required by #52.
Out of Scope Changes check ✅ Passed The changes stay focused on cache-aware SSR auth, hydration, docs, and tests, with no clear unrelated additions.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-52

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


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.

…ort.meta.server (#52)

The cache guard's `import('nitropack/runtime')` was pulled into the client bundle,
breaking `nuxt build` (`Could not load .nuxt/nitro.client.mjs`). `nitropack/runtime`
resolves to server-only Nitro virtual modules that cannot load in the client.

The `importMetaServer` helper guard does not let the bundler eliminate the import:
it is an opaque re-export Vite cannot statically evaluate. Only a literal
`import.meta.server` guard folds to `false` in the client build so Rollup tree-shakes
the whole block (and the dynamic import) away.

But Nuxt constant-folds `import.meta.*` at transform time even under Vitest's node
environment, so a literal guard makes the middleware body unreachable in unit tests —
which is exactly why the mockable helper existed. To keep fast unit coverage of the
branching cache-guard logic (including the CVE-2026-53721 lowercase re-check), it is
extracted into a pure, dependency-injected `isRequestSharedCacheable` with its own unit
test. The middleware becomes a thin `import.meta.server` shell, covered by the e2e
caching fixture.

- Add `isRequestSharedCacheable(getRouteRules, event)` + unit spec.
- Gate middleware on literal `import.meta.server`; delegate to the pure function.
- Remove `auth.server.spec.ts` (logic now covered by the pure-fn spec + e2e).

Verified: package + both example apps build, 66 unit tests pass, lint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@frederikprijck frederikprijck marked this pull request as ready for review July 14, 2026 09:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/auth0-nuxt/src/runtime/middleware/auth.server.ts (2)

34-38: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cache the dynamic import to avoid overhead on every request.

While import() is cached by the module system, calling it still creates a Promise and queues microtasks on every SSR request. For middleware running on every navigation, it is more efficient to cache the resolved function in a module-scoped variable after the first load.

⚡ Proposed fix
+let getNitroRouteRules: typeof import('nitropack/runtime').getRouteRules;
+
 export default defineNuxtRouteMiddleware(async () => {
   if (import.meta.server) {
     const app = useNuxtApp();
     const h3Event = app.ssrContext!.event;

-    const { getRouteRules } = await import('nitropack/runtime');
+    if (!getNitroRouteRules) {
+      const nitroRuntime = await import('nitropack/runtime');
+      getNitroRouteRules = nitroRuntime.getRouteRules;
+    }

-    if (isRequestSharedCacheable(getRouteRules, h3Event)) {
+    if (isRequestSharedCacheable(getNitroRouteRules, h3Event)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth0-nuxt/src/runtime/middleware/auth.server.ts` around lines 34 -
38, Cache the resolved getRouteRules function at module scope instead of
awaiting the Nitro runtime import on every request. Update the middleware flow
around isRequestSharedCacheable to lazily initialize and reuse the cached
function while preserving the existing fail-closed behavior when route rules are
unavailable.

34-38: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cache the dynamic import to avoid overhead on every request.

While import() is cached by the module system, calling it still creates a Promise and queues microtasks on every SSR request. For middleware running on every navigation, it is more efficient to cache the resolved function in a module-scoped variable after the first load.

⚡ Proposed fix
+let getNitroRouteRules: typeof import('nitropack/runtime').getRouteRules;
+
 export default defineNuxtRouteMiddleware(async () => {
   if (import.meta.server) {
     const app = useNuxtApp();
     const h3Event = app.ssrContext!.event;

-    const { getRouteRules } = await import('nitropack/runtime');
+    if (!getNitroRouteRules) {
+      const nitroRuntime = await import('nitropack/runtime');
+      getNitroRouteRules = nitroRuntime.getRouteRules;
+    }

-    if (isRequestSharedCacheable(getRouteRules, h3Event)) {
+    if (isRequestSharedCacheable(getNitroRouteRules, h3Event)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth0-nuxt/src/runtime/middleware/auth.server.ts` around lines 34 -
38, Cache the resolved getRouteRules function at module scope instead of
awaiting import('nitropack/runtime') on every request in the middleware flow.
Update the initialization path around isRequestSharedCacheable to load it once
and reuse the cached function for subsequent requests, while preserving the
existing fail-closed behavior when route rules are unavailable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/auth0-nuxt/src/runtime/middleware/auth.server.ts`:
- Around line 34-38: Cache the resolved getRouteRules function at module scope
instead of awaiting the Nitro runtime import on every request. Update the
middleware flow around isRequestSharedCacheable to lazily initialize and reuse
the cached function while preserving the existing fail-closed behavior when
route rules are unavailable.
- Around line 34-38: Cache the resolved getRouteRules function at module scope
instead of awaiting import('nitropack/runtime') on every request in the
middleware flow. Update the initialization path around isRequestSharedCacheable
to load it once and reuse the cached function for subsequent requests, while
preserving the existing fail-closed behavior when route rules are unavailable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a679150b-68e0-41b6-a538-41c9eab99a96

📥 Commits

Reviewing files that changed from the base of the PR and between 7931c04 and add692b.

📒 Files selected for processing (21)
  • packages/auth0-nuxt/EXAMPLES.md
  • packages/auth0-nuxt/README.md
  • packages/auth0-nuxt/src/module.spec.ts
  • packages/auth0-nuxt/src/module.ts
  • packages/auth0-nuxt/src/runtime/helpers/import-meta.ts
  • packages/auth0-nuxt/src/runtime/middleware/auth.server.ts
  • packages/auth0-nuxt/src/runtime/plugins/auth.client.spec.ts
  • packages/auth0-nuxt/src/runtime/plugins/auth.client.ts
  • packages/auth0-nuxt/src/runtime/server/api/auth/profile.get.spec.ts
  • packages/auth0-nuxt/src/runtime/server/api/auth/profile.get.ts
  • packages/auth0-nuxt/src/runtime/server/utils/is-request-shared-cacheable.spec.ts
  • packages/auth0-nuxt/src/runtime/server/utils/is-request-shared-cacheable.ts
  • packages/auth0-nuxt/src/runtime/server/utils/is-shared-cacheable.spec.ts
  • packages/auth0-nuxt/src/runtime/server/utils/is-shared-cacheable.ts
  • packages/auth0-nuxt/src/types.ts
  • packages/auth0-nuxt/test/caching.test.ts
  • packages/auth0-nuxt/test/fixtures/caching/app.vue
  • packages/auth0-nuxt/test/fixtures/caching/nuxt.config.ts
  • packages/auth0-nuxt/test/fixtures/caching/package.json
  • packages/auth0-nuxt/test/fixtures/caching/pages/cacheable.vue
  • packages/auth0-nuxt/test/fixtures/caching/pages/private.vue

…che guard (#52)

The cache guard only flagged `public`/`s-maxage`, so a `Cache-Control: max-age=N`
route rule still wrote the user into the __NUXT__ payload. RFC 9111's
authenticated-response restriction keys on the Authorization header, not cookies,
so a path-keyed shared cache can store a cookie-authenticated `max-age` response
and serve it across users. Treat a positive `max-age` as shared-cacheable, honor
explicit `private`/`no-store` opt-outs, and let `public`/`s-maxage` win over a
contradictory `private`.
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.

Global SSR auth middleware has no cache-awareness or opt-out — bakes the user into the __NUXT__ payload on every SSR route

1 participant