diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a4faf0..2f274c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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 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 + 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..34d7f28 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,56 @@ 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`. +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. + +### 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 a value-free `TypeError` | +| Cyclic structure | Throws a value-free `TypeError` | + +### `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 omission behavior for successful + serialization. +- `'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 property[0].property[1] as JSON; +// remove it or serialize with onUnsupported: 'omit'. +``` + +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. + +`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,11 +164,16 @@ 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 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.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..57e9ed9 100644 --- a/index.mjs +++ b/index.mjs @@ -6,14 +6,38 @@ const JSON_ESCAPES = Object.freeze({ '\u2029': '\\u2029', }) +const UNSUPPORTED_HANDLING = Object.freeze(['omit', 'throw']) +const OPTION_KEYS = Object.freeze(['replacer', 'space', 'onUnsupported']) + function assertOptions(options) { if (options === undefined) return {} 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) { @@ -25,21 +49,150 @@ 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, ordinal) { + if (Array.isArray(holder)) return `${holderPath}[${Number(key)}]` + const position = `property[${ordinal}]` + return holderPath === '' ? position : `${holderPath}.${position}` +} + +/** + * 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, noteOwnError) { + const paths = new WeakMap() + const nextOrdinals = new WeakMap() + let rootSeen = false + + return function trackingReplacer(key, entry) { + const next = replacer === undefined ? entry : replacer.call(this, key, entry) + + // 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, '') + 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 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) { + 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') { + paths.set(next, path) + nextOrdinals.set(next, 0) + } + + 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.', + ) + } + + 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) + } 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. + // 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.') + } 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/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(` +
after
+