Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

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
Loading