fix(ssr): escape hydration data in inline scripts

This commit is contained in:
DIYgod 2026-07-25 12:38:49 +08:00
parent 773f1bfe21
commit cf224d63f5
8 changed files with 163 additions and 15 deletions

View File

@ -10,6 +10,7 @@
"dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts",
"meta": "tsx helper/meta-map.ts --watch",
"start": "tsx index.ts",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@ -0,0 +1,109 @@
import { runInNewContext } from "node:vm"
import { minify } from "html-minifier-terser"
import { parseHTML } from "linkedom"
import { describe, expect, it } from "vitest"
import { createHydrationScript, injectHydrationScript } from "./hydration-script"
describe("hydration script", () => {
it("keeps attacker-controlled keys and data inside a single script element", () => {
const key = `profile</ScRiPt><script id="key-payload">`
const data = {
name: `</script><script id="data-payload">globalThis.__pwned__ = true</script><!--`,
characters: "<>&\u2028\u2029",
}
const { document } = parseHTML("<!doctype html><html><head></head><body></body></html>")
injectHydrationScript(document, key, data)
const serializedHtml = document.toString()
const { document: reparsedDocument } = parseHTML(serializedHtml)
const scripts = reparsedDocument.querySelectorAll("script")
const scriptSource = scripts[0]?.textContent
expect(scripts).toHaveLength(1)
expect(reparsedDocument.querySelector("#key-payload")).toBeNull()
expect(reparsedDocument.querySelector("#data-payload")).toBeNull()
expect(scriptSource).not.toContain("<")
expect(scriptSource).not.toContain(">")
expect(scriptSource).not.toContain("&")
expect(scriptSource).not.toContain("\u2028")
expect(scriptSource).not.toContain("\u2029")
const window = {} as {
__HYDRATE__?: Record<string, unknown>
}
runInNewContext(scriptSource!, { window })
expect(window.__HYDRATE__?.[key]).toEqual(data)
})
it("preserves JSON.parse semantics for __proto__ properties", () => {
const data = JSON.parse(`{"__proto__":{"polluted":true}}`)
const window = {} as {
__HYDRATE__?: Record<string, unknown>
}
runInNewContext(createHydrationScript("profile", data), { window })
const hydrated = window.__HYDRATE__?.profile as Record<string, unknown>
expect(Object.hasOwn(hydrated, "__proto__")).toBe(true)
expect((Object.getPrototypeOf(hydrated) as { polluted?: boolean }).polluted).toBeUndefined()
expect(({} as { polluted?: boolean }).polluted).toBeUndefined()
})
it("defines a __proto__ hydration key without changing the store prototype", () => {
const window = {} as {
__HYDRATE__?: Record<string, unknown>
}
runInNewContext(createHydrationScript("__proto__", { value: "safe" }), { window })
const hydrationStore = window.__HYDRATE__!
expect(Object.hasOwn(hydrationStore, "__proto__")).toBe(true)
expect(Object.getOwnPropertyDescriptor(hydrationStore, "__proto__")?.value).toEqual({
value: "safe",
})
expect((Object.getPrototypeOf(hydrationStore) as { value?: string }).value).toBeUndefined()
})
it("remains safe after production HTML and JavaScript minification", async () => {
const key = "profile"
const data = {
name: `</script><script id="minified-payload">globalThis.__pwned__ = true</script>`,
}
const { document } = parseHTML("<!doctype html><html><head></head><body></body></html>")
injectHydrationScript(document, key, data)
const minifiedHtml = await minify(document.toString(), {
collapseBooleanAttributes: true,
collapseInlineTagWhitespace: true,
collapseWhitespace: true,
html5: true,
minifyCSS: true,
minifyJS: true,
removeComments: true,
removeTagWhitespace: true,
})
const { document: reparsedDocument } = parseHTML(minifiedHtml)
const scripts = reparsedDocument.querySelectorAll("script")
const scriptSource = scripts[0]?.textContent
expect(scripts).toHaveLength(1)
expect(reparsedDocument.querySelector("#minified-payload")).toBeNull()
expect(scriptSource?.toLowerCase()).not.toContain("</script")
const window = {} as {
__HYDRATE__?: Record<string, unknown>
}
runInNewContext(scriptSource!, { window })
expect(window.__HYDRATE__?.[key]).toEqual(data)
})
it("rejects top-level values that JSON cannot serialize", () => {
expect(() => createHydrationScript("profile", undefined)).toThrow(
"Hydration data must be JSON serializable",
)
})
})

