fix(ssr): escape hydration data in inline scripts
This commit is contained in:
parent
773f1bfe21
commit
cf224d63f5
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"./client/**/*.tsx",
|
||||
"./types/**/*.d.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"./tailwind.config.ts",
|
||||
"./helper/**/*.ts"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineProject } from "vitest/config"
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue