Skip to content

Commit

Permalink
feat(css): add CSP nonce to hono/css related style and script tags (#…
Browse files Browse the repository at this point in the history
…3685)

* feat(css): add CSP nonce to hono/css related style and script tags

* test(helper/css): fix expected result

* test(helper/css): add test for stream with nonce

* fix(helper/css): Update regex to match nonce attribute

* refactor(helper/css): `context` is read-only and must not be modified.

* refactor(utils/html): declare context as readonly explicitly

* perf(helper/css): use `has` instead of `get`

---------

Co-authored-by: Taku Amano <[email protected]>
  • Loading branch information
meck93 and usualoma authored Nov 24, 2024
1 parent 3059741 commit bfc190f
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 13 deletions.
23 changes: 23 additions & 0 deletions runtime-tests/deno-jsx/jsx.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,29 @@ Deno.test('JSX: css', async () => {
)
})

Deno.test('JSX: css with CSP nonce', async () => {
const className = css`
color: red;
`
const html = (
<html>
<head>
<Style nonce='1234' />
</head>
<body>
<div class={className}></div>
</body>
</html>
)

const awaitedHtml = await html
const htmlEscapedString = 'callbacks' in awaitedHtml ? awaitedHtml : await awaitedHtml.toString()
assertEquals(
await resolveCallback(htmlEscapedString, HtmlEscapedCallbackPhase.Stringify, false, {}),
'<html><head><style id="hono-css" nonce="1234">.css-3142110215{color:red}</style></head><body><div class="css-3142110215"></div></body></html>'
)
})

Deno.test('JSX: normalize key', async () => {
const className = <div className='foo'></div>
const htmlFor = <div htmlFor='foo'></div>
Expand Down
15 changes: 15 additions & 0 deletions src/helper/css/common.case.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,21 @@ export const renderTest = (
'<style id="hono-css">.css-478287868{padding:0}</style><h1 class="css-478287868">Hello!</h1>'
)
})

it('Should render CSS styles with CSP nonce', async () => {
const headerClass = css`
background-color: blue;
`
const template = (
<>
<Style nonce='1234' />
<h1 class={headerClass}>Hello!</h1>
</>
)
expect(await toString(template)).toBe(
'<style id="hono-css" nonce="1234">.css-2458908649{background-color:blue}</style><h1 class="css-2458908649">Hello!</h1>'
)
})
})
})
}
47 changes: 46 additions & 1 deletion src/helper/css/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/** @jsxImportSource ../../jsx */
import { Hono } from '../../'
import { html } from '../../helper/html'
import { isValidElement } from '../../jsx'
import type { JSXNode } from '../../jsx'
import { isValidElement } from '../../jsx'
import { Suspense, renderToReadableStream } from '../../jsx/streaming'
import type { HtmlEscapedString } from '../../utils/html'
import { HtmlEscapedCallbackPhase, resolveCallback } from '../../utils/html'
Expand Down Expand Up @@ -58,6 +58,18 @@ describe('CSS Helper', () => {
<h1 class="css-2458908649">Hello!</h1>`
)
})

it('Should render CSS styles with `html` tag function and CSP nonce', async () => {
const headerClass = css`
background-color: blue;
`
const template = html`${Style({ nonce: '1234' })}
<h1 class="${headerClass}">Hello!</h1>`
expect(await toString(template)).toBe(
`<style id="hono-css" nonce="1234">.css-2458908649{background-color:blue}</style>
<h1 class="css-2458908649">Hello!</h1>`
)
})
})

describe('cx()', () => {
Expand Down Expand Up @@ -227,6 +239,23 @@ describe('CSS Helper', () => {
})
})

app.get('/stream-with-nonce', (c) => {
const stream = renderToReadableStream(
<>
<Style nonce='1234' />
<Suspense fallback={<p>Loading...</p>}>
<h1 class={headerClass}>Hello!</h1>
</Suspense>
</>
)
return c.body(stream, {
headers: {
'Content-Type': 'text/html; charset=UTF-8',
'Transfer-Encoding': 'chunked',
},
})
})

it('/sync', async () => {
const res = await app.request('http://localhost/sync')
expect(res).not.toBeNull()
Expand All @@ -247,6 +276,22 @@ if(!d)return
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
d.replaceWith(c.content)
})(document)
</script>`
)
})

it('/stream-with-nonce', async () => {
const res = await app.request('http://localhost/stream-with-nonce')
expect(res).not.toBeNull()
expect(await res.text()).toBe(
`<style id="hono-css" nonce="1234"></style><template id="H:1"></template><p>Loading...</p><!--/$--><script nonce="1234">document.querySelector('#hono-css').textContent+=".css-2458908649{background-color:blue}"</script><template data-hono-target="H:1"><h1 class="css-2458908649">Hello!</h1></template><script>
((d,c,n) => {
c=d.currentScript.previousSibling
d=d.getElementById('H:1')
if(!d)return
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
d.replaceWith(c.content)
})(document)
</script>`
)
})
Expand Down
32 changes: 22 additions & 10 deletions src/helper/css/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ interface ViewTransitionType {
}

interface StyleType {
(args?: { children?: Promise<string> }): HtmlEscapedString
(args?: { children?: Promise<string>; nonce?: string }): HtmlEscapedString
}

/**
Expand All @@ -62,8 +62,9 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
const [cssJsxDomObject, StyleRenderToDom] = createCssJsxDomObjects({ id })

const contextMap: WeakMap<object, usedClassNameData> = new WeakMap()
const nonceMap: WeakMap<object, string | undefined> = new WeakMap()

const replaceStyleRe = new RegExp(`(<style id="${id}">.*?)(</style>)`)
const replaceStyleRe = new RegExp(`(<style id="${id}"(?: nonce="[^"]*")?>.*?)(</style>)`)

const newCssClassNameObject = (cssClassName: CssClassNameCommon): Promise<string> => {
const appendStyle: HtmlEscapedCallback = ({ buffer, context }): Promise<string> | undefined => {
Expand All @@ -88,9 +89,11 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
return
}

const appendStyleScript = `<script>document.querySelector('#${id}').textContent+=${JSON.stringify(
stylesStr
)}</script>`
const nonce = nonceMap.get(context)
const appendStyleScript = `<script${
nonce ? ` nonce="${nonce}"` : ''
}>document.querySelector('#${id}').textContent+=${JSON.stringify(stylesStr)}</script>`

if (buffer) {
buffer[0] = `${appendStyleScript}${buffer[0]}`
return
Expand All @@ -100,7 +103,7 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
}

const addClassNameToContext: HtmlEscapedCallback = ({ context }) => {
if (!contextMap.get(context)) {
if (!contextMap.has(context)) {
contextMap.set(context, [{}, {}])
}
const [toAdd, added] = contextMap.get(context) as usedClassNameData
Expand Down Expand Up @@ -156,10 +159,19 @@ export const createCssContext = ({ id }: { id: Readonly<string> }): DefaultConte
return newCssClassNameObject(viewTransitionCommon(strings as any, values))
}) as ViewTransitionType

const Style: StyleType = ({ children } = {}) =>
children
? raw(`<style id="${id}">${(children as unknown as CssClassName)[STYLE_STRING]}</style>`)
: raw(`<style id="${id}"></style>`)
const Style: StyleType = ({ children, nonce } = {}) =>
raw(
`<style id="${id}"${nonce ? ` nonce="${nonce}"` : ''}>${
children ? (children as unknown as CssClassName)[STYLE_STRING] : ''
}</style>`,
[
({ context }) => {
nonceMap.set(context, nonce)
return undefined
},
]
)

// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(Style as any)[DOM_RENDERER] = StyleRenderToDom

Expand Down
12 changes: 12 additions & 0 deletions src/jsx/dom/css.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ describe('Style and css for jsx/dom', () => {
)
})

it('<Style nonce="1234" />', async () => {
const App = () => {
return (
<div>
<Style nonce='1234' />
</div>
)
}
render(<App />, root)
expect(root.innerHTML).toBe('<div><style id="hono-css" nonce="1234"></style></div>')
})

it('<Style>{css`global`}</Style>', async () => {
const App = () => {
return (
Expand Down
3 changes: 2 additions & 1 deletion src/jsx/dom/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,12 @@ export const createCssJsxDomObjects: CreateCssJsxDomObjectsType = ({ id }) => {
},
}

const Style: FC<PropsWithChildren<void>> = ({ children }) =>
const Style: FC<PropsWithChildren<{ nonce?: string }>> = ({ children, nonce }) =>
({
tag: 'style',
props: {
id,
nonce,
children:
children &&
(Array.isArray(children) ? children : [children]).map(
Expand Down
2 changes: 1 addition & 1 deletion src/utils/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const HtmlEscapedCallbackPhase = {
type HtmlEscapedCallbackOpts = {
buffer?: [string]
phase: (typeof HtmlEscapedCallbackPhase)[keyof typeof HtmlEscapedCallbackPhase]
context: object // An object unique to each JSX tree. This object is used as the WeakMap key.
context: Readonly<object> // An object unique to each JSX tree. This object is used as the WeakMap key.
}
export type HtmlEscapedCallback = (opts: HtmlEscapedCallbackOpts) => Promise<string> | undefined
export type HtmlEscaped = {
Expand Down

0 comments on commit bfc190f

Please sign in to comment.