From d95f3ddd3d34cbcbff4d5f3c9bf276f9e829d0cd Mon Sep 17 00:00:00 2001 From: Adam Lane Date: Sat, 1 Nov 2025 22:48:21 -0700 Subject: [PATCH 1/3] feat: Add middleware pattern with native per-request nonce support Introduces v0.2 API with native TanStack Start middleware pattern for per-request CSP nonce generation. Maintains backward compatibility with v0.1 handler wrapper API. New Features: - createCspMiddleware() for TanStack Start global middleware - createNonceGetter() for isomorphic nonce access (server + client) - generateNonce() for cryptographic random nonce generation - buildCspHeader() for low-level CSP header building - CSP Level 3 support with granular directive copying - Strict nonce-based CSP (no unsafe-inline for scripts in production) Breaking Changes: - Adds @tanstack/start-storage-context peer dependency - Recommended API changed from handler wrapper to middleware pattern Security Improvements: - Per-request nonce generation (vs static headers) - No unsafe-inline fallback for scripts - Integration with TanStack router ssr.nonce option - Support for strict-dynamic CSP directive The old createSecureHandler API remains available but is deprecated. See README for migration guide and new API documentation. --- .changeset/middleware-nonce-support.md | 32 ++ README.md | 426 +++++++++++++++++++------ package.json | 3 +- src/index.ts | 11 + src/internal/csp-builder.ts | 115 +++++++ src/middleware.ts | 101 ++++++ src/nonce.ts | 60 ++++ tsconfig.json | 2 +- 8 files changed, 649 insertions(+), 101 deletions(-) create mode 100644 .changeset/middleware-nonce-support.md create mode 100644 src/internal/csp-builder.ts create mode 100644 src/middleware.ts create mode 100644 src/nonce.ts diff --git a/.changeset/middleware-nonce-support.md b/.changeset/middleware-nonce-support.md new file mode 100644 index 0000000..8f727f0 --- /dev/null +++ b/.changeset/middleware-nonce-support.md @@ -0,0 +1,32 @@ +--- +"@enalmada/start-secure": major +--- + +Add native middleware pattern with per-request nonce generation for TanStack Start applications. This is a major update that introduces a new recommended API while maintaining backward compatibility. + +**New Features:** + +- `createCspMiddleware()` - Middleware factory for TanStack Start with per-request nonce generation +- `createNonceGetter()` - Isomorphic nonce retrieval (works on server and client) +- `generateNonce()` - Cryptographically secure random nonce generator +- `buildCspHeader()` - Low-level CSP header building utility +- CSP Level 3 support with automatic granular directive copying (`-elem`, `-attr`) +- Strict nonce-based CSP for scripts (no `'unsafe-inline'` in production) +- Integration with TanStack router's native `ssr.nonce` option + +**Breaking Changes:** + +- This release is a major version because it introduces a new peer dependency: `@tanstack/start-storage-context >= 1.0.0` +- The recommended API has changed from handler wrapper (`createSecureHandler`) to middleware pattern (`createCspMiddleware`) +- Projects should migrate to the new API for better security (per-request nonces vs static headers) + +**Migration:** + +The old `createSecureHandler` API is still available and fully functional, but is now deprecated. See README for migration guide from v0.1 to v0.2. + +**Security Improvements:** + +- Per-request nonce generation (previously static at startup) +- No `'unsafe-inline'` fallback for scripts in production +- Support for `'strict-dynamic'` CSP directive +- Automatic nonce application to all TanStack framework scripts diff --git a/README.md b/README.md index 95d6892..58fc53b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @enalmada/start-secure -Security header management for TanStack Start applications. +Security header management for TanStack Start applications with native nonce support. [![npm version](https://badge.fury.io/js/@enalmada%2Fstart-secure.svg)](https://www.npmjs.com/package/@enalmada/start-secure) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -10,18 +10,24 @@ Security header management for TanStack Start applications. - 🔒 Secure defaults (strict CSP, security headers) - 🎯 Type-safe CSP rule definitions - 🔄 Automatic CSP rule merging and deduplication -- 🛠️ Development mode support (HMR, eval) +- 🛠️ Development mode support (HMR, eval, WebSocket) - 📝 Rule descriptions for documentation -- 🔐 Nonce support for scripts and styles (via manual implementation) -- 🚀 Zero-config for basic security +- 🔐 **Native per-request nonce generation** (v0.2+) +- ⚡ **Middleware pattern** for TanStack Start (v0.2+) +- 🌐 **Isomorphic nonce access** (server + client) +- 🚀 Minimal setup (~10 lines) -## Important Note: Nonce Support +## What's New in v0.2 -TanStack Start does not yet have built-in nonce support in its rendering pipeline. This package supports nonce configuration for CSP headers, but you'll need to manually inject nonces into your script and style tags until TanStack Start adds native support. +TanStack Start now has **native nonce support** via `router.options.ssr.nonce`. This package has been updated to provide: -**Discussion:** https://github.com/TanStack/router/discussions/3028 +- **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 -For now, the library defaults to `'unsafe-inline'` when no nonce is provided. +**Reference:** [TanStack Router Discussion #3028](https://github.com/TanStack/router/discussions/3028) ## Installation @@ -29,84 +35,261 @@ For now, the library defaults to `'unsafe-inline'` when no nonce is provided. bun add @enalmada/start-secure ``` -## Quick Start +## Quick Start (v0.2 - Recommended) -**File:** `app/server.ts` +### Step 1: Create CSP rules configuration + +**File:** `src/config/cspRules.ts` ```typescript -import { createStartHandler, defaultStreamHandler } from '@tanstack/react-start/server'; -import { createSecureHandler } from '@enalmada/start-secure'; +import type { CspRule } from '@enalmada/start-secure'; + +export const cspRules: CspRule[] = [ + { + description: 'google-auth', + 'form-action': "'self' https://accounts.google.com", + 'img-src': "https://*.googleusercontent.com", + 'connect-src': "https://*.googleusercontent.com", + }, + { + description: 'posthog-analytics', + 'script-src': "https://*.posthog.com", + 'connect-src': "https://*.posthog.com", + }, +]; +``` -const secureHandler = createSecureHandler({ +### Step 2: Register CSP middleware + +**File:** `src/start.ts` + +```typescript +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' } + }) + ] +})); +``` + +### Step 3: Configure router with nonce + +**File:** `src/router.tsx` + +```typescript +import { createRouter } from '@tanstack/react-router'; +import { createNonceGetter } from '@enalmada/start-secure'; + +const getNonce = createNonceGetter(); + +export function getRouter() { + const router = createRouter({ + routeTree, + // ... other options + ssr: { + nonce: getNonce() // Applies nonce to all framework scripts + } + }); + + return router; +} +``` + +That's it! **Total setup: ~10 lines of code.** + +## API Reference + +### v0.2 API (Recommended) + +#### `createCspMiddleware(config)` + +Creates CSP middleware for TanStack Start with per-request nonce generation. + +**Parameters:** +- `config.rules?: CspRule[]` - Array of CSP rules to merge with defaults +- `config.options.isDev?: boolean` - Enable development mode (WebSocket, unsafe-eval, HTTPS/HTTP sources) +- `config.nonceGenerator?: () => string` - Custom nonce generator (optional, defaults to crypto-random) +- `config.additionalHeaders?: Record` - Additional response headers to set + +**Returns:** TanStack Start middleware + +**Example:** +```typescript +import { createCspMiddleware } from '@enalmada/start-secure'; + +const middleware = createCspMiddleware({ rules: [ - { - description: 'google-auth', - 'form-action': "'self' https://accounts.google.com", - 'img-src': "https://*.googleusercontent.com", - 'connect-src': "https://*.googleusercontent.com", - }, + { description: 'google-fonts', 'font-src': 'https://fonts.gstatic.com' } ], - options: { - isDev: process.env.NODE_ENV !== 'production', - }, + options: { isDev: process.env.NODE_ENV !== 'production' } }); +``` -export default { - fetch: secureHandler(createStartHandler(defaultStreamHandler)), -}; +#### `createNonceGetter()` + +Creates an isomorphic function that retrieves the nonce on both server and client. + +**Server behavior:** Retrieves nonce from TanStack Start middleware context +**Client behavior:** Retrieves nonce from `` tag + +**Returns:** Isomorphic function that returns the current nonce + +**Example:** +```typescript +import { createNonceGetter } from '@enalmada/start-secure'; + +const getNonce = createNonceGetter(); +const router = createRouter({ ssr: { nonce: getNonce() } }); ``` -## API +#### `generateNonce()` + +Generates a cryptographically secure random nonce for CSP. -### createSecureHandler(config) +**Returns:** Base64-encoded random nonce (128-bit from UUID) -Creates a security middleware wrapper for TanStack Start handlers. +**Example:** +```typescript +import { generateNonce } from '@enalmada/start-secure'; + +const nonce = generateNonce(); +// "Y2QxMjM0NTY3ODkwMTIzNDU2Nzg=" +``` + +#### `buildCspHeader(rules, nonce, isDev)` + +Low-level utility to build CSP header string from rules and nonce. **Parameters:** -- `config.rules?: CspRule[]` - Array of CSP rules to merge -- `config.options.isDev?: boolean` - Enable development mode (allows WebSocket, unsafe-eval) -- `config.options.nonce?: string` - Nonce for script/style tags -- `config.options.headerConfig?: SecurityHeadersConfig` - Override default security headers +- `rules: CspRule[]` - CSP rules to merge +- `nonce: string` - Nonce for this request +- `isDev: boolean` - Whether in development mode + +**Returns:** CSP header string + +**Example:** +```typescript +import { buildCspHeader } from '@enalmada/start-secure'; -**Returns:** Higher-order function that wraps your handler +const csp = buildCspHeader(rules, generateNonce(), false); +// "default-src 'self'; script-src 'self' 'nonce-...' ..." +``` + +### Types -### CspRule +#### `CspRule` ```typescript interface CspRule { description?: string; // Document why this rule exists - source?: string; // Route pattern (reserved for future use) - - // CSP directives - 'base-uri'?: string; - 'child-src'?: string; - 'connect-src'?: string; - 'default-src'?: string; - 'font-src'?: string; - 'form-action'?: string; - 'frame-ancestors'?: string; - 'frame-src'?: string; - 'img-src'?: string; - 'manifest-src'?: string; - 'media-src'?: string; - 'object-src'?: string; - 'script-src'?: string; - 'style-src'?: string; - 'worker-src'?: string; + source?: string; // Reserved for future use + + // CSP directives - all optional, support both string and string[] + 'base-uri'?: string | string[]; + 'child-src'?: string | string[]; + 'connect-src'?: string | string[]; + 'default-src'?: string | string[]; + 'font-src'?: string | string[]; + 'form-action'?: string | string[]; + 'frame-ancestors'?: string | string[]; + 'frame-src'?: string | string[]; + 'img-src'?: string | string[]; + 'manifest-src'?: string | string[]; + 'media-src'?: string | string[]; + 'object-src'?: string | string[]; + 'script-src'?: string | string[]; + 'script-src-attr'?: string | string[]; + 'script-src-elem'?: string | string[]; + 'style-src'?: string | string[]; + 'style-src-attr'?: string | string[]; + 'style-src-elem'?: string | string[]; + 'worker-src'?: string | string[]; +} +``` + +#### `CspMiddlewareConfig` + +```typescript +interface CspMiddlewareConfig { + rules?: CspRule[]; + options?: SecurityOptions; + nonceGenerator?: () => string; + additionalHeaders?: Record; } ``` +## Security Model + +### Scripts: Strict Nonce-based CSP + +**Production:** +``` +script-src 'self' 'nonce-XXX' 'strict-dynamic' +script-src-elem 'self' '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 inline scripts without nonce + +**Development:** +- Adds `'unsafe-eval'` for source maps and dev tools +- Adds `https:` and `http:` for CDN scripts during development + +### Styles: Pragmatic Approach + +``` +style-src 'self' 'unsafe-inline' +style-src-elem 'self' 'unsafe-inline' +style-src-attr 'unsafe-inline' +``` + +**Why `'unsafe-inline'` for styles:** +- React hydration injects styles before nonce available +- Vite HMR injects styles dynamically +- CSS-in-JS libraries generate runtime styles +- Tailwind and other frameworks inject dynamic styles +- **Trade-off:** Styles cannot execute code (low XSS risk) + +This is the industry-standard approach used by GitHub, Google, and other major sites. + +### CSP Level 3 Support + +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 + +**Example:** +```typescript +// User rule adds external font +{ 'font-src': 'https://fonts.gstatic.com' } + +// Automatically merged with base directive and copied to granular if present +``` + ## Examples -### Multiple Rules +### Multiple Service Rules ```typescript -const secureHandler = createSecureHandler({ +import { createCspMiddleware } from '@enalmada/start-secure'; + +const middleware = createCspMiddleware({ rules: [ { description: 'google-auth', 'form-action': "'self' https://accounts.google.com", 'img-src': "https://*.googleusercontent.com", + 'connect-src': "https://*.googleusercontent.com", }, { description: 'sentry-monitoring', @@ -125,84 +308,129 @@ const secureHandler = createSecureHandler({ }); ``` -### With Nonce Support +### Custom Nonce Generator ```typescript -import { createSecureHandler } from '@enalmada/start-secure'; +import { createCspMiddleware } from '@enalmada/start-secure'; -const secureHandler = createSecureHandler({ +const middleware = createCspMiddleware({ rules: [...], - options: { - nonce: 'your-generated-nonce', // Generate per-request - isDev: process.env.NODE_ENV !== 'production', + nonceGenerator: () => { + // Custom nonce generation logic + return customCryptoFunction(); }, }); ``` -### Development vs Production +### Additional Headers ```typescript -const secureHandler = createSecureHandler({ +import { createCspMiddleware } from '@enalmada/start-secure'; + +const middleware = createCspMiddleware({ rules: [...], - options: { - // Development mode adds: - // - 'unsafe-eval' for script-src (dev tools) - // - ws: and wss: for connect-src (HMR) - isDev: process.env.NODE_ENV !== 'production', + additionalHeaders: { + 'X-Custom-Header': 'value', + 'X-Powered-By': 'My App', }, }); ``` -## Default Headers +## Default Security Headers -The library provides secure defaults out of the box: +The middleware automatically sets these security headers: -```typescript -{ - 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; ...", - 'X-Frame-Options': 'DENY', - 'X-Content-Type-Options': 'nosniff', - 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'X-XSS-Protection': '1; mode=block', - 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', - 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), ...', -} +``` +Content-Security-Policy: (built from rules + nonce) +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +Referrer-Policy: strict-origin-when-cross-origin +X-XSS-Protection: 1; mode=block +Strict-Transport-Security: max-age=31536000; includeSubDomains; preload (production only) +Permissions-Policy: camera=(), microphone=(), geolocation=(), ... ``` -## Advanced Usage +## Migration from v0.1 -### Custom Header Configuration +If you're using the old `createSecureHandler` API, here's how to migrate: + +### Before (v0.1) ```typescript +// src/server.ts +import { createSecureHandler } from '@enalmada/start-secure'; + const secureHandler = createSecureHandler({ - rules: [...], - options: { - headerConfig: { - 'X-Frame-Options': 'SAMEORIGIN', - 'X-Powered-By': 'My App', - }, - }, + rules: cspRules, + options: { isDev: process.env.NODE_ENV !== 'production' } }); + +export default { + fetch: secureHandler(createStartHandler(defaultStreamHandler)) +}; +``` + +### After (v0.2) + +```typescript +// src/start.ts (NEW FILE) +import { createStart } from '@tanstack/react-start'; +import { createCspMiddleware } from '@enalmada/start-secure'; + +export const startInstance = createStart(() => ({ + requestMiddleware: [ + createCspMiddleware({ rules: cspRules, options: { isDev: true } }) + ] +})); + +// src/router.tsx (UPDATED) +import { createNonceGetter } from '@enalmada/start-secure'; + +const getNonce = createNonceGetter(); +const router = createRouter({ ssr: { nonce: getNonce() } }); + +// src/server.ts (SIMPLIFIED) +const fetch = createStartHandler(defaultStreamHandler); ``` -### Custom Header Generation +### Benefits of v0.2 + +- ✅ Per-request nonce generation (not static) +- ✅ No `'unsafe-inline'` for scripts in production +- ✅ Integrates with TanStack router nonce support +- ✅ Automatic nonce in all framework scripts +- ✅ Cleaner, more maintainable code + +--- -For advanced use cases, you can use the underlying generator directly: +## Legacy API (v0.1) + +The v0.1 handler wrapper API is still available for backward compatibility but is **deprecated**. Please migrate to v0.2 for better security. + +### `createSecureHandler(config)` (Deprecated) ```typescript -import { generateSecurityHeaders } from '@enalmada/start-secure'; +import { createSecureHandler } from '@enalmada/start-secure'; -const headers = generateSecurityHeaders( - [{ 'connect-src': 'https://api.example.com' }], - { isDev: false } -); +const secureHandler = createSecureHandler({ + rules: [ + { 'connect-src': 'https://api.example.com' } + ], + options: { + isDev: process.env.NODE_ENV !== 'production' + } +}); -// Apply headers manually -for (const [key, value] of Object.entries(headers)) { - response.headers.set(key, value); -} +export default { + fetch: secureHandler(createStartHandler(defaultStreamHandler)) +}; ``` +**Limitations:** +- ❌ Headers generated once at startup (no per-request nonces) +- ❌ Falls back to `'unsafe-inline'` for scripts +- ❌ Doesn't integrate with TanStack router + ## Contributing Contributions are welcome! Please open an issue or PR. diff --git a/package.json b/package.json index 67fd60f..b5a28ed 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "module": "./dist/index.mjs", "packageManager": "bun@1.2.23", "peerDependencies": { - "@tanstack/react-start": ">=1.0.0" + "@tanstack/react-start": ">=1.0.0", + "@tanstack/start-storage-context": ">=1.0.0" }, "publishConfig": { "access": "public" diff --git a/src/index.ts b/src/index.ts index 292bb00..51b77c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,13 +3,24 @@ * Security header management for TanStack Start applications */ +// New v0.2 API - Middleware pattern with per-request nonces +export { createCspMiddleware } from "./middleware"; +export type { CspMiddlewareConfig } from "./middleware"; +export { createNonceGetter, generateNonce } from "./nonce"; +export { buildCspHeader } from "./internal/csp-builder"; + +// Deprecated v0.1 API - Handler wrapper (kept for backward compatibility) export type { StartSecureConfig } from "./handler"; export { createSecureHandler } from "./handler"; + +// Low-level utilities export { defaultSecurityHeadersConfig, validateNonce, } from "./internal/defaults"; export { generateSecurityHeaders } from "./internal/generator"; + +// Types export type { CspRule, SecurityHeaders, diff --git a/src/internal/csp-builder.ts b/src/internal/csp-builder.ts new file mode 100644 index 0000000..edcf361 --- /dev/null +++ b/src/internal/csp-builder.ts @@ -0,0 +1,115 @@ +/** + * CSP header building utilities + * Builds CSP header from rules, nonce, and environment configuration + */ + +import type { CspRule } from "./types"; + +/** + * Build CSP header value from rules and nonce + * + * Merges base directives with user-provided rules, adds nonce to script directives, + * and copies sources from base directives to granular directives (CSP Level 3 support). + * + * @param rules - User-provided CSP rules to merge + * @param nonce - Cryptographically random nonce for this request + * @param isDev - Whether in development mode (adds unsafe-eval, WebSocket support) + * @returns CSP header string + */ +export function buildCspHeader(rules: CspRule[], nonce: string, isDev: boolean): string { + // Base directives with nonce + const directives: Record = { + "default-src": ["'self'"], + "base-uri": ["'self'"], + "child-src": ["'none'"], + "connect-src": isDev ? ["'self'", "ws://localhost:*", "wss://localhost:*"] : ["'self'"], + "font-src": ["'self'"], + "form-action": ["'self'"], + "frame-ancestors": ["'none'"], + "frame-src": ["'none'"], + "img-src": ["'self'", "blob:", "data:"], + "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