From d655a74316c20a6c918be687227228f3b21c4178 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Wed, 15 Jul 2026 12:17:06 +0200 Subject: [PATCH 1/2] fix(astro,tanstack-react-start): Fix XSS via user-controlled JSON The JSON injected in the page may contain user-controlled data in certain circumstances. This PR introduces a helper that ensures the JSON cannot be broken out of to cause XSS. --- .changeset/open-buckets-bake.md | 7 ++++ packages/astro/src/server/clerk-middleware.ts | 5 ++- .../shared/src/__tests__/htmlSafeJson.spec.ts | 37 +++++++++++++++++++ packages/shared/src/htmlSafeJson.ts | 19 ++++++++++ .../src/client/ClerkProvider.tsx | 3 +- 5 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 .changeset/open-buckets-bake.md create mode 100644 packages/shared/src/__tests__/htmlSafeJson.spec.ts create mode 100644 packages/shared/src/htmlSafeJson.ts diff --git a/.changeset/open-buckets-bake.md b/.changeset/open-buckets-bake.md new file mode 100644 index 00000000000..8a627bfb70e --- /dev/null +++ b/.changeset/open-buckets-bake.md @@ -0,0 +1,7 @@ +--- +'@clerk/tanstack-react-start': patch +'@clerk/shared': patch +'@clerk/astro': patch +--- + +Escape `<`, `>`, and `/` when serializing the Clerk auth state into SSR `` sequence inside user-controllable session claims from breaking out of the script element (stored XSS). The embedded JSON still parses to identical values on the client. diff --git a/packages/astro/src/server/clerk-middleware.ts b/packages/astro/src/server/clerk-middleware.ts index 4c4e3082034..cd455e0280d 100644 --- a/packages/astro/src/server/clerk-middleware.ts +++ b/packages/astro/src/server/clerk-middleware.ts @@ -15,6 +15,7 @@ import { signedOutAuthObject, TokenType, } from '@clerk/backend/internal'; +import { htmlSafeJson } from '@clerk/shared/htmlSafeJson'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import { isMalformedURLError } from '@clerk/shared/pathMatcher'; @@ -382,10 +383,10 @@ function decorateRequest(locals: APIContext['locals'], res: Response): Response const encoder = new TextEncoder(); const closingHeadTag = encoder.encode(''); const clerkAstroData = encoder.encode( - `\n`, + `\n`, ); const clerkSafeEnvVariables = encoder.encode( - `\n`, + `\n`, ); const hotloadScript = encoder.encode(buildClerkHotloadScript(locals)); diff --git a/packages/shared/src/__tests__/htmlSafeJson.spec.ts b/packages/shared/src/__tests__/htmlSafeJson.spec.ts new file mode 100644 index 00000000000..d9fc8c9aa83 --- /dev/null +++ b/packages/shared/src/__tests__/htmlSafeJson.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { htmlSafeJson } from '../htmlSafeJson'; + +describe('htmlSafeJson', () => { + it('escapes breakout sequences so they cannot terminate a script element', () => { + const payload = { bio: '' }; + const out = htmlSafeJson(payload); + + expect(out).not.toContain(''); + expect(out).not.toContain('/'); + expect(JSON.parse(out)).toEqual(payload); + }); + + it('escapes U+2028 and U+2029 line terminators', () => { + const payload = { sep: '

' }; + const out = htmlSafeJson(payload); + + expect(out).toContain('\\u2028'); + expect(out).toContain('\\u2029'); + expect(out).not.toContain('
'); + expect(out).not.toContain('
'); + expect(JSON.parse(out)).toEqual(payload); + }); + + it('round-trips arbitrary nested values', () => { + const payload = { a: 1, b: 'plain', c: { d: ['x', '', null] }, e: true }; + expect(JSON.parse(htmlSafeJson(payload))).toEqual(payload); + }); + + it('returns "undefined" when JSON.stringify yields undefined', () => { + expect(htmlSafeJson(undefined)).toBe('undefined'); + expect(htmlSafeJson(() => {})).toBe('undefined'); + }); +}); diff --git a/packages/shared/src/htmlSafeJson.ts b/packages/shared/src/htmlSafeJson.ts new file mode 100644 index 00000000000..e6934890b2f --- /dev/null +++ b/packages/shared/src/htmlSafeJson.ts @@ -0,0 +1,19 @@ +// `<`, `>`, `/` (to neutralize `` breakouts) plus the U+2028/U+2029 line +// terminators, which are valid in JSON strings but break executable script contexts. +const ESCAPE_REGEX = /[<>\/\u2028\u2029]/g; + +/** + * `JSON.stringify` that is safe to embed directly inside an HTML `` substring in any + * string value would break out of the surrounding script block (XSS). Escaping those + * characters to their `\uXXXX` forms keeps the value byte-identical after `JSON.parse` + * while preventing the HTML parser from terminating the element early. + */ +export function htmlSafeJson(value: unknown): string { + const json = JSON.stringify(value); + if (json === undefined) { + return 'undefined'; + } + return json.replace(ESCAPE_REGEX, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`); +} diff --git a/packages/tanstack-react-start/src/client/ClerkProvider.tsx b/packages/tanstack-react-start/src/client/ClerkProvider.tsx index f904a55489d..9231da2a554 100644 --- a/packages/tanstack-react-start/src/client/ClerkProvider.tsx +++ b/packages/tanstack-react-start/src/client/ClerkProvider.tsx @@ -1,4 +1,5 @@ import { InternalClerkProvider as ReactClerkProvider, type Ui } from '@clerk/react/internal'; +import { htmlSafeJson } from '@clerk/shared/htmlSafeJson'; import { ScriptOnce } from '@tanstack/react-router'; import { getGlobalStartContext } from '@tanstack/react-start'; import { useEffect } from 'react'; @@ -49,7 +50,7 @@ export function ClerkProvider({ return ( <> - {`window.__clerk_init_state = ${JSON.stringify(clerkInitialState)};`} + {`window.__clerk_init_state = ${htmlSafeJson(clerkInitialState)};`} Date: Wed, 15 Jul 2026 13:55:17 +0200 Subject: [PATCH 2/2] Fix linting error --- packages/shared/src/htmlSafeJson.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/htmlSafeJson.ts b/packages/shared/src/htmlSafeJson.ts index e6934890b2f..9f682873095 100644 --- a/packages/shared/src/htmlSafeJson.ts +++ b/packages/shared/src/htmlSafeJson.ts @@ -1,6 +1,6 @@ // `<`, `>`, `/` (to neutralize `` breakouts) plus the U+2028/U+2029 line // terminators, which are valid in JSON strings but break executable script contexts. -const ESCAPE_REGEX = /[<>\/\u2028\u2029]/g; +const ESCAPE_REGEX = /[<>/\u2028\u2029]/g; /** * `JSON.stringify` that is safe to embed directly inside an HTML `