fix(codemode): harden JSON replacers

This commit is contained in:
Aiden Cline
2026-07-20 17:10:32 -05:00
parent d102b48dea
commit 9e02f67f7a
2 changed files with 80 additions and 13 deletions
+10 -1
View File
@@ -96,26 +96,35 @@ const stringify = <R>(
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node)
const root: SafeObject = Object.create(null) as SafeObject
root[""] = input
const stack = new Set<object>()
const visit = (holder: SafeObject | Array<unknown>, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = yield* apply([key, toJSONValue(holder[key as keyof typeof holder])])
if (value === undefined || typeofValue(value) === "function") return undefined
copyIn(value, "JSON.stringify replacer result", true)
if (value === undefined) return undefined
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (Array.isArray(value)) {
if (stack.has(value))
throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError")
stack.add(value)
const result: Array<unknown> = []
for (let index = 0; index < value.length; index += 1) {
result.push((yield* visit(value, String(index))) ?? null)
}
stack.delete(value)
return result
}
if (!isPlainObject(value)) return {}
if (stack.has(value))
throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError")
stack.add(value)
const result: SafeObject = Object.create(null) as SafeObject
for (const name of Object.keys(value)) {
const item = yield* visit(value, name)
if (item !== undefined) result[name] = item
}
stack.delete(value)
return result
})
@@ -10,7 +10,9 @@
* - test/built-ins/JSON/stringify/replacer-array-wrong-type.js
* - test/built-ins/JSON/stringify/replacer-function-abrupt.js
* - test/built-ins/JSON/stringify/replacer-function-arguments.js
* - test/built-ins/JSON/stringify/replacer-function-array-circular.js
* - test/built-ins/JSON/stringify/replacer-function-object-deleted-property.js
* - test/built-ins/JSON/stringify/replacer-function-object-circular.js
* - test/built-ins/JSON/stringify/replacer-function-result-undefined.js
* - test/built-ins/JSON/stringify/replacer-function-result.js
*
@@ -27,6 +29,7 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
import { invokeJsonMethod } from "../src/stdlib/json.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
@@ -132,6 +135,40 @@ describe("Test262 JSON.stringify replacer adaptations", () => {
).toBe('{"a":1,"b":"<replaced>"}')
})
test("test/built-ins/JSON/stringify/replacer-function-object-circular.js", async () => {
expect(
await value(`
const direct = { prop: {} }
const indirect = { p1: { p2: {} } }
const errors = []
try { JSON.stringify(direct, () => direct) } catch (error) { errors.push(error.name) }
try {
JSON.stringify(indirect, (key, item) => key === "p2" ? indirect : item)
} catch (error) { errors.push(error.name) }
return errors
`),
).toEqual(["TypeError", "TypeError"])
})
test("test/built-ins/JSON/stringify/replacer-function-array-circular.js", async () => {
expect(
await value(`
const circular = [{}]
try { JSON.stringify(circular, () => circular) } catch (error) { return error.name }
return "missed"
`),
).toBe("TypeError")
})
test("repeated objects outside the active ancestor chain are not circular", async () => {
expect(
await value(`
const shared = { value: 1 }
return JSON.stringify({ left: shared, right: shared }, (key, item) => item)
`),
).toBe('{"left":{"value":1},"right":{"value":1}}')
})
test("function replacers replace values and map undefined to omission or null", async () => {
expect(
await value(`return JSON.stringify({ a: 1, nested: { b: [1] } }, (key, item) => item === 1 ? undefined : item)`),
@@ -140,6 +177,19 @@ describe("Test262 JSON.stringify replacer adaptations", () => {
expect(await value(`return JSON.stringify(1, () => undefined) === undefined`)).toBe(true)
})
test("callable replacer results follow JSON omission semantics", async () => {
expect(
await value(`
const fn = () => 1
return [
JSON.stringify({ user: 1, builtin: 2 }, (key, item) => key === "user" ? fn : key === "builtin" ? Math.abs : item),
JSON.stringify([1, 2], (key, item) => key === "0" ? fn : key === "1" ? Number : item),
JSON.stringify(null, () => "abc".includes) === undefined,
]
`),
).toEqual(["{}", "[null,null]", true])
})
test("built-in toJSON conversion runs before the function replacer", async () => {
expect(
await value(`
@@ -168,18 +218,26 @@ describe("CodeMode JSON callback boundaries", () => {
expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } })
})
test("blocked keys are rejected before callbacks can expose them", async () => {
test("blocked parse keys are rejected before reviver traversal", async () => {
expect(
await value(`
let parseBlocked = false
let stringifyBlocked = false
try { JSON.parse('{"__proto__":1}', (key, item) => item) } catch (error) { parseBlocked = true }
try {
const input = JSON.parse('{"constructor":1}')
JSON.stringify(input, (key, item) => item)
} catch (error) { stringifyBlocked = true }
return [parseBlocked, stringifyBlocked]
`),
).toEqual([true, true])
await value(
`try { JSON.parse('{"__proto__":1}', (key, item) => item) } catch (error) { return true } return false`,
),
).toBe(true)
})
test("JSON.stringify directly rejects blocked input keys", () => {
expect(() =>
invokeJsonMethod(
{
invokeFunction: () => Effect.die("unused"),
invokeCallable: () => Effect.die("unused"),
settlePromise: () => Effect.die("unused"),
},
"stringify",
[Object.fromEntries([["constructor", 1]])],
{ type: "CallExpression" },
),
).toThrow("blocked property 'constructor'")
})
})