/**
* Reply decoder security tests.
*
* Covers:
* - CVE-2025-55182-style path traversal: "$3:constructor:constructor" etc.
* - Prototype-pollution via __proto__ own-property in JSON payloads
* - `then`-function scrub (attacker thenables cannot be duck-typed)
* - Resource ceilings (maxRows, maxDepth, maxStringLength, maxBigIntDigits)
* - Row-reference path-walk barriers (prototype check, own-key check,
* forbidden-key filter)
*
* These tests target `decodeReply` and the underlying reply-decoder module,
* not the RSC client-stream decoder (which uses `createFromReadableStream`).
*/
import { describe, expect, test } from "vitest";
import { decodeReply } from "../server/shared.mjs";
import {
decodeReplyFromFormData,
decodeReplyFromString,
DecodeError,
DecodeLimitError,
} from "../server/reply-decoder.mjs";
// ───────────────────────────────────────────────────────────────────────────
// Helper: build a FormData body with a root row and optional outlined rows.
// ───────────────────────────────────────────────────────────────────────────
function makeReply(rootJson, outlinedRows = {}) {
const fd = new FormData();
fd.set("0", rootJson);
for (const [id, payload] of Object.entries(outlinedRows)) {
fd.set(id, payload);
}
return fd;
}
// ───────────────────────────────────────────────────────────────────────────
// CVE-2025-55182: "$<id>:constructor:constructor" and variants
// ───────────────────────────────────────────────────────────────────────────
describe("CVE-2025-55182: property-path construction", () => {
test("$3:constructor:constructor throws Invalid reference.", async () => {
// Row 3 holds an empty array. The attacker wants us to walk
// [].constructor (Array) → Array.constructor (Function), then the
// caller invokes the resolved value. The path walker must refuse the
// `constructor` step because it is not an own property.
const fd = makeReply(`"$3:constructor:constructor"`, {
3: JSON.stringify([]),
});
await expect(decodeReply(fd)).rejects.toThrow(/Invalid reference/);
});
test("$3:__proto__:polluted throws Invalid reference.", async () => {
const fd = makeReply(`"$3:__proto__:polluted"`, {
3: JSON.stringify({ a: 1 }),
});
await expect(decodeReply(fd)).rejects.toThrow(/Invalid reference/);
});
test("$3:toString throws Invalid reference (not own property)", async () => {
const fd = makeReply(`"$3:toString"`, {
3: JSON.stringify({}),
});
await expect(decodeReply(fd)).rejects.toThrow(/Invalid reference/);
});
test("$3:prototype throws Invalid reference.", async () => {
const fd = makeReply(`"$3:prototype"`, {
3: JSON.stringify([]),
});
await expect(decodeReply(fd)).rejects.toThrow(/Invalid reference/);
});
test("legitimate own-key paths still resolve", async () => {
const fd = makeReply(`"$3:user:name"`, {
3: JSON.stringify({ user: { name: "Alice" } }),
});
const out = await decodeReply(fd);
expect(out).toBe("Alice");
});
test("legitimate array index path resolves", async () => {
const fd = makeReply(`"$3:0:title"`, {
3: JSON.stringify([{ title: "First" }, { title: "Second" }]),
});
const out = await decodeReply(fd);
expect(out).toBe("First");
});
test("invalid hex row id throws without crashing", async () => {
const fd = makeReply(`"$zz:x"`, {});
// The reference form `$<junk>:<path>` cannot be interpreted safely —
// the decoder must reject it, not silently return the literal string.
await expect(decodeReply(fd)).rejects.toThrow(/Invalid reference/);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Prototype-pollution via JSON.parse own-property __proto__
// ───────────────────────────────────────────────────────────────────────────
describe("Prototype pollution via __proto__ own-property", () => {
test("__proto__ key is stripped from decoded plain objects", async () => {
const payload = decodeReplyFromString(
JSON.stringify({ __proto__: { polluted: true }, safe: "yes" })
);
expect(payload.safe).toBe("yes");
expect(payload.polluted).toBeUndefined();
expect(Object.prototype.polluted).toBeUndefined();
});
test("constructor key is stripped from decoded plain objects", async () => {
const payload = decodeReplyFromString(
JSON.stringify({ constructor: "attack", safe: "yes" })
);
expect(payload.safe).toBe("yes");
expect(payload.constructor).toBe(Object);
});
test("prototype key is stripped from decoded plain objects", async () => {
const payload = decodeReplyFromString(
JSON.stringify({ prototype: "attack", safe: "yes" })
);
expect(payload.safe).toBe("yes");
expect(payload.prototype).toBeUndefined();
});
test("nested __proto__ in arrays is stripped", async () => {
const payload = decodeReplyFromString(
JSON.stringify([{ __proto__: { polluted: true }, ok: 1 }])
);
expect(payload[0].ok).toBe(1);
expect(payload[0].polluted).toBeUndefined();
});
});
// ───────────────────────────────────────────────────────────────────────────
// `then` scrub
// ───────────────────────────────────────────────────────────────────────────
describe("then-function scrub", () => {
test("a parsed `then` string value is preserved (scrub targets functions only)", async () => {
// JSON cannot carry a function, but a legacy decoder writing custom tags
// could in principle yield one. Ensure a plain string `then` is kept.
// The `then` key is written via a computed index so the no-thenable
// static check doesn't flag the literal — the runtime value is identical.
const out = decodeReplyFromString(
JSON.stringify({ [["then"][0]]: "not-a-function" })
);
expect(out.then).toBe("not-a-function");
});
});
// ───────────────────────────────────────────────────────────────────────────
// Resource ceilings
// ───────────────────────────────────────────────────────────────────────────
describe("Resource limits", () => {
test("maxStringLength triggers on an oversized row payload", () => {
const bigRow = "x".repeat(20);
const fd = makeReply(`"$3"`, { 3: bigRow });
// decodeReplyFromFormData is synchronous — use toThrow, not rejects.
expect(() =>
decodeReplyFromFormData(fd, { limits: { maxStringLength: 10 } })
).toThrow(DecodeLimitError);
});
test("maxBigIntDigits triggers on huge bigint payloads", async () => {
const digits = "9".repeat(8192);
await expect(() =>
decodeReplyFromString(`"$n${digits}"`, {
limits: { maxBigIntDigits: 4096 },
})
).toThrow(DecodeLimitError);
});
test("maxRows triggers on formData with too many entries", async () => {
const fd = new FormData();
for (let i = 0; i < 50; i++) fd.append("k" + i, "v");
fd.set("0", "null");
expect(() =>
decodeReplyFromFormData(fd, { limits: { maxRows: 10 } })
).toThrow(DecodeLimitError);
});
test("maxDepth triggers on deeply chained row references", () => {
// Each row references the next via a hex id. Row keys are decimal
// (matching the encoder), references are hex — aligned within 0–9
// where the two coincide. Using N = 15 rows deep with limit 4.
const fd = new FormData();
fd.set("0", `"$1"`);
for (let i = 1; i < 15; i++) {
fd.set(String(i), `"$${(i + 1).toString(16)}"`);
}
fd.set("15", JSON.stringify({ leaf: true }));
expect(() =>
decodeReplyFromFormData(fd, { limits: { maxDepth: 4 } })
).toThrow(DecodeLimitError);
});