Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion e2e/react-start/csp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"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"
"test:e2e:strictCsp": "rm -rf port*.txt; VITE_CSP=strict playwright test --project=chromium",
"test:e2e:nonceOnlyCsp": "rm -rf port*.txt; VITE_CSP= playwright test --project=chromium",
"test:e2e": "pnpm run test:e2e:strictCsp && pnpm run test:e2e:nonceOnlyCsp"
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
Expand Down
24 changes: 21 additions & 3 deletions e2e/react-start/csp/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@
// 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 OtherRouteImport } from './routes/other'
import { Route as IndexRouteImport } from './routes/index'

const OtherRoute = OtherRouteImport.update({
id: '/other',
path: '/other',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
Expand All @@ -19,28 +25,39 @@ const IndexRoute = IndexRouteImport.update({

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/other': typeof OtherRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/other': typeof OtherRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/other': typeof OtherRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths: '/' | '/other'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to: '/' | '/other'
id: '__root__' | '/' | '/other'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
OtherRoute: typeof OtherRoute
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/other': {
id: '/other'
path: '/other'
fullPath: '/other'
preLoaderRoute: typeof OtherRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
Expand All @@ -53,6 +70,7 @@ declare module '@tanstack/react-router' {

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
OtherRoute: OtherRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
27 changes: 26 additions & 1 deletion e2e/react-start/csp/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
/// <reference types="vite/client" />

import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from '@tanstack/react-router'

export const requiresTrustedTypes: boolean =
import.meta.env.VITE_CSP === 'strict'

declare const trustedTypes: any
Copy link
Author

Choose a reason for hiding this comment

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

These types should make it into TS proper soon™ given that there's now two engines (Safari + Chromium) that ship the feature.


function createPolicy<T>(name: string, policy: T): T {
if (!requiresTrustedTypes) return policy

if (typeof trustedTypes !== 'undefined') {
return trustedTypes.createPolicy(name, policy)
}
return policy
}

const policy = createPolicy('test-app', {
createHTML: (s: string) => s,
})

export const Route = createRootRoute({
headers: ({ ssr }) => {
const nonce = ssr?.nonce
Expand All @@ -14,6 +34,7 @@ export const Route = createRootRoute({
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'nonce-${nonce}'`,
...(requiresTrustedTypes ? ["require-trusted-types-for 'script'"] : []),
].join('; '),
}
},
Expand All @@ -26,7 +47,11 @@ export const Route = createRootRoute({
links: [{ rel: 'stylesheet', href: '/external.css' }],
scripts: [{ src: '/external.js' }],
styles: [
{ children: '.inline-styled { color: green; font-weight: bold; }' },
{
children: policy.createHTML(
'.inline-styled { color: green; font-weight: bold; }',
),
},
],
}),
component: RootComponent,
Expand Down
7 changes: 6 additions & 1 deletion e2e/react-start/csp/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
component: Home,
Expand All @@ -20,6 +20,11 @@ function Home() {
<button data-testid="counter-btn" onClick={() => setCount((c) => c + 1)}>
Count: <span data-testid="counter-value">{count}</span>
</button>
<p>
<Link to="/other" data-testid="other-link">
Test Navigation
</Link>
</p>
</div>
)
}
19 changes: 19 additions & 0 deletions e2e/react-start/csp/src/routes/other.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/other')({
component: Other,
})

function Other() {
const [count, setCount] = useState(0)

return (
<div>
<h1 data-testid="other-heading">CSP Navigation Test</h1>
<button data-testid="counter-btn" onClick={() => setCount((c) => c + 1)}>
Count: <span data-testid="counter-value">{count}</span>
</button>
</div>
)
}
6 changes: 6 additions & 0 deletions e2e/react-start/csp/test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"status": "failed",
"failedTests": [
"7ddd34f8774fe467a796-c4f28a71d37273b11ec5"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Page snapshot

```yaml
- generic [ref=e3]:
- strong [ref=e4]: Something went wrong!
- button "Show Error" [ref=e5]
```
27 changes: 27 additions & 0 deletions e2e/react-start/csp/tests/csp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,30 @@ test('Each request gets a unique nonce', async ({ page }) => {
expect(nonce2).toBeTruthy()
expect(nonce1).not.toBe(nonce2)
})

test('Client-side navigation works', async ({ page }) => {
const violations: Array<string> = []
const logViolation = (text: string) => {
const lowerText = text.toLowerCase()
if (
lowerText.includes('trusted type') ||
lowerText.includes('content security policy') ||
lowerText.includes('trustedhtml') ||
lowerText.includes('trustedscript')
) {
violations.push(text)
}
}

// Wait until idle to ensure we're doing a soft navigation.
await page.goto('/', { waitUntil: 'networkidle' })
await expect(page.getByTestId('csp-heading')).toBeVisible()

page.on('console', (msg) => logViolation(msg.text()))
page.on('pageerror', (err) => logViolation(err.message))

await page.getByRole('link', { name: 'Test Navigation' }).click()

await expect(page.getByTestId('other-heading')).toBeVisible()
expect(violations).toEqual([])
})
47 changes: 33 additions & 14 deletions packages/react-router/src/Asset.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react'
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
import { trustedTanStackPolicy } from './utils'
import type { RouterManagedTag } from '@tanstack/router-core'

interface ScriptAttrs {
Expand Down Expand Up @@ -50,6 +51,8 @@ function Script({
}) {
const router = useRouter()

const hasContent = children != null

React.useEffect(() => {
if (attrs?.src) {
const normSrc = (() => {
Expand All @@ -70,16 +73,24 @@ function Script({

const script = document.createElement('script')

if (children) {
script.textContent = children
}

for (const [key, value] of Object.entries(attrs)) {
if (
key !== 'suppressHydrationWarning' &&
value !== undefined &&
value !== false
) {
script.setAttribute(
key,
typeof value === 'boolean' ? '' : String(value),
)
if (key === 'src') {
script.src = value as string
} else {
script.setAttribute(
key,
typeof value === 'boolean' ? '' : String(value),
)
}
}
}

Expand All @@ -92,7 +103,7 @@ function Script({
}
}

if (typeof children === 'string') {
if (hasContent) {
const typeAttr =
typeof attrs?.type === 'string' ? attrs.type : 'text/javascript'
const nonceAttr =
Expand Down Expand Up @@ -124,10 +135,14 @@ function Script({
value !== undefined &&
value !== false
) {
script.setAttribute(
key,
typeof value === 'boolean' ? '' : String(value),
)
if (key === 'src') {
script.src = value as string
} else {
script.setAttribute(
key,
typeof value === 'boolean' ? '' : String(value),
)
}
}
}
}
Expand All @@ -142,15 +157,17 @@ function Script({
}

return undefined
}, [attrs, children])
}, [attrs, children, hasContent])

if (!(isServer ?? router.isServer)) {
const { src, ...rest } = attrs || {}
const { src: _src, ...rest } = attrs || {}
// render an empty script on the client just to avoid hydration errors
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: '' }}
dangerouslySetInnerHTML={{
__html: trustedTanStackPolicy.createHTML(''),
}}
{...rest}
></script>
)
Expand All @@ -160,11 +177,13 @@ function Script({
return <script {...attrs} suppressHydrationWarning />
}

if (typeof children === 'string') {
if (hasContent) {
return (
<script
{...attrs}
dangerouslySetInnerHTML={{ __html: children }}
dangerouslySetInnerHTML={{
__html: children,
}}
suppressHydrationWarning
/>
)
Expand Down
19 changes: 19 additions & 0 deletions packages/react-router/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,22 @@ export function useForwardedRef<T>(ref?: React.ForwardedRef<T>) {
React.useImperativeHandle(ref, () => innerRef.current!, [])
return innerRef
}

interface TanStackPolicy {
createHTML: (s: string) => string
createScript: (s: string) => string
createScriptURL: (s: string) => string
}

declare const trustedTypes: any

const tanStackPolicy: TanStackPolicy = {
Copy link
Author

Choose a reason for hiding this comment

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

NOTE: This policy should really be locked down, at least to some extent. E.g. createHTML shouldn't just bless any string when its purpose is to bless static strings like ''.

createHTML: (s: string) => s,
createScript: (s: string) => s,
createScriptURL: (s: string) => s,
}

export const trustedTanStackPolicy: TanStackPolicy =
typeof trustedTypes !== 'undefined'
? trustedTypes.createPolicy('tanstack-internal', tanStackPolicy)
: tanStackPolicy
Loading