Skip to content

Commit bcbdda6

Browse files
fix(astro,tanstack-react-start): Fix XSS via user-controlled JSON (#9166)
1 parent e657d99 commit bcbdda6

5 files changed

Lines changed: 68 additions & 3 deletions

File tree

.changeset/open-buckets-bake.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/tanstack-react-start': patch
3+
'@clerk/shared': patch
4+
'@clerk/astro': patch
5+
---
6+
7+
Escape `<`, `>`, and `/` when serializing the Clerk auth state into SSR `<script>` tags, preventing a `</script>` 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.

packages/astro/src/server/clerk-middleware.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
signedOutAuthObject,
1616
TokenType,
1717
} from '@clerk/backend/internal';
18+
import { htmlSafeJson } from '@clerk/shared/htmlSafeJson';
1819
import { isDevelopmentFromSecretKey } from '@clerk/shared/keys';
1920
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
2021
import { isMalformedURLError } from '@clerk/shared/pathMatcher';
@@ -382,10 +383,10 @@ function decorateRequest(locals: APIContext['locals'], res: Response): Response
382383
const encoder = new TextEncoder();
383384
const closingHeadTag = encoder.encode('</head>');
384385
const clerkAstroData = encoder.encode(
385-
`<script id="__CLERK_ASTRO_DATA__" type="application/json">${JSON.stringify(locals.auth())}</script>\n`,
386+
`<script id="__CLERK_ASTRO_DATA__" type="application/json">${htmlSafeJson(locals.auth())}</script>\n`,
386387
);
387388
const clerkSafeEnvVariables = encoder.encode(
388-
`<script id="__CLERK_ASTRO_SAFE_VARS__" type="application/json">${JSON.stringify(getClientSafeEnv(locals))}</script>\n`,
389+
`<script id="__CLERK_ASTRO_SAFE_VARS__" type="application/json">${htmlSafeJson(getClientSafeEnv(locals))}</script>\n`,
389390
);
390391
const hotloadScript = encoder.encode(buildClerkHotloadScript(locals));
391392

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { htmlSafeJson } from '../htmlSafeJson';
4+
5+
describe('htmlSafeJson', () => {
6+
it('escapes </script> breakout sequences so they cannot terminate a script element', () => {
7+
const payload = { bio: '</script><script>alert(1)</script>' };
8+
const out = htmlSafeJson(payload);
9+
10+
expect(out).not.toContain('</');
11+
expect(out).not.toContain('<');
12+
expect(out).not.toContain('>');
13+
expect(out).not.toContain('/');
14+
expect(JSON.parse(out)).toEqual(payload);
15+
});
16+
17+
it('escapes U+2028 and U+2029 line terminators', () => {
18+
const payload = { sep: '

' };
19+
const out = htmlSafeJson(payload);
20+
21+
expect(out).toContain('\\u2028');
22+
expect(out).toContain('\\u2029');
23+
expect(out).not.toContain('
');
24+
expect(out).not.toContain('
');
25+
expect(JSON.parse(out)).toEqual(payload);
26+
});
27+
28+
it('round-trips arbitrary nested values', () => {
29+
const payload = { a: 1, b: 'plain', c: { d: ['x', '</script>', null] }, e: true };
30+
expect(JSON.parse(htmlSafeJson(payload))).toEqual(payload);
31+
});
32+
33+
it('returns "undefined" when JSON.stringify yields undefined', () => {
34+
expect(htmlSafeJson(undefined)).toBe('undefined');
35+
expect(htmlSafeJson(() => {})).toBe('undefined');
36+
});
37+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// `<`, `>`, `/` (to neutralize `</script>` breakouts) plus the U+2028/U+2029 line
2+
// terminators, which are valid in JSON strings but break executable script contexts.
3+
const ESCAPE_REGEX = /[<>/\u2028\u2029]/g;
4+
5+
/**
6+
* `JSON.stringify` that is safe to embed directly inside an HTML `<script>` element.
7+
*
8+
* `JSON.stringify` leaves `<`, `>` and `/` untouched, so a `</script>` substring in any
9+
* string value would break out of the surrounding script block (XSS). Escaping those
10+
* characters to their `\uXXXX` forms keeps the value byte-identical after `JSON.parse`
11+
* while preventing the HTML parser from terminating the element early.
12+
*/
13+
export function htmlSafeJson(value: unknown): string {
14+
const json = JSON.stringify(value);
15+
if (json === undefined) {
16+
return 'undefined';
17+
}
18+
return json.replace(ESCAPE_REGEX, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
19+
}

packages/tanstack-react-start/src/client/ClerkProvider.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { InternalClerkProvider as ReactClerkProvider, type Ui } from '@clerk/react/internal';
2+
import { htmlSafeJson } from '@clerk/shared/htmlSafeJson';
23
import { ScriptOnce } from '@tanstack/react-router';
34
import { getGlobalStartContext } from '@tanstack/react-start';
45
import { useEffect } from 'react';
@@ -49,7 +50,7 @@ export function ClerkProvider<TUi extends Ui = Ui>({
4950

5051
return (
5152
<>
52-
<ScriptOnce>{`window.__clerk_init_state = ${JSON.stringify(clerkInitialState)};`}</ScriptOnce>
53+
<ScriptOnce>{`window.__clerk_init_state = ${htmlSafeJson(clerkInitialState)};`}</ScriptOnce>
5354
<ClerkOptionsProvider options={mergedProps}>
5455
<ReactClerkProvider
5556
initialState={clerkSsrState}

0 commit comments

Comments
 (0)