From 9c0b99b91b747e555fb1871c0eb8259cb01e2ba0 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 11 Jan 2026 12:58:38 +0100 Subject: [PATCH 1/2] fix: make nonce accessible in headers added csp e2e test for react --- e2e/react-start/csp/.gitignore | 4 + e2e/react-start/csp/package.json | 29 ++++ e2e/react-start/csp/playwright.config.ts | 35 +++++ e2e/react-start/csp/public/external.css | 4 + e2e/react-start/csp/public/external.js | 3 + e2e/react-start/csp/src/routeTree.gen.ts | 68 +++++++++ e2e/react-start/csp/src/router.tsx | 20 +++ e2e/react-start/csp/src/routes/__root.tsx | 47 ++++++ e2e/react-start/csp/src/routes/index.tsx | 25 +++ e2e/react-start/csp/tests/csp.spec.ts | 170 +++++++++++++++++++++ e2e/react-start/csp/tsconfig.json | 22 +++ e2e/react-start/csp/vite.config.ts | 9 ++ packages/router-core/src/load-matches.ts | 1 + packages/router-core/src/route.ts | 5 +- packages/router-core/src/ssr/ssr-client.ts | 1 + pnpm-lock.yaml | 40 +++++ 16 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 e2e/react-start/csp/.gitignore create mode 100644 e2e/react-start/csp/package.json create mode 100644 e2e/react-start/csp/playwright.config.ts create mode 100644 e2e/react-start/csp/public/external.css create mode 100644 e2e/react-start/csp/public/external.js create mode 100644 e2e/react-start/csp/src/routeTree.gen.ts create mode 100644 e2e/react-start/csp/src/router.tsx create mode 100644 e2e/react-start/csp/src/routes/__root.tsx create mode 100644 e2e/react-start/csp/src/routes/index.tsx create mode 100644 e2e/react-start/csp/tests/csp.spec.ts create mode 100644 e2e/react-start/csp/tsconfig.json create mode 100644 e2e/react-start/csp/vite.config.ts diff --git a/e2e/react-start/csp/.gitignore b/e2e/react-start/csp/.gitignore new file mode 100644 index 00000000000..f0450534a3c --- /dev/null +++ b/e2e/react-start/csp/.gitignore @@ -0,0 +1,4 @@ +node_modules +.output +dist +*.txt diff --git a/e2e/react-start/csp/package.json b/e2e/react-start/csp/package.json new file mode 100644 index 00000000000..90bea313cb6 --- /dev/null +++ b/e2e/react-start/csp/package.json @@ -0,0 +1,29 @@ +{ + "name": "tanstack-react-start-e2e-csp", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^7.1.7" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "srvx": "^0.10.0", + "typescript": "^5.7.2" + } +} diff --git a/e2e/react-start/csp/playwright.config.ts b/e2e/react-start/csp/playwright.config.ts new file mode 100644 index 00000000000..5f9de5709e4 --- /dev/null +++ b/e2e/react-start/csp/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/csp/public/external.css b/e2e/react-start/csp/public/external.css new file mode 100644 index 00000000000..4085d14afdc --- /dev/null +++ b/e2e/react-start/csp/public/external.css @@ -0,0 +1,4 @@ +.external-styled { + color: blue; + font-weight: bold; +} diff --git a/e2e/react-start/csp/public/external.js b/e2e/react-start/csp/public/external.js new file mode 100644 index 00000000000..9efb4f87698 --- /dev/null +++ b/e2e/react-start/csp/public/external.js @@ -0,0 +1,3 @@ +// This script sets a window global when loaded +// Using a global avoids race conditions with React and DOM ownership issues +window.__EXTERNAL_SCRIPT_LOADED__ = true diff --git a/e2e/react-start/csp/src/routeTree.gen.ts b/e2e/react-start/csp/src/routeTree.gen.ts new file mode 100644 index 00000000000..dceedffdc12 --- /dev/null +++ b/e2e/react-start/csp/src/routeTree.gen.ts @@ -0,0 +1,68 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' + fileRoutesByTo: FileRoutesByTo + to: '/' + id: '__root__' | '/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/csp/src/router.tsx b/e2e/react-start/csp/src/router.tsx new file mode 100644 index 00000000000..c8caf51e8d8 --- /dev/null +++ b/e2e/react-start/csp/src/router.tsx @@ -0,0 +1,20 @@ +import { createRouter } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' +import { routeTree } from './routeTree.gen' + +const getSSROptions = createIsomorphicFn().server(() => { + const array = new Uint8Array(16) + crypto.getRandomValues(array) + const nonce = Array.from(array, (b) => b.toString(16).padStart(2, '0')).join( + '', + ) + return { nonce } +}) + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + ssr: getSSROptions(), + }) +} diff --git a/e2e/react-start/csp/src/routes/__root.tsx b/e2e/react-start/csp/src/routes/__root.tsx new file mode 100644 index 00000000000..dabcb8ebc28 --- /dev/null +++ b/e2e/react-start/csp/src/routes/__root.tsx @@ -0,0 +1,47 @@ +import { + createRootRoute, + HeadContent, + Outlet, + Scripts, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + headers: ({ ssr }) => { + const nonce = ssr?.nonce + if (!nonce) return + return { + 'Content-Security-Policy': [ + "default-src 'self'", + `script-src 'self' 'nonce-${nonce}'`, + `style-src 'self' 'nonce-${nonce}'`, + ].join('; '), + } + }, + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'CSP Nonce Test' }, + ], + links: [{ rel: 'stylesheet', href: '/external.css' }], + scripts: [{ src: '/external.js' }], + styles: [ + { children: '.inline-styled { color: green; font-weight: bold; }' }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + ) +} diff --git a/e2e/react-start/csp/src/routes/index.tsx b/e2e/react-start/csp/src/routes/index.tsx new file mode 100644 index 00000000000..bb765ce87cf --- /dev/null +++ b/e2e/react-start/csp/src/routes/index.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + const [count, setCount] = useState(0) + + return ( +
+

CSP Nonce Test

+

+ This should be green if inline styles work +

+

+ This should be blue if external styles work +

+ +
+ ) +} diff --git a/e2e/react-start/csp/tests/csp.spec.ts b/e2e/react-start/csp/tests/csp.spec.ts new file mode 100644 index 00000000000..5bdfd93e9be --- /dev/null +++ b/e2e/react-start/csp/tests/csp.spec.ts @@ -0,0 +1,170 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test('CSP header is set with nonce', async ({ page }) => { + const response = await page.goto('/') + const csp = response?.headers()['content-security-policy'] + expect(csp).toContain("script-src 'self' 'nonce-") + expect(csp).toContain("style-src 'self' 'nonce-") +}) + +test('Inline scripts have nonce attribute', async ({ page }) => { + await page.goto('/') + const scripts = await page.locator('script[nonce]').all() + expect(scripts.length).toBeGreaterThan(0) +}) + +test('Inline styles have nonce attribute', async ({ page }) => { + await page.goto('/') + const styles = await page.locator('style[nonce]').all() + expect(styles.length).toBeGreaterThan(0) +}) + +test('External script has nonce attribute', async ({ page }) => { + await page.goto('/') + const externalScript = page.locator('script[src="/external.js"]') + await expect(externalScript).toHaveAttribute('nonce') +}) + +test('External stylesheet has nonce attribute', async ({ page }) => { + await page.goto('/') + const externalStylesheet = page.locator('link[href="/external.css"]') + await expect(externalStylesheet).toHaveAttribute('nonce') +}) + +test('Nonces match between header and elements', async ({ page }) => { + // Intercept the HTML response to get raw content before browser strips nonces + let rawHtml = '' + await page.route('/', async (route) => { + const response = await route.fetch() + rawHtml = await response.text() + await route.fulfill({ response }) + }) + + const response = await page.goto('/') + await page.unrouteAll({ behavior: 'ignoreErrors' }) + + const csp = response?.headers()['content-security-policy'] || '' + + // Extract nonce from CSP header + const nonceMatch = csp.match(/nonce-([a-f0-9]+)/) + expect(nonceMatch).toBeTruthy() + const headerNonce = nonceMatch![1] + + // Check script nonces match - look for nonce attribute anywhere in the script tag + const scriptNonces = [ + ...rawHtml.matchAll(/]*\bnonce="([^"]+)"[^>]*>/g), + ].map((m) => m[1]) + expect(scriptNonces.length).toBeGreaterThan(0) + for (const nonce of scriptNonces) { + expect(nonce).toBe(headerNonce) + } + + // Check style nonces match + const styleNonces = [ + ...rawHtml.matchAll(/]*\bnonce="([^"]+)"[^>]*>/g), + ].map((m) => m[1]) + expect(styleNonces.length).toBeGreaterThan(0) + for (const nonce of styleNonces) { + expect(nonce).toBe(headerNonce) + } + + // Check external script nonce matches (nonce can be before or after src) + const externalScriptMatch = rawHtml.match( + /]*\bsrc="\/external\.js"[^>]*\bnonce="([^"]+)"[^>]*>|]*\bnonce="([^"]+)"[^>]*\bsrc="\/external\.js"[^>]*>/, + ) + expect(externalScriptMatch).toBeTruthy() + expect(externalScriptMatch![1] || externalScriptMatch![2]).toBe(headerNonce) + + // Check external stylesheet nonce matches (nonce can be before or after href) + const externalStyleMatch = rawHtml.match( + /]*\bhref="\/external\.css"[^>]*\bnonce="([^"]+)"[^>]*>|]*\bnonce="([^"]+)"[^>]*\bhref="\/external\.css"[^>]*>/, + ) + expect(externalStyleMatch).toBeTruthy() + expect(externalStyleMatch![1] || externalStyleMatch![2]).toBe(headerNonce) +}) + +test('Hydration works - counter increments', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('counter-value')).toContainText('0') + + // Keep clicking until React hydrates and the counter increments + // This handles the race between test execution and hydration + await expect + .poll( + async () => { + await page.getByTestId('counter-btn').click() + return page.getByTestId('counter-value').textContent() + }, + { timeout: 10000, intervals: [100, 200, 500, 1000] }, + ) + .not.toBe('0') + + // Now that hydration is confirmed, verify further increments work + const currentValue = parseInt( + (await page.getByTestId('counter-value').textContent()) || '0', + ) + await page.getByTestId('counter-btn').click() + await expect(page.getByTestId('counter-value')).toContainText( + String(currentValue + 1), + ) +}) + +test('Inline styles work with CSP', async ({ page }) => { + await page.goto('/') + const el = page.getByTestId('inline-styled') + await expect(el).toBeVisible() + // Verify the style was applied (green color) + const color = await el.evaluate((e) => getComputedStyle(e).color) + expect(color).toBe('rgb(0, 128, 0)') // green +}) + +test('External styles work with CSP', async ({ page }) => { + await page.goto('/') + const el = page.getByTestId('external-styled') + await expect(el).toBeVisible() + // Verify the style was applied (blue color) + const color = await el.evaluate((e) => getComputedStyle(e).color) + expect(color).toBe('rgb(0, 0, 255)') // blue +}) + +test('External script executes with CSP', async ({ page }) => { + await page.goto('/') + // Check that the external script set its window global + await expect + .poll(() => page.evaluate(() => (window as any).__EXTERNAL_SCRIPT_LOADED__)) + .toBe(true) +}) + +test('No CSP violations in console', async ({ page }) => { + const violations: string[] = [] + page.on('console', (msg) => { + if (msg.text().toLowerCase().includes('content security policy')) { + violations.push(msg.text()) + } + }) + page.on('pageerror', (err) => { + if (err.message.toLowerCase().includes('content security policy')) { + violations.push(err.message) + } + }) + await page.goto('/') + await page.getByTestId('counter-btn').click() + // Small wait to ensure any async violations are caught + await page.waitForTimeout(100) + expect(violations).toEqual([]) +}) + +test('Each request gets a unique nonce', async ({ page }) => { + const response1 = await page.goto('/') + const csp1 = response1?.headers()['content-security-policy'] || '' + const nonce1 = csp1.match(/nonce-([a-f0-9]+)/)?.[1] + + const response2 = await page.goto('/') + const csp2 = response2?.headers()['content-security-policy'] || '' + const nonce2 = csp2.match(/nonce-([a-f0-9]+)/)?.[1] + + expect(nonce1).toBeTruthy() + expect(nonce2).toBeTruthy() + expect(nonce1).not.toBe(nonce2) +}) diff --git a/e2e/react-start/csp/tsconfig.json b/e2e/react-start/csp/tsconfig.json new file mode 100644 index 00000000000..3a9fb7cd716 --- /dev/null +++ b/e2e/react-start/csp/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/csp/vite.config.ts b/e2e/react-start/csp/vite.config.ts new file mode 100644 index 00000000000..275bfacde2a --- /dev/null +++ b/e2e/react-start/csp/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [tanstackStart()], +}) diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 87cb9ce34a9..5e31c8ee70d 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -556,6 +556,7 @@ const executeHead = ( return } const assetContext = { + ssr: inner.router.options.ssr, matches: inner.matches, match, params: match.params, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 10ba75d682e..f6abe2f1c83 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1131,6 +1131,9 @@ type AssetFnContextOptions< in out TBeforeLoadFn, in out TLoaderDeps, > = { + ssr?: { + nonce?: string + } matches: Array< RouteMatch< TRouteId, @@ -1322,7 +1325,7 @@ export interface UpdatableRouteOptions< TBeforeLoadFn, TLoaderDeps >, - ) => Awaitable> + ) => Awaitable | undefined> head?: ( ctx: AssetFnContextOptions< TRouteId, diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index a58e1041d58..67d83d2d61f 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -187,6 +187,7 @@ export async function hydrate(router: AnyRouter): Promise { } const assetContext = { + ssr: router.options.ssr, matches: router.state.matches, match, params: match.params, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e95d29c54a7..25c59bdc99e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1516,6 +1516,46 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/csp: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + srvx: + specifier: ^0.10.0 + version: 0.10.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + e2e/react-start/css-modules: dependencies: '@tanstack/react-router': From d199d7c47e3e3284c4a9bc1fc4602de71b0b2741 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 11 Jan 2026 13:04:51 +0100 Subject: [PATCH 2/2] solid csp --- e2e/solid-start/csp/.gitignore | 5 + e2e/solid-start/csp/package.json | 27 ++++ e2e/solid-start/csp/playwright.config.ts | 35 +++++ e2e/solid-start/csp/public/external.css | 4 + e2e/solid-start/csp/public/external.js | 3 + e2e/solid-start/csp/src/routeTree.gen.ts | 68 +++++++++ e2e/solid-start/csp/src/router.tsx | 20 +++ e2e/solid-start/csp/src/routes/__root.tsx | 49 +++++++ e2e/solid-start/csp/src/routes/index.tsx | 25 ++++ e2e/solid-start/csp/tests/csp.spec.ts | 170 ++++++++++++++++++++++ e2e/solid-start/csp/tsconfig.json | 23 +++ e2e/solid-start/csp/vite.config.ts | 10 ++ packages/solid-router/src/Asset.tsx | 2 +- pnpm-lock.yaml | 34 +++++ 14 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 e2e/solid-start/csp/.gitignore create mode 100644 e2e/solid-start/csp/package.json create mode 100644 e2e/solid-start/csp/playwright.config.ts create mode 100644 e2e/solid-start/csp/public/external.css create mode 100644 e2e/solid-start/csp/public/external.js create mode 100644 e2e/solid-start/csp/src/routeTree.gen.ts create mode 100644 e2e/solid-start/csp/src/router.tsx create mode 100644 e2e/solid-start/csp/src/routes/__root.tsx create mode 100644 e2e/solid-start/csp/src/routes/index.tsx create mode 100644 e2e/solid-start/csp/tests/csp.spec.ts create mode 100644 e2e/solid-start/csp/tsconfig.json create mode 100644 e2e/solid-start/csp/vite.config.ts diff --git a/e2e/solid-start/csp/.gitignore b/e2e/solid-start/csp/.gitignore new file mode 100644 index 00000000000..a86a144649a --- /dev/null +++ b/e2e/solid-start/csp/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.output +test-results +playwright-report diff --git a/e2e/solid-start/csp/package.json b/e2e/solid-start/csp/package.json new file mode 100644 index 00000000000..36ce4523d0e --- /dev/null +++ b/e2e/solid-start/csp/package.json @@ -0,0 +1,27 @@ +{ + "name": "tanstack-solid-start-e2e-csp", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/solid-router": "workspace:^", + "@tanstack/solid-start": "workspace:^", + "solid-js": "^1.9.10", + "vite": "^7.1.7" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "srvx": "^0.10.0", + "typescript": "^5.7.2", + "vite-plugin-solid": "^2.11.10" + } +} diff --git a/e2e/solid-start/csp/playwright.config.ts b/e2e/solid-start/csp/playwright.config.ts new file mode 100644 index 00000000000..5f9de5709e4 --- /dev/null +++ b/e2e/solid-start/csp/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/solid-start/csp/public/external.css b/e2e/solid-start/csp/public/external.css new file mode 100644 index 00000000000..4085d14afdc --- /dev/null +++ b/e2e/solid-start/csp/public/external.css @@ -0,0 +1,4 @@ +.external-styled { + color: blue; + font-weight: bold; +} diff --git a/e2e/solid-start/csp/public/external.js b/e2e/solid-start/csp/public/external.js new file mode 100644 index 00000000000..35cb8170642 --- /dev/null +++ b/e2e/solid-start/csp/public/external.js @@ -0,0 +1,3 @@ +// This script sets a window global when loaded +// Using a global avoids race conditions with Solid and DOM ownership issues +window.__EXTERNAL_SCRIPT_LOADED__ = true diff --git a/e2e/solid-start/csp/src/routeTree.gen.ts b/e2e/solid-start/csp/src/routeTree.gen.ts new file mode 100644 index 00000000000..4776246e1fc --- /dev/null +++ b/e2e/solid-start/csp/src/routeTree.gen.ts @@ -0,0 +1,68 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' + fileRoutesByTo: FileRoutesByTo + to: '/' + id: '__root__' | '/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/solid-start' +declare module '@tanstack/solid-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/solid-start/csp/src/router.tsx b/e2e/solid-start/csp/src/router.tsx new file mode 100644 index 00000000000..bf0b283bb67 --- /dev/null +++ b/e2e/solid-start/csp/src/router.tsx @@ -0,0 +1,20 @@ +import { createRouter } from '@tanstack/solid-router' +import { createIsomorphicFn } from '@tanstack/solid-start' +import { routeTree } from './routeTree.gen' + +const getSSROptions = createIsomorphicFn().server(() => { + const array = new Uint8Array(16) + crypto.getRandomValues(array) + const nonce = Array.from(array, (b) => b.toString(16).padStart(2, '0')).join( + '', + ) + return { nonce } +}) + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + ssr: getSSROptions(), + }) +} diff --git a/e2e/solid-start/csp/src/routes/__root.tsx b/e2e/solid-start/csp/src/routes/__root.tsx new file mode 100644 index 00000000000..82faa1929db --- /dev/null +++ b/e2e/solid-start/csp/src/routes/__root.tsx @@ -0,0 +1,49 @@ +import { + createRootRoute, + HeadContent, + Outlet, + Scripts, +} from '@tanstack/solid-router' +import { HydrationScript } from 'solid-js/web' + +export const Route = createRootRoute({ + headers: ({ ssr }) => { + const nonce = ssr?.nonce + if (!nonce) return + return { + 'Content-Security-Policy': [ + "default-src 'self'", + `script-src 'self' 'nonce-${nonce}'`, + `style-src 'self' 'nonce-${nonce}'`, + ].join('; '), + } + }, + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'CSP Nonce Test' }, + ], + links: [{ rel: 'stylesheet', href: '/external.css' }], + scripts: [{ src: '/external.js' }], + styles: [ + { children: '.inline-styled { color: green; font-weight: bold; }' }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + + ) +} diff --git a/e2e/solid-start/csp/src/routes/index.tsx b/e2e/solid-start/csp/src/routes/index.tsx new file mode 100644 index 00000000000..d737e5d4772 --- /dev/null +++ b/e2e/solid-start/csp/src/routes/index.tsx @@ -0,0 +1,25 @@ +import { createSignal } from 'solid-js' +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + const [count, setCount] = createSignal(0) + + return ( +
+

CSP Nonce Test

+

+ This should be green if inline styles work +

+

+ This should be blue if external styles work +

+ +
+ ) +} diff --git a/e2e/solid-start/csp/tests/csp.spec.ts b/e2e/solid-start/csp/tests/csp.spec.ts new file mode 100644 index 00000000000..247f3b5965b --- /dev/null +++ b/e2e/solid-start/csp/tests/csp.spec.ts @@ -0,0 +1,170 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test('CSP header is set with nonce', async ({ page }) => { + const response = await page.goto('/') + const csp = response?.headers()['content-security-policy'] + expect(csp).toContain("script-src 'self' 'nonce-") + expect(csp).toContain("style-src 'self' 'nonce-") +}) + +test('Inline scripts have nonce attribute', async ({ page }) => { + await page.goto('/') + const scripts = await page.locator('script[nonce]').all() + expect(scripts.length).toBeGreaterThan(0) +}) + +test('Inline styles have nonce attribute', async ({ page }) => { + await page.goto('/') + const styles = await page.locator('style[nonce]').all() + expect(styles.length).toBeGreaterThan(0) +}) + +test('External script has nonce attribute', async ({ page }) => { + await page.goto('/') + const externalScript = page.locator('script[src="/external.js"]') + await expect(externalScript).toHaveAttribute('nonce') +}) + +test('External stylesheet has nonce attribute', async ({ page }) => { + await page.goto('/') + const externalStylesheet = page.locator('link[href="/external.css"]') + await expect(externalStylesheet).toHaveAttribute('nonce') +}) + +test('Nonces match between header and elements', async ({ page }) => { + // Intercept the HTML response to get raw content before browser strips nonces + let rawHtml = '' + await page.route('/', async (route) => { + const response = await route.fetch() + rawHtml = await response.text() + await route.fulfill({ response }) + }) + + const response = await page.goto('/') + await page.unrouteAll({ behavior: 'ignoreErrors' }) + + const csp = response?.headers()['content-security-policy'] || '' + + // Extract nonce from CSP header + const nonceMatch = csp.match(/nonce-([a-f0-9]+)/) + expect(nonceMatch).toBeTruthy() + const headerNonce = nonceMatch![1] + + // Check script nonces match - look for nonce attribute anywhere in the script tag + const scriptNonces = [ + ...rawHtml.matchAll(/]*\bnonce="([^"]+)"[^>]*>/g), + ].map((m) => m[1]) + expect(scriptNonces.length).toBeGreaterThan(0) + for (const nonce of scriptNonces) { + expect(nonce).toBe(headerNonce) + } + + // Check style nonces match + const styleNonces = [ + ...rawHtml.matchAll(/]*\bnonce="([^"]+)"[^>]*>/g), + ].map((m) => m[1]) + expect(styleNonces.length).toBeGreaterThan(0) + for (const nonce of styleNonces) { + expect(nonce).toBe(headerNonce) + } + + // Check external script nonce matches (nonce can be before or after src) + const externalScriptMatch = rawHtml.match( + /]*\bsrc="\/external\.js"[^>]*\bnonce="([^"]+)"[^>]*>|]*\bnonce="([^"]+)"[^>]*\bsrc="\/external\.js"[^>]*>/, + ) + expect(externalScriptMatch).toBeTruthy() + expect(externalScriptMatch![1] || externalScriptMatch![2]).toBe(headerNonce) + + // Check external stylesheet nonce matches (nonce can be before or after href) + const externalStyleMatch = rawHtml.match( + /]*\bhref="\/external\.css"[^>]*\bnonce="([^"]+)"[^>]*>|]*\bnonce="([^"]+)"[^>]*\bhref="\/external\.css"[^>]*>/, + ) + expect(externalStyleMatch).toBeTruthy() + expect(externalStyleMatch![1] || externalStyleMatch![2]).toBe(headerNonce) +}) + +test('Hydration works - counter increments', async ({ page }) => { + await page.goto('/') + await expect(page.getByTestId('counter-value')).toContainText('0') + + // Keep clicking until Solid hydrates and the counter increments + // This handles the race between test execution and hydration + await expect + .poll( + async () => { + await page.getByTestId('counter-btn').click() + return page.getByTestId('counter-value').textContent() + }, + { timeout: 10000, intervals: [100, 200, 500, 1000] }, + ) + .not.toBe('0') + + // Now that hydration is confirmed, verify further increments work + const currentValue = parseInt( + (await page.getByTestId('counter-value').textContent()) || '0', + ) + await page.getByTestId('counter-btn').click() + await expect(page.getByTestId('counter-value')).toContainText( + String(currentValue + 1), + ) +}) + +test('Inline styles work with CSP', async ({ page }) => { + await page.goto('/') + const el = page.getByTestId('inline-styled') + await expect(el).toBeVisible() + // Verify the style was applied (green color) + const color = await el.evaluate((e) => getComputedStyle(e).color) + expect(color).toBe('rgb(0, 128, 0)') // green +}) + +test('External styles work with CSP', async ({ page }) => { + await page.goto('/') + const el = page.getByTestId('external-styled') + await expect(el).toBeVisible() + // Verify the style was applied (blue color) + const color = await el.evaluate((e) => getComputedStyle(e).color) + expect(color).toBe('rgb(0, 0, 255)') // blue +}) + +test('External script executes with CSP', async ({ page }) => { + await page.goto('/') + // Check that the external script set its window global + await expect + .poll(() => page.evaluate(() => (window as any).__EXTERNAL_SCRIPT_LOADED__)) + .toBe(true) +}) + +test('No CSP violations in console', async ({ page }) => { + const violations: string[] = [] + page.on('console', (msg) => { + if (msg.text().toLowerCase().includes('content security policy')) { + violations.push(msg.text()) + } + }) + page.on('pageerror', (err) => { + if (err.message.toLowerCase().includes('content security policy')) { + violations.push(err.message) + } + }) + await page.goto('/') + await page.getByTestId('counter-btn').click() + // Small wait to ensure any async violations are caught + await page.waitForTimeout(100) + expect(violations).toEqual([]) +}) + +test('Each request gets a unique nonce', async ({ page }) => { + const response1 = await page.goto('/') + const csp1 = response1?.headers()['content-security-policy'] || '' + const nonce1 = csp1.match(/nonce-([a-f0-9]+)/)?.[1] + + const response2 = await page.goto('/') + const csp2 = response2?.headers()['content-security-policy'] || '' + const nonce2 = csp2.match(/nonce-([a-f0-9]+)/)?.[1] + + expect(nonce1).toBeTruthy() + expect(nonce2).toBeTruthy() + expect(nonce1).not.toBe(nonce2) +}) diff --git a/e2e/solid-start/csp/tsconfig.json b/e2e/solid-start/csp/tsconfig.json new file mode 100644 index 00000000000..a40235b863f --- /dev/null +++ b/e2e/solid-start/csp/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/solid-start/csp/vite.config.ts b/e2e/solid-start/csp/vite.config.ts new file mode 100644 index 00000000000..a9913da087e --- /dev/null +++ b/e2e/solid-start/csp/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [tanstackStart(), viteSolid({ ssr: true })], +}) diff --git a/packages/solid-router/src/Asset.tsx b/packages/solid-router/src/Asset.tsx index 8e1e79bff2e..4ffe4710c31 100644 --- a/packages/solid-router/src/Asset.tsx +++ b/packages/solid-router/src/Asset.tsx @@ -17,7 +17,7 @@ export function Asset({ case 'link': return case 'style': - return case 'script': return default: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25c59bdc99e..7c7efd80b9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3447,6 +3447,40 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/solid-start/csp: + dependencies: + '@tanstack/solid-router': + specifier: workspace:^ + version: link:../../../packages/solid-router + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../../packages/solid-start + solid-js: + specifier: 1.9.10 + version: 1.9.10 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + srvx: + specifier: ^0.10.0 + version: 0.10.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite-plugin-solid: + specifier: ^2.11.10 + version: 2.11.10(@testing-library/jest-dom@6.6.3)(solid-js@1.9.10)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/solid-start/css-modules: dependencies: '@tanstack/solid-router':