Skip to content

Commit 38e1cf5

Browse files
fix(core): reject a hydration payload that parses but has the wrong shape (#656)
`getHydrationData` guards `typeof document`, returns null when the script is absent, and wraps the parse in try/catch -- and then cast the result: return JSON.parse(content) as HydrationData; So it caught a parse failure and handed back a parse success of any shape. `initHydration` dereferences that through `applyHydratedHead(data.head)`, which reads `head.title`: script content getHydrationData initHydration valid payload the data ok not json null ok (empty) null ok null null ok {} {} throws on 'title' [] [] throws on 'title' {"head":null} {"head":null} throws on 'title' "str" "str" throws on 'title' 123 123 throws on 'title' Malformed JSON was handled; well-formed JSON of the wrong shape was not, which inverts what the guard looks like it is doing. `initHydration` runs at client startup, so the exception aborts hydration for the whole page rather than degrading to a client render. This is not reachable through the framework's own rendering -- serializeHydrationData always emits the right shape. It is reachable when something between the server and the parse alters the script (an HTML minifier or transformer, a proxy that rewrites the document, a stale script from a previous version), and through the public exports, which a consumer can call against a document the framework did not produce. `applyHydratedHead` is likewise public, so it keeps its own guard for callers that reach it directly, and now requires the title to be a string rather than assigning whatever it was handed. Closes #655
1 parent 875c2bb commit 38e1cf5

2 files changed

Lines changed: 153 additions & 3 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* MIT License
4+
*
5+
* Copyright (c) 2025 Chris M. Perez
6+
*/
7+
8+
import { afterEach, describe, expect, it } from 'vitest';
9+
import {
10+
applyHydratedHead,
11+
getHydrationData,
12+
initHydration,
13+
serializeHydrationData,
14+
} from '../../ssr/hydration.js';
15+
import { HYDRATION_SCRIPT_ID } from '../../constants.js';
16+
17+
/** Put `content` in the hydration script, as a transformed document might. */
18+
const putScript = (content: string): void => {
19+
document.body.replaceChildren();
20+
const script = document.createElement('script');
21+
script.id = HYDRATION_SCRIPT_ID;
22+
script.type = 'application/json';
23+
script.textContent = content;
24+
document.body.append(script);
25+
};
26+
27+
const VALID = {
28+
head: { title: 'T' },
29+
state: { a: 1 },
30+
url: '/',
31+
timestamp: 0,
32+
};
33+
34+
describe('hydration payloads that parse but have the wrong shape', () => {
35+
afterEach(() => {
36+
document.body.replaceChildren();
37+
document.title = '';
38+
});
39+
40+
// `getHydrationData` catches a parse failure and then cast the result, so
41+
// these reached `applyHydratedHead` and threw on `head.title`.
42+
const WRONG: [string, string][] = [
43+
['an empty object', '{}'],
44+
['an array', '[]'],
45+
['a null head', '{"head":null}'],
46+
['a string', '"str"'],
47+
['a number', '123'],
48+
['a head that is not an object', '{"head":5}'],
49+
];
50+
51+
it.each(WRONG)('returns null for %s', (_label, content) => {
52+
putScript(content);
53+
54+
expect(getHydrationData()).toBeNull();
55+
});
56+
57+
it.each(WRONG)('does not throw from initHydration for %s', (_label, content) => {
58+
putScript(content);
59+
60+
expect(() => initHydration()).not.toThrow();
61+
expect(initHydration()).toBeNull();
62+
});
63+
});
64+
65+
describe('hydration payloads that were already handled', () => {
66+
afterEach(() => {
67+
document.body.replaceChildren();
68+
});
69+
70+
it.each([
71+
['malformed json', 'not json'],
72+
['empty content', ''],
73+
['a literal null', 'null'],
74+
])('still returns null for %s', (_label, content) => {
75+
putScript(content);
76+
77+
expect(getHydrationData()).toBeNull();
78+
expect(() => initHydration()).not.toThrow();
79+
});
80+
81+
it('returns null when there is no script at all', () => {
82+
document.body.replaceChildren();
83+
84+
expect(getHydrationData()).toBeNull();
85+
expect(initHydration()).toBeNull();
86+
});
87+
});
88+
89+
describe('a valid hydration payload', () => {
90+
afterEach(() => {
91+
document.body.replaceChildren();
92+
document.title = '';
93+
});
94+
95+
it('round-trips through the serializer', () => {
96+
const html = serializeHydrationData(VALID);
97+
const content = /type="application\/json">([\s\S]*)<\/script>/.exec(
98+
html
99+
)?.[1] as string;
100+
putScript(content);
101+
102+
expect(getHydrationData()).toEqual(VALID);
103+
});
104+
105+
it('is returned and applies its title', () => {
106+
putScript(JSON.stringify(VALID));
107+
108+
expect(initHydration()).toEqual(VALID);
109+
expect(document.title).toBe('T');
110+
});
111+
112+
it('is accepted with an empty head and empty state', () => {
113+
const payload = { head: {}, state: {}, url: '/', timestamp: 0 };
114+
putScript(JSON.stringify(payload));
115+
116+
expect(getHydrationData()).toEqual(payload);
117+
expect(() => initHydration()).not.toThrow();
118+
});
119+
120+
it('tolerates a head carrying no title', () => {
121+
expect(() => applyHydratedHead({})).not.toThrow();
122+
});
123+
124+
it('tolerates being called directly with no head at all', () => {
125+
// A public export, so a consumer reaches it without going through
126+
// `getHydrationData` and its shape check.
127+
expect(() => applyHydratedHead(null as never)).not.toThrow();
128+
expect(() => applyHydratedHead(undefined as never)).not.toThrow();
129+
expect(() => applyHydratedHead('str' as never)).not.toThrow();
130+
});
131+
});

packages/core/src/ssr/hydration.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* SOFTWARE.
2323
*/
2424

25+
import { Predicate } from 'effect';
2526
import type { HeadProps } from './types.js';
2627
import { HYDRATION_SCRIPT_ID } from '../constants.js';
2728

@@ -48,6 +49,19 @@ export const serializeHydrationData = (data: HydrationData): string => {
4849
return `<script id="${HYDRATION_SCRIPT_ID}" type="application/json">${escaped}</script>`;
4950
};
5051

52+
/**
53+
* Whether a parsed payload is actually hydration data.
54+
*
55+
* The parse result used to be cast straight to `HydrationData`, so the guard
56+
* below caught malformed JSON and then handed back well-formed JSON of any
57+
* shape: `{}`, `[]`, `"str"` and `123` all reached `applyHydratedHead`, which
58+
* threw reading `head.title` and aborted hydration for the whole page.
59+
*
60+
* Only `head` is checked, because that is the field this module dereferences.
61+
*/
62+
const isHydrationData = (value: unknown): value is HydrationData =>
63+
Predicate.isRecord(value) && Predicate.isRecord(value.head);
64+
5165
/**
5266
* Retrieve hydration data from the DOM on the client side.
5367
* Returns null if the hydration script is not found or parsing fails.
@@ -65,7 +79,8 @@ export const getHydrationData = (): HydrationData | null => {
6579
try {
6680
const content = script.textContent;
6781
if (!content) return null;
68-
return JSON.parse(content) as HydrationData;
82+
const parsed: unknown = JSON.parse(content);
83+
return isHydrationData(parsed) ? parsed : null;
6984
} catch {
7085
return null;
7186
}
@@ -154,9 +169,13 @@ const sameContent = (
154169
*/
155170
export const applyHydratedHead = (head: HeadProps): void => {
156171
if (typeof document === 'undefined') return;
172+
// A public export, so it is reachable without `getHydrationData` and its
173+
// shape check; `head.title` threw when it was handed null or a primitive.
174+
if (!Predicate.isRecord(head)) return;
157175

158-
if (head.title && document.title !== head.title) {
159-
document.title = head.title;
176+
const title = head.title;
177+
if (Predicate.isString(title) && title !== '' && document.title !== title) {
178+
document.title = title;
160179
}
161180
};
162181

0 commit comments

Comments
 (0)