Skip to content

Commit c38ddcb

Browse files
committed
Add support for csp nonces being set on the reply object
1 parent 32592af commit c38ddcb

File tree

11 files changed

+226
-18
lines changed

11 files changed

+226
-18
lines changed

packages/fastify-renderer/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"@types/sanitize-filename": "^1.6.3",
8888
"@typescript-eslint/eslint-plugin": "^5.40.0",
8989
"@typescript-eslint/parser": "^5.40.0",
90+
"cheerio": "^1.0.0-rc.12",
9091
"eslint": "^7.32.0",
9192
"eslint-config-prettier": "^8.5.0",
9293
"eslint-plugin-import": "^2.26.0",

packages/fastify-renderer/src/node/Plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export class FastifyRendererPlugin {
9999

100100
if (file.endsWith('.js')) {
101101
if (root) {
102-
bus.push('tail', `<script type="module" src="${file}"></script>`)
102+
bus.loadScript(file)
103103
} else {
104104
bus.preloadModule(file)
105105
}

packages/fastify-renderer/src/node/RenderBus.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Readable } from 'stream'
2+
import { Render, scriptTag, stylesheetLinkTag } from './renderers/Renderer'
23

34
export interface Stack {
45
content: string[]
@@ -12,6 +13,8 @@ export class RenderBus {
1213
stacks: Record<string, Stack> = {}
1314
included = new Set<string>()
1415

16+
constructor(readonly render: Render) {}
17+
1518
private createStack(key) {
1619
const stack: Stack = (this.stacks[key] = {
1720
content: [],
@@ -57,6 +60,10 @@ export class RenderBus {
5760
linkStylesheet(path: string) {
5861
if (this.included.has(path)) return
5962
this.included.add(path)
60-
this.push('head', `<link rel="stylesheet" href="${path}">`)
63+
this.push('head', stylesheetLinkTag(this.render, path))
64+
}
65+
66+
loadScript(src: string) {
67+
this.push('tail', scriptTag(this.render, ``, { src }))
6168
}
6269
}

packages/fastify-renderer/src/node/renderers/Renderer.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,21 @@ export interface Renderer {
4141
buildVirtualServerEntrypointModuleID(renderable: RenderableRegistration): string
4242
vitePlugins(): Plugin[]
4343
}
44+
45+
export function scriptTag(render: Render, content: string, attrs: Record<string, string> = {}) {
46+
if ('cspNonce' in render.reply) {
47+
attrs.nonce ??= (render.reply as any).cspNonce.script
48+
}
49+
50+
const attrsString = Object.entries(attrs)
51+
.map(([key, value]) => `${key}="${value}"`)
52+
.join(' ')
53+
54+
return `<script type="module" ${attrsString}>${content}</script>`
55+
}
56+
57+
export function stylesheetLinkTag(render: Render, href: string) {
58+
const nonceString = 'cspNonce' in render.reply ? `nonce="${(render.reply as any).cspNonce.style}"` : ''
59+
60+
return `<link rel="stylesheet" href="${href}" ${nonceString}>`
61+
}

packages/fastify-renderer/src/node/renderers/react/ReactRenderer.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { RenderBus } from '../../RenderBus'
1010
import { wrap } from '../../tracing'
1111
import { FastifyRendererHook } from '../../types'
1212
import { mapFilepathToEntrypointName, unthunk } from '../../utils'
13-
import { Render, RenderableRegistration, Renderer } from '../Renderer'
13+
import { Render, RenderableRegistration, Renderer, scriptTag } from '../Renderer'
1414

1515
const CLIENT_ENTRYPOINT_PREFIX = '/@fstr!entrypoint:'
1616
const SERVER_ENTRYPOINT_PREFIX = '/@fstr!server-entrypoint:'
@@ -195,24 +195,23 @@ export class ReactRenderer implements Renderer {
195195
}
196196

197197
private startRenderBus(render: Render<any>) {
198-
const bus = new RenderBus()
198+
const bus = new RenderBus(render)
199199

200200
// push the script for the react-refresh runtime that vite's plugin normally would
201201
if (this.plugin.devMode) {
202-
bus.push('tail', this.reactRefreshScriptTag())
202+
bus.push('tail', this.reactRefreshScriptTag(render))
203203
}
204204

205205
// push the props for the entrypoint to use when hydrating client side
206-
bus.push('tail', `<script type="module">window.__FSTR_PROPS=${JSON.stringify(render.props)};</script>`)
206+
bus.push('tail', scriptTag(render, `window.__FSTR_PROPS=${JSON.stringify(render.props)};`))
207207

208208
// if we're in development, we just source the entrypoint directly from vite and let the browser do its thing importing all the referenced stuff
209209
if (this.plugin.devMode) {
210210
bus.push(
211211
'tail',
212-
`<script type="module" src="${path.join(
213-
this.plugin.assetsHost,
214-
this.entrypointScriptTagSrcForClient(render)
215-
)}"></script>`
212+
scriptTag(render, ``, {
213+
src: path.join(this.plugin.assetsHost, this.entrypointScriptTagSrcForClient(render)),
214+
})
216215
)
217216
} else {
218217
const entrypointName = this.buildVirtualClientEntrypointModuleID(render)
@@ -500,13 +499,15 @@ export const routes = [
500499
}
501500
}
502501

503-
private reactRefreshScriptTag() {
504-
return `<script type="module">
502+
private reactRefreshScriptTag(render: Render) {
503+
return scriptTag(
504+
render,
505+
`
505506
import RefreshRuntime from "${path.join(this.viteConfig.base, '@react-refresh')}"
506507
RefreshRuntime.injectIntoGlobalHook(window)
507508
window.$RefreshReg$ = () => {}
508509
window.$RefreshSig$ = () => (type) => type
509-
window.__vite_plugin_react_preamble_installed__ = true
510-
</script>`
510+
window.__vite_plugin_react_preamble_installed__ = true`
511+
)
511512
}
512513
}

packages/fastify-renderer/test/Plugin.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import fs from 'fs'
22
import path from 'path'
33
import { DefaultDocumentTemplate } from '../src/node/DocumentTemplate'
44
import { FastifyRendererOptions } from '../src/node/Plugin'
5-
import { RenderBus } from '../src/node/RenderBus'
65
import { ReactRenderer } from '../src/node/renderers/react/ReactRenderer'
76
import { RenderableRegistration } from '../src/node/renderers/Renderer'
8-
import { newFastifyRendererPlugin } from './helpers'
7+
import { newFastifyRendererPlugin, newRenderBus } from './helpers'
98

109
jest.mock('fs', () => ({
1110
...jest.requireActual('fs'), // import and retain the original functionalities
@@ -76,7 +75,7 @@ describe('FastifyRendererPlugin', () => {
7675
describe('pushImportTagsFromManifest()', () => {
7776
test('should throw when an entry is not found in the manifest', async () => {
7877
const plugin = newFastifyRendererPlugin({} as FastifyRendererOptions)
79-
const bus = new RenderBus()
78+
const bus = newRenderBus()
8079
expect(() => plugin.pushImportTagsFromManifest(bus, 'entry-name')).toThrow()
8180
})
8281

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as cheerio from 'cheerio'
2+
import path from 'path'
3+
4+
import FastifyRenderer from '../src/node'
5+
import { newFastify } from './helpers'
6+
7+
const testComponent = require.resolve(path.join(__dirname, 'fixtures', 'test-style-importer.tsx'))
8+
const testLayoutComponent = require.resolve(path.join(__dirname, 'fixtures', 'test-layout.tsx'))
9+
10+
const options = {
11+
vite: { root: __dirname, logLevel: (process.env.LOG_LEVEL ?? 'info') as any },
12+
devMode: true,
13+
renderer: {
14+
mode: 'sync' as const,
15+
type: 'react' as const,
16+
},
17+
}
18+
19+
describe('csp nonce handling', () => {
20+
let server
21+
beforeAll(async () => {
22+
server = await newFastify()
23+
await server.register(FastifyRenderer, options)
24+
server.setRenderConfig({
25+
base: '',
26+
layout: testLayoutComponent,
27+
})
28+
29+
server.decorateReply('cspNonce', {
30+
style: 'style-nonce',
31+
script: 'script-nonce',
32+
})
33+
server.get('/render-test', { render: testComponent }, async (_request, _reply) => ({ a: 1, b: 2 }))
34+
})
35+
36+
test('should script and style tags with csp nonces if available', async () => {
37+
const response = await server.inject({
38+
method: 'GET',
39+
url: '/render-test',
40+
headers: { Accept: 'text/html' },
41+
})
42+
43+
expect(response.statusCode).toEqual(200)
44+
const $ = cheerio.load(response.body as string)
45+
const scripts = $('script')
46+
expect(scripts).toHaveLength(3)
47+
for (const tag of scripts) {
48+
expect(tag.attribs.nonce).toEqual('script-nonce')
49+
}
50+
})
51+
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
body {
2+
color: red;
3+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import React from 'react'
2+
import './styles.css'
3+
4+
// eslint-disable-next-line react/display-name
5+
export default function (props: { a: string; b: number }) {
6+
if (typeof props.a == 'undefined') {
7+
throw new Error('expected to be passed props from render function')
8+
}
9+
return (
10+
<>
11+
<h1>{props.a}</h1>
12+
<p>{props.b}</p>
13+
</>
14+
)
15+
}

packages/fastify-renderer/test/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const newFastify = async (options?: FastifyServerOptions) => {
1818
}
1919

2020
export const newRenderBus = () => {
21-
return new RenderBus()
21+
return new RenderBus(getMockRender({}))
2222
}
2323

2424
export const newFastifyRendererPlugin = (options: FastifyRendererOptions = {}) => {

0 commit comments

Comments
 (0)