View File

@ -0,0 +1,38 @@
const serializeJsonForInlineScript = (value: unknown): string => {
const serialized = JSON.stringify(value)
if (serialized === undefined) {
throw new TypeError("Hydration data must be JSON serializable")
}
return serialized
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029")
}
export const createHydrationScript = (key: string, data: unknown): string => {
const serializedData = JSON.stringify(data)
if (serializedData === undefined) {
throw new TypeError("Hydration data must be JSON serializable")
}
return `
window.__HYDRATE__ = window.__HYDRATE__ || {}
Object.defineProperty(window.__HYDRATE__, ${serializeJsonForInlineScript(key)}, {
configurable: true,
enumerable: true,
value: JSON.parse(${serializeJsonForInlineScript(serializedData)}),
writable: true,
})
`
}
export const injectHydrationScript = (document: Document, key: string, data: unknown): void => {
const script = document.createElement("script")
script.textContent = createHydrationScript(key, data)
document.head.append(script)
}

View File

@ -9,6 +9,7 @@ import { FetchError } from "ofetch"
import path, { dirname, resolve } from "pathe"
import xss from "xss"
import { injectHydrationScript } from "../lib/hydration-script"
import { NotFoundError } from "../lib/not-found"
import { buildSeoMetaTags } from "../lib/seo"
import { injectMetaHandler, MetaError } from "../meta-handler"
@ -151,13 +152,7 @@ async function injectMetaToTemplate(document: Document, req: FastifyRequest, res
break
}
case "hydrate": {
// Insert hydrate script
const script = document.createElement("script")
script.innerHTML = `
window.__HYDRATE__ = window.__HYDRATE__ || {}
window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})
`
document.head.append(script)
injectHydrationScript(document, meta.key, meta.data)
break
}
}

View File

@ -62,7 +62,8 @@ export const ogRoute = (app: FastifyInstance) => {
const createErrorFallback = (reply: FastifyReply) => (code: number | Error) => {
if (typeof code !== "number" && code instanceof Error) {
reply.code(500).send(code.message)
console.error("OG render error:", code)
reply.code(500).send("Internal server error")
return null
}
let message = "Internal server error"

View File

@ -38,6 +38,7 @@
"./client/**/*.tsx",
"./types/**/*.d.ts",
"vite.config.mts",
"vitest.config.ts",
"./tailwind.config.ts",
"./helper/**/*.ts"
],

View File

@ -0,0 +1,7 @@
import { defineProject } from "vitest/config"
export default defineProject({
test: {
environment: "node",
},
})

View File

@ -12,6 +12,7 @@ import xss from "xss"
import resvgWasm from "./resvg.wasm"
// OG image rendering
import { createFollowClient } from "./src/lib/api-client"
import { injectHydrationScript } from "./src/lib/hydration-script"
import { NotFoundError } from "./src/lib/not-found"
import { setFontsBucket } from "./src/lib/og/fonts.worker"
import { setWasmModule } from "./src/lib/og/resvg-wasm-shim"
@ -127,7 +128,7 @@ app.get("/og/:type/:id", async (c) => {
return c.text(e === 404 ? "Not found" : "Internal server error", e)
}
console.error("OG render error:", e)
return c.text(e?.message || "Internal server error", 500)
return c.text("Internal server error", 500)
}
if (!imageRes) {
@ -298,12 +299,7 @@ async function injectMetaToTemplate(document: Document, c: any) {
break
}
case "hydrate": {
const script = document.createElement("script")
script.innerHTML = `
window.__HYDRATE__ = window.__HYDRATE__ || {}
window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})
`
document.head.append(script)
injectHydrationScript(document, meta.key, meta.data)
break
}
}