From 5f7e640929e3f36fd8cecc03db49a90b176c9c39 Mon Sep 17 00:00:00 2001 From: Moritz Eck Date: Tue, 19 Nov 2024 00:53:43 +0100 Subject: [PATCH 1/7] feat(css): add CSP nonce to hono/css related style and script tags --- runtime-tests/deno-jsx/jsx.test.tsx | 23 +++++++++++++++ src/helper/css/common.case.test.tsx | 15 ++++++++++ src/helper/css/index.test.tsx | 14 ++++++++- src/helper/css/index.ts | 45 ++++++++++++++++++++++++----- src/jsx/dom/css.test.tsx | 12 ++++++++ src/jsx/dom/css.ts | 3 +- 6 files changed, 102 insertions(+), 10 deletions(-) diff --git a/runtime-tests/deno-jsx/jsx.test.tsx b/runtime-tests/deno-jsx/jsx.test.tsx index 4b58a34a9..414869df0 100644 --- a/runtime-tests/deno-jsx/jsx.test.tsx +++ b/runtime-tests/deno-jsx/jsx.test.tsx @@ -111,6 +111,29 @@ Deno.test('JSX: css', async () => { ) }) +Deno.test('JSX: css with CSP nonce', async () => { + const className = css` + color: red; + ` + const html = ( + + +
' + ) +}) + Deno.test('JSX: normalize key', async () => { const className =
const htmlFor =
diff --git a/src/helper/css/common.case.test.tsx b/src/helper/css/common.case.test.tsx index 74422944c..112acd190 100644 --- a/src/helper/css/common.case.test.tsx +++ b/src/helper/css/common.case.test.tsx @@ -488,6 +488,21 @@ export const renderTest = ( '

Hello!

' ) }) + + it('Should render CSS styles with CSP nonce', async () => { + const headerClass = css` + background-color: blue; + ` + const template = ( + <> +

Hello!

' + ) + }) }) }) } diff --git a/src/helper/css/index.test.tsx b/src/helper/css/index.test.tsx index 679f99e15..3e84a9529 100644 --- a/src/helper/css/index.test.tsx +++ b/src/helper/css/index.test.tsx @@ -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' @@ -58,6 +58,18 @@ describe('CSS Helper', () => {

Hello!

` ) }) + + 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' })} +

Hello!

` + expect(await toString(template)).toBe( + ` +

Hello!

` + ) + }) }) describe('cx()', () => { diff --git a/src/helper/css/index.ts b/src/helper/css/index.ts index f1aa02a8d..417923afc 100644 --- a/src/helper/css/index.ts +++ b/src/helper/css/index.ts @@ -50,7 +50,7 @@ interface ViewTransitionType { } interface StyleType { - (args?: { children?: Promise }): HtmlEscapedString + (args?: { children?: Promise; nonce?: string }): HtmlEscapedString } /** @@ -88,9 +88,12 @@ export const createCssContext = ({ id }: { id: Readonly }): DefaultConte return } - const appendStyleScript = `` + const styleNonce = (context as any)?.style?.nonce + + const appendStyleScript = `document.querySelector('#${id}').textContent+=${JSON.stringify(stylesStr)}` + if (buffer) { buffer[0] = `${appendStyleScript}${buffer[0]}` return @@ -156,10 +159,36 @@ export const createCssContext = ({ id }: { id: Readonly }): DefaultConte return newCssClassNameObject(viewTransitionCommon(strings as any, values)) }) as ViewTransitionType - const Style: StyleType = ({ children } = {}) => - children - ? raw(``) - : raw(``) + const Style: StyleType = ({ children, nonce } = {}) => { + const styleTag = children + ? raw( + `` + ) + : raw(``) + + ;(styleTag as any).nonce = nonce + + const storeNonce: HtmlEscapedCallback = ({ context }) => { + if (!nonce) { + return + } + if (!(context as any)?.style) { + ;(context as any).style = {} + } + ;(context as any).style.nonce = nonce + return Promise.resolve(nonce) + } + + if (!styleTag.callbacks) { + styleTag.callbacks = [] + } + styleTag.callbacks.push(storeNonce) + + return styleTag + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(Style as any)[DOM_RENDERER] = StyleRenderToDom diff --git a/src/jsx/dom/css.test.tsx b/src/jsx/dom/css.test.tsx index f7c3298fd..5161af156 100644 --- a/src/jsx/dom/css.test.tsx +++ b/src/jsx/dom/css.test.tsx @@ -52,6 +52,18 @@ describe('Style and css for jsx/dom', () => { ) }) + it('') + }) + it('', async () => { const App = () => { return ( diff --git a/src/jsx/dom/css.ts b/src/jsx/dom/css.ts index c40eb6b1c..5a3b34403 100644 --- a/src/jsx/dom/css.ts +++ b/src/jsx/dom/css.ts @@ -120,11 +120,12 @@ export const createCssJsxDomObjects: CreateCssJsxDomObjectsType = ({ id }) => { }, } - const Style: FC> = ({ children }) => + const Style: FC> = ({ children, nonce }) => ({ tag: 'style', props: { id, + nonce, children: children && (Array.isArray(children) ? children : [children]).map( From f82e191dda3c4c4a6e647e7c0c576b6ee6d6ba22 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 23 Nov 2024 05:54:23 +0900 Subject: [PATCH 2/7] test(helper/css): fix expected result --- runtime-tests/deno-jsx/jsx.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime-tests/deno-jsx/jsx.test.tsx b/runtime-tests/deno-jsx/jsx.test.tsx index 414869df0..c0399afce 100644 --- a/runtime-tests/deno-jsx/jsx.test.tsx +++ b/runtime-tests/deno-jsx/jsx.test.tsx @@ -130,7 +130,7 @@ Deno.test('JSX: css with CSP nonce', async () => { const htmlEscapedString = 'callbacks' in awaitedHtml ? awaitedHtml : await awaitedHtml.toString() assertEquals( await resolveCallback(htmlEscapedString, HtmlEscapedCallbackPhase.Stringify, false, {}), - '
' + '
' ) }) From 671384726b72cea9a747ed00e6e900d2c307692c Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 23 Nov 2024 05:55:04 +0900 Subject: [PATCH 3/7] test(helper/css): add test for stream with nonce --- src/helper/css/index.test.tsx | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/helper/css/index.test.tsx b/src/helper/css/index.test.tsx index 3e84a9529..c8001d9c3 100644 --- a/src/helper/css/index.test.tsx +++ b/src/helper/css/index.test.tsx @@ -239,6 +239,23 @@ describe('CSS Helper', () => { }) }) + app.get('/stream-with-nonce', (c) => { + const stream = renderToReadableStream( + <> +

Loading...

` ) }) From 771d785516a5a0df3839c9640c02449b3242e8a3 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 23 Nov 2024 05:55:23 +0900 Subject: [PATCH 4/7] fix(helper/css): Update regex to match nonce attribute --- src/helper/css/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helper/css/index.ts b/src/helper/css/index.ts index 417923afc..4f0e7d669 100644 --- a/src/helper/css/index.ts +++ b/src/helper/css/index.ts @@ -63,7 +63,7 @@ export const createCssContext = ({ id }: { id: Readonly }): DefaultConte const contextMap: WeakMap = new WeakMap() - const replaceStyleRe = new RegExp(`()`) + const replaceStyleRe = new RegExp(`()`) const newCssClassNameObject = (cssClassName: CssClassNameCommon): Promise => { const appendStyle: HtmlEscapedCallback = ({ buffer, context }): Promise | undefined => { From 63cc496a7cee7048a673633561174c1a88cdc30e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 23 Nov 2024 05:56:27 +0900 Subject: [PATCH 5/7] refactor(helper/css): `context` is read-only and must not be modified. --- src/helper/css/index.ts | 47 +++++++++++++---------------------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/src/helper/css/index.ts b/src/helper/css/index.ts index 4f0e7d669..c426b2892 100644 --- a/src/helper/css/index.ts +++ b/src/helper/css/index.ts @@ -62,6 +62,7 @@ export const createCssContext = ({ id }: { id: Readonly }): DefaultConte const [cssJsxDomObject, StyleRenderToDom] = createCssJsxDomObjects({ id }) const contextMap: WeakMap = new WeakMap() + const nonceMap: WeakMap = new WeakMap() const replaceStyleRe = new RegExp(`()`) @@ -88,10 +89,9 @@ export const createCssContext = ({ id }: { id: Readonly }): DefaultConte return } - const styleNonce = (context as any)?.style?.nonce - + const nonce = nonceMap.get(context) const appendStyleScript = `document.querySelector('#${id}').textContent+=${JSON.stringify(stylesStr)}` if (buffer) { @@ -159,35 +159,18 @@ export const createCssContext = ({ id }: { id: Readonly }): DefaultConte return newCssClassNameObject(viewTransitionCommon(strings as any, values)) }) as ViewTransitionType - const Style: StyleType = ({ children, nonce } = {}) => { - const styleTag = children - ? raw( - `` - ) - : raw(``) - - ;(styleTag as any).nonce = nonce - - const storeNonce: HtmlEscapedCallback = ({ context }) => { - if (!nonce) { - return - } - if (!(context as any)?.style) { - ;(context as any).style = {} - } - ;(context as any).style.nonce = nonce - return Promise.resolve(nonce) - } - - if (!styleTag.callbacks) { - styleTag.callbacks = [] - } - styleTag.callbacks.push(storeNonce) - - return styleTag - } + const Style: StyleType = ({ children, nonce } = {}) => + raw( + ``, + [ + ({ context }) => { + nonceMap.set(context, nonce) + return undefined + }, + ] + ) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(Style as any)[DOM_RENDERER] = StyleRenderToDom From 9177a9d1c209853d3636452f075d68a45e01eced Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 23 Nov 2024 05:59:29 +0900 Subject: [PATCH 6/7] refactor(utils/html): declare context as readonly explicitly --- src/utils/html.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/html.ts b/src/utils/html.ts index 7731e565c..855fa160f 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -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 // An object unique to each JSX tree. This object is used as the WeakMap key. } export type HtmlEscapedCallback = (opts: HtmlEscapedCallbackOpts) => Promise | undefined export type HtmlEscaped = { From 45ce9470a45aa9bf6a264b1c8264eda486451ad6 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Sat, 23 Nov 2024 06:00:54 +0900 Subject: [PATCH 7/7] perf(helper/css): use `has` instead of `get` --- src/helper/css/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helper/css/index.ts b/src/helper/css/index.ts index c426b2892..76437d597 100644 --- a/src/helper/css/index.ts +++ b/src/helper/css/index.ts @@ -103,7 +103,7 @@ export const createCssContext = ({ id }: { id: Readonly }): DefaultConte } const addClassNameToContext: HtmlEscapedCallback = ({ context }) => { - if (!contextMap.get(context)) { + if (!contextMap.has(context)) { contextMap.set(context, [{}, {}]) } const [toAdd, added] = contextMap.get(context) as usedClassNameData