From b63206c8d88b24c98affa016b4155a9550f2808f Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:29:08 +0530 Subject: [PATCH 1/8] feat: separate undefined from unsupported values JSON cannot represent undefined, functions or symbols, and JSON.stringify treats all three identically: omitted from an object, null in an array. That conflates a representable absence with an unsupported type, and it drops nested data silently, so embedded page data can be missing fields the author expected. - Top-level failures now name the category rather than sharing one message. A function replacer keeps the generic message, because the original value no longer explains the result. - Add the optional onUnsupported option. 'omit' is the default and preserves current behaviour exactly; 'throw' reports the value and its path (user.email, items[2].name) instead of dropping it. - Values are inspected after toJSON and after a function replacer, so a value that becomes unrepresentable is reported too. - onUnsupported: 'throw' rejects an array replacer, which omits properties by design and would give the combination a confusing meaning. The serializer API and all existing behaviour are unchanged. --- index.d.ts | 12 +++ index.mjs | 105 ++++++++++++++++++++++++- test/unsupported-values.test.mjs | 129 +++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 test/unsupported-values.test.mjs diff --git a/index.d.ts b/index.d.ts index 1421e1e..00bb827 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,9 +2,21 @@ export type InlineJsonReplacer = | ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[] +/** + * How to treat values JSON cannot represent (`undefined`, functions, symbols) + * below the top level. + * + * - `'omit'` (default) keeps native `JSON.stringify` behaviour: the property is + * omitted from an object and becomes `null` in an array. + * - `'throw'` reports the value and its path instead of dropping it. Requires a + * function replacer or no replacer. + */ +export type InlineJsonUnsupportedHandling = 'omit' | 'throw' + export interface SerializeInlineJsonOptions { replacer?: InlineJsonReplacer space?: number + onUnsupported?: InlineJsonUnsupportedHandling } export declare function serializeInlineJson( diff --git a/index.mjs b/index.mjs index 60e1abf..af594a4 100644 --- a/index.mjs +++ b/index.mjs @@ -6,6 +6,8 @@ const JSON_ESCAPES = Object.freeze({ '\u2029': '\\u2029', }) +const UNSUPPORTED_HANDLING = Object.freeze(['omit', 'throw']) + function assertOptions(options) { if (options === undefined) return {} @@ -25,21 +27,116 @@ function assertSpace(space) { } } +function assertUnsupported(onUnsupported) { + if ( + onUnsupported !== undefined && + !UNSUPPORTED_HANDLING.includes(onUnsupported) + ) { + throw new TypeError("Option onUnsupported must be 'omit' or 'throw' when provided.") + } +} + +/** + * Classify a value that JSON cannot represent. + * + * `undefined` is a representable absence; a function or symbol is an + * unsupported type. JSON.stringify treats both the same way — omitted in an + * object, `null` in an array — so the distinction is made here. + * + * @returns {'undefined' | 'function' | 'symbol' | null} null when representable + */ +function categorize(value) { + if (value === undefined) return 'undefined' + + const type = typeof value + if (type === 'function' || type === 'symbol') return type + + return null +} + +function childPath(holderPath, holder, key) { + if (Array.isArray(holder)) return `${holderPath}[${key}]` + return holderPath === '' ? key : `${holderPath}.${key}` +} + +/** + * Wrap a replacer so that values JSON cannot represent are reported with their + * location instead of being dropped. Only used when `onUnsupported` is + * `'throw'`; otherwise the caller's replacer is passed through untouched so + * native behaviour is preserved exactly. + */ +function strictReplacer(replacer) { + const paths = new WeakMap() + + return function trackingReplacer(key, entry) { + const next = replacer === undefined ? entry : replacer.call(this, key, entry) + + if (key === '') { + if (next !== null && typeof next === 'object') paths.set(next, '') + return next + } + + // Every holder reached here was registered before JSON.stringify recursed + // into it: the root is registered on the key === '' call above, and each + // nested object is registered below before it becomes a holder in turn. + const path = childPath(paths.get(this), this, key) + const category = categorize(next) + + if (category !== null) { + throw new TypeError( + `Cannot represent the ${category} value at ${path} as JSON; ` + + "remove it or serialize with onUnsupported: 'omit'.", + ) + } + + if (next !== null && typeof next === 'object') paths.set(next, path) + + return next + } +} + +function topLevelMessage(value, hasFunctionReplacer) { + if (hasFunctionReplacer) { + return 'The top-level value cannot be represented as JSON.' + } + + const category = value === undefined ? 'undefined' : typeof value + + return category === 'undefined' + ? 'The top-level value is undefined and cannot be represented as JSON.' + : `The top-level value is an unsupported ${category} and cannot be represented as JSON.` +} + /** * Serialize a value as JSON that can be placed in the raw-text content of an * HTML script element. * * @param {unknown} value - * @param {{ replacer?: ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[], space?: number }} [options] + * @param {{ replacer?: ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[], space?: number, onUnsupported?: 'omit' | 'throw' }} [options] * @returns {string} */ export function serializeInlineJson(value, options) { - const { replacer, space } = assertOptions(options) + const { replacer, space, onUnsupported } = assertOptions(options) assertSpace(space) - const serialized = JSON.stringify(value, replacer, space) + assertUnsupported(onUnsupported) + + const strict = onUnsupported === 'throw' + const arrayReplacer = Array.isArray(replacer) + + if (strict && arrayReplacer) { + throw new TypeError( + "Option onUnsupported: 'throw' requires a function replacer or no replacer, " + + 'because a property allowlist omits properties by design.', + ) + } + + const effectiveReplacer = strict ? strictReplacer(replacer) : replacer + const serialized = JSON.stringify(value, effectiveReplacer, space) if (serialized === undefined) { - throw new TypeError('The top-level value cannot be represented as JSON.') + throw new TypeError( + topLevelMessage(value, !arrayReplacer && typeof replacer === 'function'), + ) } return serialized.replace(/[<>&\u2028\u2029]/g, (character) => JSON_ESCAPES[character]) diff --git a/test/unsupported-values.test.mjs b/test/unsupported-values.test.mjs new file mode 100644 index 0000000..8b2abb3 --- /dev/null +++ b/test/unsupported-values.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { serializeInlineJson } from '../index.mjs' + +test('distinguishes an undefined top-level value from an unsupported type', () => { + assert.throws( + () => serializeInlineJson(undefined), + /top-level value is undefined/, + ) + assert.throws( + () => serializeInlineJson(Symbol('value')), + /top-level value is an unsupported symbol/, + ) + assert.throws( + () => serializeInlineJson(() => {}), + /top-level value is an unsupported function/, + ) +}) + +test('keeps the generic top-level message when a function replacer produced the gap', () => { + assert.throws( + () => serializeInlineJson({ a: 1 }, { replacer: () => undefined }), + /^TypeError: The top-level value cannot be represented as JSON\.$/, + ) +}) + +test('omits unrepresentable values by default, preserving native behaviour', () => { + const serialized = serializeInlineJson({ + kept: 1, + dropped: undefined, + fn() {}, + sym: Symbol('s'), + list: [1, undefined, 3], + }) + + assert.deepEqual(JSON.parse(serialized), { kept: 1, list: [1, null, 3] }) +}) + +test('omit is accepted explicitly and behaves like the default', () => { + const value = { kept: 1, dropped: undefined } + + assert.equal( + serializeInlineJson(value, { onUnsupported: 'omit' }), + serializeInlineJson(value), + ) +}) + +test('strict mode reports a dropped object property with its path', () => { + assert.throws( + () => serializeInlineJson({ a: { b: undefined } }, { onUnsupported: 'throw' }), + /Cannot represent the undefined value at a\.b as JSON/, + ) +}) + +test('strict mode reports an unsupported array element with its index', () => { + assert.throws( + () => serializeInlineJson({ list: [1, Symbol('s')] }, { onUnsupported: 'throw' }), + /Cannot represent the symbol value at list\[1\] as JSON/, + ) + assert.throws( + () => serializeInlineJson([() => {}], { onUnsupported: 'throw' }), + /Cannot represent the function value at \[0\] as JSON/, + ) +}) + +test('strict mode reports a top-level property without a leading separator', () => { + assert.throws( + () => serializeInlineJson({ missing: undefined }, { onUnsupported: 'throw' }), + /value at missing as JSON/, + ) +}) + +test('strict mode inspects values after toJSON and after a function replacer', () => { + assert.throws( + () => + serializeInlineJson( + { wrapped: { toJSON: () => undefined } }, + { onUnsupported: 'throw' }, + ), + /undefined value at wrapped as JSON/, + ) + + assert.throws( + () => + serializeInlineJson( + { a: 1 }, + { + onUnsupported: 'throw', + replacer(key, entry) { + return key === 'a' ? undefined : entry + }, + }, + ), + /undefined value at a as JSON/, + ) +}) + +test('strict mode passes representable data through unchanged and still escapes', () => { + const value = { nested: { list: [1, ''], ok: null } } + const strict = serializeInlineJson(value, { onUnsupported: 'throw' }) + + assert.equal(strict, serializeInlineJson(value)) + assert.doesNotMatch(strict, /[<>&\u2028\u2029]/u) + assert.deepEqual(JSON.parse(strict), value) +}) + +test('strict mode accepts a non-object top-level value', () => { + assert.equal(serializeInlineJson('plain', { onUnsupported: 'throw' }), '"plain"') + assert.equal(serializeInlineJson(7, { onUnsupported: 'throw' }), '7') +}) + +test('strict mode rejects an array replacer, which omits properties by design', () => { + assert.throws( + () => serializeInlineJson({ a: 1 }, { replacer: ['a'], onUnsupported: 'throw' }), + /requires a function replacer or no replacer/, + ) +}) + +test('rejects an unknown onUnsupported value', () => { + assert.throws( + () => serializeInlineJson({}, { onUnsupported: 'ignore' }), + /must be 'omit' or 'throw'/, + ) + assert.throws( + () => serializeInlineJson({}, { onUnsupported: null }), + /must be 'omit' or 'throw'/, + ) +}) From b5bc332b82a50b40fd020d0c5072f3fc1bdf1e12 Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:29:08 +0530 Subject: [PATCH 2/8] test: round-trip embedded values through an HTML parse Existing tests round-trip through JSON.parse, which never exercises the reason this package escapes < in the first place. Add a fixture that implements the HTML raw-text end-tag rule -- -- and reads the value back out of a rendered page. Covers nested Unicode (combining marks, astral planes, RTL scripts, U+2028 and U+2029) and confirms a hostile payload cannot terminate its containing script element or disturb the markup after it. The fixture is a test helper implementing one spec rule, not a general HTML parser, and is not shipped in the package. --- test/html-embedding.test.mjs | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 test/html-embedding.test.mjs diff --git a/test/html-embedding.test.mjs b/test/html-embedding.test.mjs new file mode 100644 index 0000000..eaa7a71 --- /dev/null +++ b/test/html-embedding.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { serializeInlineJson } from '../index.mjs' + +/** + * Minimal raw-text extractor used as a local parser fixture. + * + * A script element's content is raw text: it ends at the first ``, case-insensitively. That rule is the + * whole reason this package escapes `<`, so the fixture implements exactly it + * rather than depending on a full HTML parser. It is a test fixture, not a + * general-purpose HTML parser. + */ +function readScriptData(html, id) { + const opening = new RegExp(`]*\\bid="${id}"[^>]*>`, 'i').exec(html) + assert.ok(opening, `no script element with id "${id}"`) + + const body = html.slice(opening.index + opening[0].length) + const end = /<\/script[\s/>]/i.exec(body) + assert.ok(end, 'script element was never closed') + + return { data: body.slice(0, end.index), remainder: body.slice(end.index) } +} + +function renderPage(value, options) { + return ` + + t + + +

after

+ +` +} + +test('round-trips nested Unicode data through an HTML parse', () => { + const value = { + title: 'Edilec — outils', + emoji: '🛠️', + scripts: ['日本語', 'Ελληνικά', 'العربية', 'עברית'], + nested: { deep: { combining: 'é', astral: '𝔘𝔫𝔦' } }, + separators: 'one\u2028two\u2029three', + } + + const { data } = readScriptData(renderPage(value, { space: 2 }), 'page-data') + + assert.deepEqual(JSON.parse(data), value) +}) + +test('a hostile payload cannot terminate its containing script element', () => { + const value = { + a: '', + b: '', + c: '-->', + '': 'in a key too', + } + + const html = renderPage(value) + const { data, remainder } = readScriptData(html, 'page-data') + + // The element ends exactly once, at the real closing tag we emitted. + assert.ok(remainder.startsWith('')) + assert.equal(html.match(/<\/script[\s/>]/gi).length, 1) + + // And the payload survives intact. + assert.deepEqual(JSON.parse(data), value) +}) + +test('the document structure after the script element is unaffected', () => { + const { remainder } = readScriptData( + renderPage({ escape: '

injected

' }), + 'page-data', + ) + + assert.equal(remainder.match(/id="after"/g).length, 1) +}) From 90fa313841a71e3edfa22016dbd60165531076df Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:29:08 +0530 Subject: [PATCH 3/8] docs: document the supported-value contract Add a supported-values table and an onUnsupported section covering the silent data loss that the default behaviour inherits from JSON.stringify, and record the changes in the changelog. --- CHANGELOG.md | 11 +++++++++++ README.md | 44 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a4faf0..9ec49c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented in this file. ## Unreleased +- Separate `undefined` from unsupported types when a top-level value cannot be + represented as JSON, so the error names the category instead of using one + shared message. +- Add the optional `onUnsupported` option. `'omit'` is the default and keeps + existing behavior exactly; `'throw'` reports an unrepresentable nested value + and its path rather than silently dropping it. +- Document the supported-value contract in the README. +- Add an HTML raw-text parse fixture that extracts the embedded value back out + of a rendered page, covering Unicode round-trips and confirming a hostile + payload cannot terminate its containing script element. + ## 0.1.1 - 2026-08-24 - Expand the threat model, compatibility guidance, context matrix, examples, diff --git a/README.md b/README.md index 7252999..1aadd62 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,46 @@ serializeInlineJson(value, { Options follow native `JSON.stringify` behavior with one deliberate restriction: `space` must be an integer from 0 through 10. A top-level value -that cannot be represented as JSON throws instead of returning `undefined`. +that cannot be represented as JSON throws instead of returning `undefined`, and +the error distinguishes an `undefined` value from an unsupported type. + +### Supported values + +| Input | Result | +| --- | --- | +| Object, array, string, finite number, boolean, `null` | Serialized | +| `Date`, or any value with `toJSON` | Serialized via `toJSON` | +| Non-finite number (`NaN`, `±Infinity`) | `null` | +| `undefined`, function, symbol — top level | Throws, naming the category | +| `undefined`, function, symbol — nested | See `onUnsupported` below | +| `BigInt` | Throws (native) | +| Cyclic structure | Throws (native) | + +### `onUnsupported` + +JSON cannot represent `undefined`, functions or symbols. Native +`JSON.stringify` drops such a property from an object and turns such an element +into `null` in an array — silently, so embedded page data can end up missing +fields the author expected to be there. + +- `'omit'` (default) keeps that native behavior exactly. +- `'throw'` reports the value and its path instead: + +```js +serializeInlineJson({ user: { id: 1, email: undefined } }, { + onUnsupported: 'throw', +}) +// TypeError: Cannot represent the undefined value at user.email as JSON; +// remove it or serialize with onUnsupported: 'omit'. +``` + +Paths use dots for properties and brackets for array indices (`items[2].name`). +Values are inspected after `toJSON` and after a function replacer, so a value +that *becomes* unrepresentable is reported too. + +`onUnsupported: 'throw'` requires a function replacer or no replacer. An array +replacer is a property allowlist that omits properties by design, so combining +the two is rejected rather than given a confusing meaning. ## What it escapes @@ -117,7 +156,8 @@ The detailed security assumptions and abuse cases are in ## Native JSON behavior retained - Cyclic values and `BigInt` values throw. -- Unsupported object properties are omitted. +- Unsupported object properties are omitted by default; set + `onUnsupported: 'throw'` to be told about them instead. - Non-finite numbers become `null`. - Getters, `toJSON`, and replacer callbacks execute normally. - A replacer or `toJSON` implementation can have side effects; this package From 191360b5183024558000844934a243e32998b326 Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:59:59 +0530 Subject: [PATCH 4/8] Inspect empty-name properties in strict JSON mode --- index.mjs | 6 +++++- test/unsupported-values.test.mjs | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/index.mjs b/index.mjs index af594a4..15dece1 100644 --- a/index.mjs +++ b/index.mjs @@ -67,11 +67,15 @@ function childPath(holderPath, holder, key) { */ function strictReplacer(replacer) { const paths = new WeakMap() + let rootSeen = false return function trackingReplacer(key, entry) { const next = replacer === undefined ? entry : replacer.call(this, key, entry) - if (key === '') { + // JSON.stringify also permits an ordinary property named ''. Only its + // first callback is the wrapper root, regardless of later property keys. + if (!rootSeen) { + rootSeen = true if (next !== null && typeof next === 'object') paths.set(next, '') return next } diff --git a/test/unsupported-values.test.mjs b/test/unsupported-values.test.mjs index 8b2abb3..978b818 100644 --- a/test/unsupported-values.test.mjs +++ b/test/unsupported-values.test.mjs @@ -71,6 +71,18 @@ test('strict mode reports a top-level property without a leading separator', () ) }) +test('strict mode inspects legal empty-name properties at root and nested levels', () => { + assert.equal(serializeInlineJson({ '': 1 }, { onUnsupported: 'throw' }), '{"":1}') + assert.throws( + () => serializeInlineJson({ '': undefined }, { onUnsupported: 'throw' }), + /Cannot represent the undefined value/, + ) + assert.throws( + () => serializeInlineJson({ outer: { '': undefined } }, { onUnsupported: 'throw' }), + /Cannot represent the undefined value/, + ) +}) + test('strict mode inspects values after toJSON and after a function replacer', () => { assert.throws( () => From a5ce52d7ff53e437f27865d86036d16d25cd5152 Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:00:53 +0530 Subject: [PATCH 5/8] Reject unknown serializer options before omission --- README.md | 7 +++++-- index.mjs | 26 ++++++++++++++++++++++++-- test/unsupported-values.test.mjs | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1aadd62..ef1bfc9 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,11 @@ serializeInlineJson(value, { }) ``` -Options follow native `JSON.stringify` behavior with one deliberate -restriction: `space` must be an integer from 0 through 10. A top-level value +Options are a plain object with only `replacer`, `space`, and +`onUnsupported` as own enumerable data properties; unknown or inherited +options are rejected rather than silently changing the omission policy. +The supported replacer and spacing behavior follows native `JSON.stringify`, +except `space` must be an integer from 0 through 10. A top-level value that cannot be represented as JSON throws instead of returning `undefined`, and the error distinguishes an `undefined` value from an unsupported type. diff --git a/index.mjs b/index.mjs index 15dece1..e42e9cd 100644 --- a/index.mjs +++ b/index.mjs @@ -7,6 +7,7 @@ const JSON_ESCAPES = Object.freeze({ }) const UNSUPPORTED_HANDLING = Object.freeze(['omit', 'throw']) +const OPTION_KEYS = Object.freeze(['replacer', 'space', 'onUnsupported']) function assertOptions(options) { if (options === undefined) return {} @@ -14,8 +15,29 @@ function assertOptions(options) { if (options === null || typeof options !== 'object' || Array.isArray(options)) { throw new TypeError('Options must be an object when provided.') } - - return options + let keys + let descriptors + try { + if (![Object.prototype, null].includes(Object.getPrototypeOf(options))) { + throw new TypeError('Options must be a plain object.') + } + keys = Reflect.ownKeys(options) + descriptors = Object.getOwnPropertyDescriptors(options) + } catch { + throw new TypeError('Options must be a plain object with data properties.') + } + const selected = {} + for (const key of keys) { + if (typeof key !== 'string' || !OPTION_KEYS.includes(key)) { + throw new TypeError('Unknown option.') + } + const descriptor = descriptors[key] + if (!Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) { + throw new TypeError('Options must use enumerable data properties.') + } + selected[key] = descriptor.value + } + return selected } function assertSpace(space) { diff --git a/test/unsupported-values.test.mjs b/test/unsupported-values.test.mjs index 978b818..b1ad22b 100644 --- a/test/unsupported-values.test.mjs +++ b/test/unsupported-values.test.mjs @@ -139,3 +139,29 @@ test('rejects an unknown onUnsupported value', () => { /must be 'omit' or 'throw'/, ) }) + +test('unknown option names cannot silently turn strict omission into a pass', () => { + const value = { known: undefined } + assert.throws(() => serializeInlineJson(value, { onUnsupported: 'throw' }), TypeError) + assert.throws( + () => serializeInlineJson(value, { onUnsupproted: 'throw' }), + /Unknown option/, + ) + const canary = 'token=SYNTHETIC_SECRET_CANARY' + assert.throws( + () => serializeInlineJson(value, { [canary]: 'throw' }), + error => error instanceof TypeError && !error.message.includes(canary), + ) + assert.throws( + () => serializeInlineJson(value, { [Symbol('hidden')]: true }), + /Unknown option/, + ) + const inherited = Object.create({ onUnsupported: 'throw' }) + assert.throws(() => serializeInlineJson(value, inherited), /plain object/) + let getterCalled = false + const accessor = { get onUnsupported() { getterCalled = true; return 'throw' } } + assert.throws(() => serializeInlineJson(value, accessor), /data properties/) + assert.equal(getterCalled, false) + const plain = Object.assign(Object.create(null), { onUnsupported: 'throw' }) + assert.throws(() => serializeInlineJson(value, plain), TypeError) +}) From da14ac46e66c65ed0bca23ccebff380d5180dead Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:04:30 +0530 Subject: [PATCH 6/8] Use positional strict diagnostics without echoing keys --- README.md | 10 ++++++--- index.mjs | 22 +++++++++++++------ test/unsupported-values.test.mjs | 37 ++++++++++++++++++++++++++------ 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ef1bfc9..745b89e 100644 --- a/README.md +++ b/README.md @@ -110,17 +110,21 @@ into `null` in an array — silently, so embedded page data can end up missing fields the author expected to be there. - `'omit'` (default) keeps that native behavior exactly. -- `'throw'` reports the value and its path instead: +- `'throw'` reports the value and its source position instead: ```js serializeInlineJson({ user: { id: 1, email: undefined } }, { onUnsupported: 'throw', }) -// TypeError: Cannot represent the undefined value at user.email as JSON; +// TypeError: Cannot represent the undefined value at property[0].property[1] as JSON; // remove it or serialize with onUnsupported: 'omit'. ``` -Paths use dots for properties and brackets for array indices (`items[2].name`). +Object positions are zero-based in `JSON.stringify` visitation order; array +positions keep their numeric indices (for example, `property[0][2]`). Raw +property names are never copied into diagnostics, because they may contain +control characters or private data. Distinct object and array locations remain +distinguishable. Values are inspected after `toJSON` and after a function replacer, so a value that *becomes* unrepresentable is reported too. diff --git a/index.mjs b/index.mjs index e42e9cd..e63c908 100644 --- a/index.mjs +++ b/index.mjs @@ -76,9 +76,10 @@ function categorize(value) { return null } -function childPath(holderPath, holder, key) { - if (Array.isArray(holder)) return `${holderPath}[${key}]` - return holderPath === '' ? key : `${holderPath}.${key}` +function childPath(holderPath, holder, key, ordinal) { + if (Array.isArray(holder)) return `${holderPath}[${Number(key)}]` + const position = `property[${ordinal}]` + return holderPath === '' ? position : `${holderPath}.${position}` } /** @@ -89,6 +90,7 @@ function childPath(holderPath, holder, key) { */ function strictReplacer(replacer) { const paths = new WeakMap() + const nextOrdinals = new WeakMap() let rootSeen = false return function trackingReplacer(key, entry) { @@ -98,14 +100,19 @@ function strictReplacer(replacer) { // first callback is the wrapper root, regardless of later property keys. if (!rootSeen) { rootSeen = true - if (next !== null && typeof next === 'object') paths.set(next, '') + if (next !== null && typeof next === 'object') { + paths.set(next, '') + nextOrdinals.set(next, 0) + } return next } // Every holder reached here was registered before JSON.stringify recursed // into it: the root is registered on the key === '' call above, and each // nested object is registered below before it becomes a holder in turn. - const path = childPath(paths.get(this), this, key) + const ordinal = nextOrdinals.get(this) + nextOrdinals.set(this, ordinal + 1) + const path = childPath(paths.get(this), this, key, ordinal) const category = categorize(next) if (category !== null) { @@ -115,7 +122,10 @@ function strictReplacer(replacer) { ) } - if (next !== null && typeof next === 'object') paths.set(next, path) + if (next !== null && typeof next === 'object') { + paths.set(next, path) + nextOrdinals.set(next, 0) + } return next } diff --git a/test/unsupported-values.test.mjs b/test/unsupported-values.test.mjs index b1ad22b..b497bec 100644 --- a/test/unsupported-values.test.mjs +++ b/test/unsupported-values.test.mjs @@ -46,17 +46,17 @@ test('omit is accepted explicitly and behaves like the default', () => { ) }) -test('strict mode reports a dropped object property with its path', () => { +test('strict mode reports a dropped object property by source position', () => { assert.throws( () => serializeInlineJson({ a: { b: undefined } }, { onUnsupported: 'throw' }), - /Cannot represent the undefined value at a\.b as JSON/, + /Cannot represent the undefined value at property\[0\]\.property\[0\] as JSON/, ) }) test('strict mode reports an unsupported array element with its index', () => { assert.throws( () => serializeInlineJson({ list: [1, Symbol('s')] }, { onUnsupported: 'throw' }), - /Cannot represent the symbol value at list\[1\] as JSON/, + /Cannot represent the symbol value at property\[0\]\[1\] as JSON/, ) assert.throws( () => serializeInlineJson([() => {}], { onUnsupported: 'throw' }), @@ -64,10 +64,10 @@ test('strict mode reports an unsupported array element with its index', () => { ) }) -test('strict mode reports a top-level property without a leading separator', () => { +test('strict mode reports a top-level property position without a leading separator', () => { assert.throws( () => serializeInlineJson({ missing: undefined }, { onUnsupported: 'throw' }), - /value at missing as JSON/, + /value at property\[0\] as JSON/, ) }) @@ -90,7 +90,7 @@ test('strict mode inspects values after toJSON and after a function replacer', ( { wrapped: { toJSON: () => undefined } }, { onUnsupported: 'throw' }, ), - /undefined value at wrapped as JSON/, + /undefined value at property\[0\] as JSON/, ) assert.throws( @@ -104,7 +104,7 @@ test('strict mode inspects values after toJSON and after a function replacer', ( }, }, ), - /undefined value at a as JSON/, + /undefined value at property\[0\] as JSON/, ) }) @@ -165,3 +165,26 @@ test('unknown option names cannot silently turn strict omission into a pass', () const plain = Object.assign(Object.create(null), { onUnsupported: 'throw' }) assert.throws(() => serializeInlineJson(value, plain), TypeError) }) + +test('strict diagnostics locate properties without echoing or conflating raw keys', () => { + const messageFor = value => { + try { + serializeInlineJson(value, { onUnsupported: 'throw' }) + assert.fail('strict serialization should refuse an unsupported value') + } catch (error) { + assert.ok(error instanceof TypeError) + return error.message + } + } + for (const key of ['a\nb', `a${String.fromCodePoint(0x202e)}b`, + 'token=SYNTHETIC_SECRET_CANARY']) { + const message = messageFor({ [key]: undefined }) + assert.equal(message.includes(key), false) + assert.equal(message.includes('\n'), false) + assert.equal(message.includes(String.fromCodePoint(0x202e)), false) + assert.equal(message.includes('SYNTHETIC_SECRET_CANARY'), false) + assert.match(message, /property\[0\]/) + } + assert.notEqual(messageFor({ 'a.b': undefined }), messageFor({ a: { b: undefined } })) + assert.notEqual(messageFor({ 'items[0]': undefined }), messageFor({ items: [undefined] })) +}) From 370a9c8599bac23ac94c67192be77117901477c8 Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:10:19 +0530 Subject: [PATCH 7/8] Keep native serialization failures value-free --- CHANGELOG.md | 10 ++++++++-- README.md | 11 ++++++++--- docs/architecture.md | 7 +++++-- docs/threat-model.md | 7 +++++++ index.mjs | 15 +++++++++++++-- test/index.test.mjs | 39 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 79 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec49c0..2f274c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,18 @@ All notable changes to this project are documented in this file. ## Unreleased +- Replace native and callback serialization error messages with a value-free + `TypeError` so a cyclic key or callback exception cannot copy private input + into diagnostics. Strict unsupported-value errors retain positional detail. +- Reject unknown option names and report unsupported nested object properties + by source position, including legal empty-name properties. - Separate `undefined` from unsupported types when a top-level value cannot be represented as JSON, so the error names the category instead of using one shared message. - Add the optional `onUnsupported` option. `'omit'` is the default and keeps - existing behavior exactly; `'throw'` reports an unrepresentable nested value - and its path rather than silently dropping it. + existing successful-serialization behavior; `'throw'` reports an + unrepresentable nested value and its source position rather than silently + dropping it. - Document the supported-value contract in the README. - Add an HTML raw-text parse fixture that extracts the embedded value back out of a rendered page, covering Unicode round-trips and confirming a hostile diff --git a/README.md b/README.md index 745b89e..34d7f28 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,8 @@ the error distinguishes an `undefined` value from an unsupported type. | Non-finite number (`NaN`, `±Infinity`) | `null` | | `undefined`, function, symbol — top level | Throws, naming the category | | `undefined`, function, symbol — nested | See `onUnsupported` below | -| `BigInt` | Throws (native) | -| Cyclic structure | Throws (native) | +| `BigInt` | Throws a value-free `TypeError` | +| Cyclic structure | Throws a value-free `TypeError` | ### `onUnsupported` @@ -109,7 +109,8 @@ JSON cannot represent `undefined`, functions or symbols. Native into `null` in an array — silently, so embedded page data can end up missing fields the author expected to be there. -- `'omit'` (default) keeps that native behavior exactly. +- `'omit'` (default) keeps that native omission behavior for successful + serialization. - `'throw'` reports the value and its source position instead: ```js @@ -169,6 +170,10 @@ The detailed security assumptions and abuse cases are in - Getters, `toJSON`, and replacer callbacks execute normally. - A replacer or `toJSON` implementation can have side effects; this package does not isolate user code. +- Errors thrown during serialization are replaced with a value-free `TypeError` + unless they are this package's own positional strict-mode diagnostic. Native + cycle errors can otherwise echo private property names, and callback errors + can carry arbitrary input. The original error and cause are not attached. ## Architecture diff --git a/docs/architecture.md b/docs/architecture.md index 1175158..b3460cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,8 +21,11 @@ and the compatibility contract without improving the supported use case. - Invalid options fail before serialization. - Unsupported top-level values throw instead of returning `undefined`. -- Native failures for cycles and `BigInt` are preserved. -- Replacer, getter, or `toJSON` exceptions propagate unchanged. +- Cycles and `BigInt` still fail with `TypeError`, but their native messages are + replaced with a value-free diagnostic because native cycle errors can name + private properties. +- Replacer, getter, or `toJSON` exceptions are also replaced with a value-free + `TypeError`; callbacks still execute and can have side effects. ## Release boundary diff --git a/docs/threat-model.md b/docs/threat-model.md index 191fdfb..ff9a11a 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -52,6 +52,13 @@ with a JSON Unicode escape. Because the serialized output contains no literal The escapes remain valid JSON and reconstruct the original characters when parsed. +Serialization failures are also a report surface. Native cycle errors may +include raw property names, and getters, `toJSON`, or replacer callbacks may +throw messages containing arbitrary data. Except for this package's own +source-position-only strict diagnostic, such errors are replaced with a +value-free `TypeError` without the original message or cause. This does not +stop callbacks from executing or undo their side effects. + ## Trust assumptions - The surrounding HTML element and its attributes are created safely. diff --git a/index.mjs b/index.mjs index e63c908..2f35cce 100644 --- a/index.mjs +++ b/index.mjs @@ -9,6 +9,8 @@ const JSON_ESCAPES = Object.freeze({ const UNSUPPORTED_HANDLING = Object.freeze(['omit', 'throw']) const OPTION_KEYS = Object.freeze(['replacer', 'space', 'onUnsupported']) +class UnsupportedValueError extends TypeError {} + function assertOptions(options) { if (options === undefined) return {} @@ -116,7 +118,7 @@ function strictReplacer(replacer) { const category = categorize(next) if (category !== null) { - throw new TypeError( + throw new UnsupportedValueError( `Cannot represent the ${category} value at ${path} as JSON; ` + "remove it or serialize with onUnsupported: 'omit'.", ) @@ -167,7 +169,16 @@ export function serializeInlineJson(value, options) { } const effectiveReplacer = strict ? strictReplacer(replacer) : replacer - const serialized = JSON.stringify(value, effectiveReplacer, space) + let serialized + try { + serialized = JSON.stringify(value, effectiveReplacer, space) + } catch (error) { + // Native cycle diagnostics can contain raw object keys; callbacks may + // throw messages containing arbitrary input too. Preserve only our own + // positional strict diagnostic, never the original message or cause. + if (error instanceof UnsupportedValueError) throw error + throw new TypeError('JSON serialization failed; inspect the input and callbacks locally.') + } if (serialized === undefined) { throw new TypeError( diff --git a/test/index.test.mjs b/test/index.test.mjs index 76e0fbf..62b3080 100644 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -109,10 +109,47 @@ test('rejects an unsupported top-level value', () => { assert.throws(() => serializeInlineJson(() => {}), /top-level value/) }) -test('retains native JSON.stringify failures for BigInt and cyclic data', () => { +test('rejects BigInt and cyclic data with TypeError', () => { assert.throws(() => serializeInlineJson(1n), TypeError) const cyclic = {} cyclic.self = cyclic assert.throws(() => serializeInlineJson(cyclic), TypeError) }) + +test('serialization failures do not echo cyclic property names in either mode', () => { + const canary = 'SYNTHETIC_SECRET_CANARY' + const hidden = String.fromCodePoint(0x202e) + const cyclic = {} + cyclic[`token=${canary}${hidden}`] = cyclic + + for (const options of [undefined, { onUnsupported: 'throw' }]) { + assert.throws(() => serializeInlineJson(cyclic, options), (error) => { + assert.ok(error instanceof TypeError) + assert.ok(error.message.length > 0) + assert.equal(String(error.stack).includes(canary), false) + assert.equal(String(error.stack).includes(hidden), false) + return true + }) + } +}) + +test('getter and replacer exceptions do not echo their private messages', () => { + const canary = 'SYNTHETIC_SECRET_CANARY' + const withGetter = { get value() { throw new Error(canary) } } + const throwingReplacer = () => { throw new Error(canary) } + + for (const attempt of [ + () => serializeInlineJson(withGetter), + () => serializeInlineJson(withGetter, { onUnsupported: 'throw' }), + () => serializeInlineJson({ value: 1 }, { replacer: throwingReplacer }), + () => serializeInlineJson({ value: 1 }, { replacer: throwingReplacer, onUnsupported: 'throw' }), + ]) { + assert.throws(attempt, (error) => { + assert.ok(error instanceof TypeError) + assert.ok(error.message.length > 0) + assert.equal(String(error.stack).includes(canary), false) + return true + }) + } +}) From 50025ce9eb3f7618f267fffdd7517258f10c72f5 Mon Sep 17 00:00:00 2001 From: Krishnam Murarka <83580811+KRISHNAMMurarka@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:14:41 +0530 Subject: [PATCH 8/8] Trust only current-call strict errors --- index.mjs | 21 +++++++++++++++------ test/index.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/index.mjs b/index.mjs index 2f35cce..57e9ed9 100644 --- a/index.mjs +++ b/index.mjs @@ -9,8 +9,6 @@ const JSON_ESCAPES = Object.freeze({ const UNSUPPORTED_HANDLING = Object.freeze(['omit', 'throw']) const OPTION_KEYS = Object.freeze(['replacer', 'space', 'onUnsupported']) -class UnsupportedValueError extends TypeError {} - function assertOptions(options) { if (options === undefined) return {} @@ -90,7 +88,7 @@ function childPath(holderPath, holder, key, ordinal) { * `'throw'`; otherwise the caller's replacer is passed through untouched so * native behaviour is preserved exactly. */ -function strictReplacer(replacer) { +function strictReplacer(replacer, noteOwnError) { const paths = new WeakMap() const nextOrdinals = new WeakMap() let rootSeen = false @@ -118,10 +116,12 @@ function strictReplacer(replacer) { const category = categorize(next) if (category !== null) { - throw new UnsupportedValueError( + const error = new TypeError( `Cannot represent the ${category} value at ${path} as JSON; ` + "remove it or serialize with onUnsupported: 'omit'.", ) + noteOwnError(error) + throw error } if (next !== null && typeof next === 'object') { @@ -168,7 +168,14 @@ export function serializeInlineJson(value, options) { ) } - const effectiveReplacer = strict ? strictReplacer(replacer) : replacer + let ownError = null + let hasOwnError = false + const effectiveReplacer = strict + ? strictReplacer(replacer, error => { + ownError = error + hasOwnError = true + }) + : replacer let serialized try { serialized = JSON.stringify(value, effectiveReplacer, space) @@ -176,7 +183,9 @@ export function serializeInlineJson(value, options) { // Native cycle diagnostics can contain raw object keys; callbacks may // throw messages containing arbitrary input too. Preserve only our own // positional strict diagnostic, never the original message or cause. - if (error instanceof UnsupportedValueError) throw error + // Match the exact error created by this call: callbacks can throw a + // fabricated TypeError or even reuse a mutated error from an earlier call. + if (hasOwnError && error === ownError) throw error throw new TypeError('JSON serialization failed; inspect the input and callbacks locally.') } diff --git a/test/index.test.mjs b/test/index.test.mjs index 62b3080..8221bc0 100644 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -144,6 +144,7 @@ test('getter and replacer exceptions do not echo their private messages', () => () => serializeInlineJson(withGetter, { onUnsupported: 'throw' }), () => serializeInlineJson({ value: 1 }, { replacer: throwingReplacer }), () => serializeInlineJson({ value: 1 }, { replacer: throwingReplacer, onUnsupported: 'throw' }), + () => serializeInlineJson({ value: 1 }, { replacer: () => { throw null }, onUnsupported: 'throw' }), ]) { assert.throws(attempt, (error) => { assert.ok(error instanceof TypeError) @@ -153,3 +154,28 @@ test('getter and replacer exceptions do not echo their private messages', () => }) } }) + +test('a callback cannot forge or reuse a prior strict error to expose private text', () => { + let prior + assert.throws(() => serializeInlineJson({ missing: undefined }, { onUnsupported: 'throw' }), (error) => { + prior = error + return true + }) + const canary = 'SYNTHETIC_SECRET_CANARY' + const fabricated = new prior.constructor(canary) + prior.message = canary + + for (const thrown of [fabricated, prior]) { + for (const options of [ + { replacer: () => { throw thrown } }, + { replacer: () => { throw thrown }, onUnsupported: 'throw' }, + ]) { + assert.throws(() => serializeInlineJson({ value: 1 }, options), (error) => { + assert.ok(error instanceof TypeError) + assert.ok(error.message.length > 0) + assert.equal(String(error.stack).includes(canary), false) + return true + }) + } + } +})