diff --git a/.changeset/fix-remove-createNonceGetter-v1.0.1.md b/.changeset/fix-remove-createNonceGetter-v1.0.1.md new file mode 100644 index 0000000..1bed490 --- /dev/null +++ b/.changeset/fix-remove-createNonceGetter-v1.0.1.md @@ -0,0 +1,54 @@ +--- +'@enalmada/start-secure': patch +--- + +**BREAKING CHANGE:** Remove broken `createNonceGetter()` function + +## What Changed + +- **Removed:** `createNonceGetter()` function (had critical AsyncLocalStorage bug) +- **Updated:** README with official TanStack pattern using direct context access +- **Updated:** API documentation to show deprecation notice +- **Added:** Comprehensive migration guide (docs/MIGRATION-1.0-to-1.0.1.md) + +## Why This Change? + +The `createIsomorphicFn()` wrapper in `createNonceGetter()` broke Node.js AsyncLocalStorage context chain: +- Server-side `getStartContext()` failed with "No Start context found" +- Scripts rendered without nonce attributes +- All scripts blocked by CSP + +## Migration Required + +**Before (v1.0.0 - BROKEN):** +```typescript +import { createNonceGetter } from '@enalmada/start-secure'; +const getNonce = createNonceGetter(); +const router = createRouter({ ssr: { nonce: getNonce() } }); +``` + +**After (v1.0.1 - WORKING):** +```typescript +export async function getRouter() { + let nonce: string | undefined; + if (typeof window === 'undefined') { + const { getStartContext } = await import('@tanstack/start-storage-context'); + nonce = getStartContext().contextAfterGlobalMiddlewares?.nonce; + } + return createRouter({ ssr: { nonce } }); +} +``` + +This aligns with the official TanStack Router pattern: https://github.com/TanStack/router/discussions/3028 + +## What Still Works + +No changes to these (all work perfectly): +- ✅ `createCspMiddleware()` - Middleware nonce generation +- ✅ `generateNonce()` - Crypto-random nonce generation +- ✅ `buildCspHeader()` - CSP header building +- ✅ All security headers and CSP rules + +## Full Migration Guide + +See [docs/MIGRATION-1.0-to-1.0.1.md](../docs/MIGRATION-1.0-to-1.0.1.md) for complete migration guide with troubleshooting. diff --git a/README.md b/README.md index aa59392..9a36c49 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,20 @@ Security header management for TanStack Start applications with native nonce sup - 🔄 Automatic CSP rule merging and deduplication - 🛠️ Development mode support (HMR, eval, WebSocket) - 📝 Rule descriptions for documentation -- 🔐 **Native per-request nonce generation** (v0.2+) -- ⚡ **Middleware pattern** for TanStack Start (v0.2+) -- 🌐 **Isomorphic nonce access** (server + client) -- 🚀 Minimal setup (~10 lines) +- 🔐 **Native per-request nonce generation** +- ⚡ **Middleware pattern** for TanStack Start +- 🎯 **Official TanStack pattern** (direct context access) +- 🚀 Minimal setup (~20 lines) -## What's New in v0.2 +## Overview -TanStack Start now has **native nonce support** via `router.options.ssr.nonce`. This package has been updated to provide: +TanStack Start has **native nonce support** via `router.options.ssr.nonce`. This package provides: - **Per-request nonce generation** - Unique cryptographic nonce for each request - **Middleware pattern** - Integrates with TanStack Start's global middleware system -- **Isomorphic nonce getter** - Works seamlessly on server and client - **No `'unsafe-inline'` for scripts** - Strict CSP in production (scripts only, styles remain pragmatic) - **Automatic nonce application** - TanStack router applies nonces to all framework scripts +- **Direct context access** - Official TanStack pattern (no broken wrappers) **Reference:** [TanStack Router Discussion #3028](https://github.com/TanStack/router/discussions/3028) @@ -35,7 +35,7 @@ TanStack Start now has **native nonce support** via `router.options.ssr.nonce`. bun add @enalmada/start-secure ``` -## Quick Start (v0.2 - Recommended) +## Quick Start ### Step 1: Create CSP rules configuration @@ -84,28 +84,38 @@ export const startInstance = createStart(() => ({ ```typescript import { createRouter } from '@tanstack/react-router'; -import { createNonceGetter } from '@enalmada/start-secure'; -const getNonce = createNonceGetter(); +export async function getRouter() { + // Get nonce on server (client uses meta tag automatically) + let nonce: string | undefined; + + if (typeof window === 'undefined') { + // Dynamic import for server-only code + const { getStartContext } = await import('@tanstack/start-storage-context'); + const context = getStartContext(); + nonce = context.contextAfterGlobalMiddlewares?.nonce; + } -export function getRouter() { const router = createRouter({ routeTree, // ... other options - ssr: { - nonce: getNonce() // Applies nonce to all framework scripts - } + ssr: { nonce } // Applies nonce to all framework scripts }); return router; } ``` -That's it! **Total setup: ~10 lines of code.** +**Why this pattern?** +- Direct context access (official TanStack pattern) +- No wrapper to break AsyncLocalStorage +- Works on both server and client + +That's it! **Total setup: ~20 lines of code.** ## API Reference -### v0.2 API (Recommended) +### Middleware API (Recommended) #### `createCspMiddleware(config)` @@ -131,21 +141,25 @@ const middleware = createCspMiddleware({ }); ``` -#### `createNonceGetter()` +#### `createNonceGetter()` ⚠️ REMOVED -Creates an isomorphic function that retrieves the nonce on both server and client. +**This function has been removed due to a critical AsyncLocalStorage bug.** -**Server behavior:** Retrieves nonce from TanStack Start middleware context -**Client behavior:** Retrieves nonce from `` tag +The isomorphic wrapper broke AsyncLocalStorage context chain, preventing nonce access. +Use direct context access instead (see Quick Start above). -**Returns:** Isomorphic function that returns the current nonce +**Migration:** See [MIGRATION-1.0-to-1.0.1.md](./docs/MIGRATION-1.0-to-1.0.1.md) -**Example:** +**Correct pattern:** ```typescript -import { createNonceGetter } from '@enalmada/start-secure'; - -const getNonce = createNonceGetter(); -const router = createRouter({ ssr: { nonce: getNonce() } }); +export async function getRouter() { + let nonce: string | undefined; + if (typeof window === 'undefined') { + const { getStartContext } = await import('@tanstack/start-storage-context'); + nonce = getStartContext().contextAfterGlobalMiddlewares?.nonce; + } + return createRouter({ ssr: { nonce } }); +} ``` #### `generateNonce()` @@ -230,18 +244,23 @@ interface CspMiddlewareConfig { **Production:** ``` -script-src 'self' 'nonce-XXX' 'strict-dynamic' -script-src-elem 'self' 'nonce-XXX' 'strict-dynamic' +script-src 'nonce-XXX' 'strict-dynamic' +script-src-elem 'nonce-XXX' 'strict-dynamic' ``` - ✅ Unique nonce per request - ✅ `'strict-dynamic'` allows nonce-verified scripts to load other scripts -- ✅ `'unsafe-inline'` is ignored when nonce present (CSP Level 2+ backward compatibility) +- ✅ No `'self'`, `'unsafe-inline'`, or URL whitelists (ignored by `'strict-dynamic'`) - ✅ No inline scripts without nonce **Development:** -- Adds `'unsafe-eval'` for source maps and dev tools -- Adds `https:` and `http:` for CDN scripts during development +``` +script-src 'nonce-XXX' 'strict-dynamic' 'unsafe-eval' +script-src-elem 'nonce-XXX' 'strict-dynamic' +``` + +- Adds `'unsafe-eval'` to `script-src` only (for source maps and dev tools) +- `'unsafe-eval'` NOT added to `script-src-elem` (causes browser warning) ### Styles: Pragmatic Approach @@ -267,13 +286,17 @@ The package properly handles granular directives (`-elem`, `-attr`): 1. User rules can target base directives (`script-src`, `style-src`) 2. Sources are automatically copied to granular directives 3. CSP Level 3 browsers check granular directives first +4. **Exception:** `'unsafe-eval'` is NOT copied from `script-src` to `script-src-elem` (prevents browser warning) -**Example:** +**How it works:** ```typescript -// User rule adds external font -{ 'font-src': 'https://fonts.gstatic.com' } +// Base directives (user or default) +script-src 'nonce-XXX' 'strict-dynamic' 'unsafe-eval' // (dev mode) + +// Automatically copied to granular directive (minus unsafe-eval) +script-src-elem 'nonce-XXX' 'strict-dynamic' // No unsafe-eval here -// Automatically merged with base directive and copied to granular if present +// Result: Zero browser warnings ``` ## Examples @@ -350,11 +373,11 @@ Strict-Transport-Security: max-age=31536000; includeSubDomains; preload (product Permissions-Policy: camera=(), microphone=(), geolocation=(), ... ``` -## Migration from v0.1 +## Migration from Handler Wrapper Pattern If you're using the old `createSecureHandler` API, here's how to migrate: -### Before (v0.1) +### Before (Handler Wrapper - Deprecated) ```typescript // src/server.ts @@ -370,7 +393,7 @@ export default { }; ``` -### After (v0.2) +### After (Middleware Pattern - Recommended) ```typescript // src/start.ts (NEW FILE) @@ -384,16 +407,20 @@ export const startInstance = createStart(() => ({ })); // src/router.tsx (UPDATED) -import { createNonceGetter } from '@enalmada/start-secure'; - -const getNonce = createNonceGetter(); -const router = createRouter({ ssr: { nonce: getNonce() } }); +export async function getRouter() { + let nonce: string | undefined; + if (typeof window === 'undefined') { + const { getStartContext } = await import('@tanstack/start-storage-context'); + nonce = getStartContext().contextAfterGlobalMiddlewares?.nonce; + } + return createRouter({ ssr: { nonce } }); +} // src/server.ts (SIMPLIFIED) const fetch = createStartHandler(defaultStreamHandler); ``` -### Benefits of v0.2 +### Benefits of Middleware Pattern - ✅ Per-request nonce generation (not static) - ✅ No `'unsafe-inline'` for scripts in production @@ -403,9 +430,9 @@ const fetch = createStartHandler(defaultStreamHandler); --- -## Legacy API (v0.1) +## Legacy API (Handler Wrapper) -The v0.1 handler wrapper API is still available for backward compatibility but is **deprecated**. Please migrate to v0.2 for better security. +The old handler wrapper API is still available for backward compatibility but is **deprecated**. Please migrate to the middleware pattern for better security. ### `createSecureHandler(config)` (Deprecated) diff --git a/docs/MIGRATION-1.0-to-1.0.1.md b/docs/MIGRATION-1.0-to-1.0.1.md new file mode 100644 index 0000000..f5e3d8f --- /dev/null +++ b/docs/MIGRATION-1.0-to-1.0.1.md @@ -0,0 +1,355 @@ +# Migration Guide: v1.0.0 to v1.0.1 + +## Breaking Change: createNonceGetter() Removed + +### Why This Change? + +v1.0.0's `createNonceGetter()` had a critical bug where the isomorphic wrapper broke AsyncLocalStorage, preventing nonce access. v1.0.1 removes this broken function and aligns with the official TanStack Router pattern. + +**The Bug:** +- `createIsomorphicFn()` wrapper broke AsyncLocalStorage context chain +- Server-side `getStartContext()` failed with "No Start context found" +- Scripts rendered without nonce attributes +- All scripts blocked by CSP + +**The Fix:** +- Remove broken isomorphic wrapper +- Use direct context access (official TanStack pattern) +- Simpler, more explicit, and actually works + +## Migration Steps + +### Before (v1.0.0 - BROKEN) + +```typescript +// src/router.tsx +import { createNonceGetter } from '@enalmada/start-secure'; + +const getNonce = createNonceGetter(); // ❌ Broken - returns undefined + +export function getRouter() { + return createRouter({ + ssr: { nonce: getNonce() } // ❌ Scripts have no nonces + }); +} +``` + +### After (v1.0.1 - WORKING) + +```typescript +// src/router.tsx +import { createRouter } from '@tanstack/react-router'; + +export async function getRouter() { + // Get nonce on server (client uses meta tag automatically) + let nonce: string | undefined; + + if (typeof window === 'undefined') { + // Dynamic import for server-only code + const { getStartContext } = await import('@tanstack/start-storage-context'); + const context = getStartContext(); + nonce = context.contextAfterGlobalMiddlewares?.nonce; + } + + return createRouter({ + // ... other options + ssr: { nonce } // ✅ Scripts now have nonces + }); +} +``` + +## Step-by-Step Migration + +### 1. Update Package + +```bash +bun add @enalmada/start-secure@^1.0.1 +``` + +### 2. Update Router Code + +**Change 1:** Make `getRouter()` async + +```diff +- export function getRouter() { ++ export async function getRouter() { +``` + +**Change 2:** Remove `createNonceGetter()` import and usage + +```diff +- import { createNonceGetter } from '@enalmada/start-secure'; +- const getNonce = createNonceGetter(); +``` + +**Change 3:** Add direct context access + +```typescript +// At the start of getRouter() +let nonce: string | undefined; + +if (typeof window === 'undefined') { + // Dynamic import prevents Node.js code in browser bundle + const { getStartContext } = await import('@tanstack/start-storage-context'); + const context = getStartContext(); + nonce = context.contextAfterGlobalMiddlewares?.nonce; +} +``` + +**Change 4:** Update router config + +```diff + return createRouter({ + // ... other options + ssr: { +- nonce: getNonce() ++ nonce + } + }); +``` + +Or, if using `exactOptionalPropertyTypes`: + +```diff + return createRouter({ + // ... other options +- ssr: { +- nonce: getNonce() +- } ++ ...(nonce ? { ssr: { nonce } } : {}) + }); +``` + +### 3. Verify Integration + +After updating: + +1. **Start dev server:** + ```bash + bun dev + ``` + +2. **Open browser DevTools** + +3. **Check Console** - Should see no CSP violations + +4. **Inspect Page Source** (View → Developer → View Source) + - Search for `` exists in `` + +5. **Check Network Tab** - Response Headers + - Verify CSP header includes nonce value + - Example: `Content-Security-Policy: script-src 'nonce-XXX' 'strict-dynamic'` + +## Complete Example + +### Full Router Implementation + +```typescript +// src/router.tsx +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { QueryClient } from "@tanstack/react-query"; +import { createRouter } from "@tanstack/react-router"; +import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; +import type { ReactNode } from "react"; +import { DefaultCatchBoundary } from "./components/DefaultCatchBoundary"; +import { NotFound } from "./components/NotFound"; +import { routeTree } from "./routeTree.gen"; + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} + +interface RouterContext { + queryClient: QueryClient; +} + +export async function getRouter() { + // Get nonce on server (client uses meta tag automatically) + let nonce: string | undefined; + + if (typeof window === "undefined") { + try { + // Dynamic import for server-only code + const { getStartContext } = await import("@tanstack/start-storage-context"); + const context = getStartContext(); + nonce = context.contextAfterGlobalMiddlewares?.nonce; + } catch (error) { + // Context not available (shouldn't happen, but handle gracefully) + nonce = undefined; + } + } + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, // 5 minutes + }, + }, + }); + + const router = createRouter({ + routeTree, + context: { queryClient } as RouterContext, + defaultPreload: "intent", + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: NotFound, + Wrap: ({ children }: { children: ReactNode }) => ( + {children} + ), + + // CSP nonce support - applies to all framework-generated scripts + ...(nonce ? { ssr: { nonce } } : {}), + }); + + setupRouterSsrQueryIntegration({ + router, + queryClient, + handleRedirects: true, + wrapQueryClient: true, + }); + + return router; +} +``` + +### Middleware Setup (No Changes) + +The middleware setup remains unchanged from v1.0.0: + +```typescript +// src/start.ts +import { createStart } from '@tanstack/react-start'; +import { createCspMiddleware } from '@enalmada/start-secure'; +import { cspRules } from './config/cspRules'; + +export const startInstance = createStart(() => ({ + requestMiddleware: [ + createCspMiddleware({ + rules: cspRules, + options: { isDev: process.env.NODE_ENV !== 'production' } + }) + ] +})); +``` + +## What Still Works + +These parts of the package are **NOT** affected by this change: + +- ✅ `createCspMiddleware()` - Works perfectly +- ✅ `generateNonce()` - Works perfectly +- ✅ `buildCspHeader()` - Works perfectly +- ✅ Middleware nonce generation - Works perfectly +- ✅ CSP header setting - Works perfectly + +Only the **router integration pattern** changed. The middleware functionality remains solid. + +## Troubleshooting + +### Scripts Still Blocked by CSP + +**Symptom:** +``` +Content-Security-Policy: The page's settings blocked an inline script +``` + +**Check:** +1. Is `getRouter()` async? (Required for dynamic import) +2. Is dynamic import inside `typeof window === 'undefined'` check? +3. Are you accessing `contextAfterGlobalMiddlewares?.nonce`? +4. Is nonce passed to router config? + +**Debug:** +Add console.log to verify nonce value: +```typescript +if (typeof window === 'undefined') { + const { getStartContext } = await import('@tanstack/start-storage-context'); + const context = getStartContext(); + nonce = context.contextAfterGlobalMiddlewares?.nonce; + console.log('[DEBUG] Nonce from context:', nonce ? 'EXISTS' : 'UNDEFINED'); +} +``` + +### TypeScript Error with exactOptionalPropertyTypes + +**Error:** +``` +Type 'string | undefined' is not assignable to type 'string' +``` + +**Fix:** +Use conditional spread instead of always including ssr object: +```typescript +return createRouter({ + // ... other options + ...(nonce ? { ssr: { nonce } } : {}) +}); +``` + +### Import Error in Browser + +**Error:** +``` +Module "node:async_hooks" has been externalized for browser compatibility +``` + +**Cause:** Imported `getStartContext` at module level (browser tries to load it) + +**Fix:** Use dynamic import inside server-only conditional: +```typescript +if (typeof window === 'undefined') { + const { getStartContext } = await import('@tanstack/start-storage-context'); + // ... +} +``` + +## Why This Pattern is Better + +### Official TanStack Pattern + +This migration aligns with the official TanStack Router pattern documented in: +- [Router Discussion #3028](https://github.com/TanStack/router/discussions/3028) + +The TanStack maintainers recommend this exact approach. We were trying to be "helpful" with an isomorphic wrapper, but it broke AsyncLocalStorage. + +### Benefits of Direct Access + +1. **Works** - No AsyncLocalStorage bugs +2. **Simple** - 5 lines of code, very explicit +3. **Official** - Matches TanStack documentation +4. **Debuggable** - Easy to add console.log for troubleshooting +5. **No magic** - No hidden wrapper behavior + +### AsyncLocalStorage Explained + +**Common Question:** "Does AsyncLocalStorage work in browsers?" + +**Answer:** AsyncLocalStorage is a **Node.js server-side API only**. It has nothing to do with browser compatibility. + +**How it works:** +- **Server (Node.js):** Uses AsyncLocalStorage to track per-request context +- **Client (Browser):** Reads nonce from `` tag + +The workaround is needed because `createIsomorphicFn()` broke the **server-side** AsyncLocalStorage chain, not because of any browser issue. + +## Support + +If you encounter issues after migration: + +1. Check this guide's Troubleshooting section +2. Verify you're on v1.0.1 or later +3. Review the Complete Example above +4. Check [GitHub Issues](https://github.com/Enalmada/start-secure/issues) + +## References + +- **Official TanStack Pattern:** https://github.com/TanStack/router/discussions/3028 +- **Bug Analysis:** See TanStarter `.plan/plans/tanstack_csp/CRITICAL-BUG.md` +- **AsyncLocalStorage Docs:** https://nodejs.org/api/async_context.html +- **Package Repo:** https://github.com/Enalmada/start-secure diff --git a/src/index.ts b/src/index.ts index 31a16a1..d5d9d7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,4 +23,6 @@ export type { export type { CspMiddlewareConfig } from "./middleware"; // New v0.2 API - Middleware pattern with per-request nonces export { createCspMiddleware } from "./middleware"; -export { createNonceGetter, generateNonce } from "./nonce"; +// Note: createNonceGetter removed in v1.0.1 due to AsyncLocalStorage bug +// Use direct context access instead (see docs/MIGRATION-1.0-to-1.0.1.md) +export { generateNonce } from "./nonce"; diff --git a/src/internal/csp-builder.ts b/src/internal/csp-builder.ts index a2f98cc..ddccf07 100644 --- a/src/internal/csp-builder.ts +++ b/src/internal/csp-builder.ts @@ -31,24 +31,26 @@ export function buildCspHeader(rules: CspRule[], nonce: string, isDev: boolean): "manifest-src": ["'self'"], "media-src": ["'self'"], "object-src": ["'none'"], - // Script sources with nonce - // Note: 'unsafe-inline' is ignored when nonce is present (CSP Level 2+) - // It's included for backward compatibility with older browsers - "script-src": [ - "'self'", - `'nonce-${nonce}'`, - "'unsafe-inline'", - "'strict-dynamic'", - ...(isDev ? ["'unsafe-eval'", "https:", "http:"] : []), - ], - // Allow