diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d2120f..14144766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to **Inkspan** (`@contextualwisdomlab/cwl-editor`) are docum ## [Unreleased] +## [0.5.19] — 2026-08-04 + +### Changed +- Package version **0.5.19** +- Raw JSON text and decoded UTF-8 envelopes now enforce configured value-count and nesting ceilings with an explicit stack before native `JSON.parse()` materializes the object graph +- The scanner reserves the fixed envelope wrapper allowance so `maxJsonValues` and `maxNestingDepth` continue to describe `documentJson` + +### Security +- Pathological wide or deep JSON fails before full native-parser allocation while post-parse cloning retains the same limits as defense in depth +- Duplicate-name rejection, malformed-JSON handling, redacted errors, strict UTF-8 decoding, and CWL/naruon transport and tenant boundaries remain unchanged + +### Tests +- Added exact-limit, raw value-count, scalar/container depth, duplicate, malformed, and parser-integration coverage under the repository-wide 100% TypeScript coverage gate + +### Documentation +- Clarified preparse enforcement, fixed wrapper allowances, defense-in-depth cloning, and the distinction between syntax errors and resource-limit rejection + + ## [0.5.18] — 2026-08-04 ### Added diff --git a/docs/document-envelope.md b/docs/document-envelope.md index ae20f727..13b2b92e 100644 --- a/docs/document-envelope.md +++ b/docs/document-envelope.md @@ -108,10 +108,17 @@ message, compressed-body, and object-store limits before the library boundary. OWASP likewise recommends total request-size limits and avoiding unbounded input-driven resource allocation. -The value and array-width checks run before recursively materializing child -values. Object reflection reads descriptors without invoking getters. Limit -failures use `DocumentEnvelopeError` and do not include source strings, object -names, inline image bytes, or tenant identifiers. +For JSON text and decoded UTF-8 bytes, the iterative duplicate-name scanner also +counts every scalar and container and tracks value depth before `JSON.parse()`. +The fixed three-value and one-depth envelope wrapper allowance keeps configured +limits scoped to `documentJson`. Oversized raw structures therefore fail before +the native parser allocates their complete object graph. Syntax-invalid input +that remains inside the ceilings is still reported by the canonical parser. + +The post-parse value and array-width checks remain as defense in depth before +recursively cloning child values. Object reflection reads descriptors without +invoking getters. Limit failures use `DocumentEnvelopeError` and do not include +source strings, object names, inline image bytes, or tenant identifiers. ## Validation and safety diff --git a/package.json b/package.json index 10fc724d..dd043cea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@contextualwisdomlab/cwl-editor", - "version": "0.5.18", - "description": "Inkspan — commercial-grade Markdown + HTML WYSIWYG editor module (TipTap/ProseMirror, MIT) with SSR-safe client hydration, native form integration, lossless document snapshots, resource-bounded duplicate-name-safe versioned persistence envelopes, canonical JSON plus strict UTF-8 byte round trips, schema-checked JSON restore and insertion, host-owned lifecycle callbacks, strict link and image policies, accessible editing controls, provider-neutral Yjs collaboration, a standalone base64 converter, and bundled offline multilingual Noto Sans fonts.", + "version": "0.5.19", + "description": "Inkspan — commercial-grade Markdown + HTML WYSIWYG editor module (TipTap/ProseMirror, MIT) with SSR-safe client hydration, native form integration, lossless document snapshots, preparse-resource-bounded duplicate-name-safe versioned persistence envelopes, canonical JSON plus strict UTF-8 byte round trips, schema-checked JSON restore and insertion, host-owned lifecycle callbacks, strict link and image policies, accessible editing controls, provider-neutral Yjs collaboration, a standalone base64 converter, and bundled offline multilingual Noto Sans fonts.", "license": "MIT", "author": "ContextualWisdomLab", "type": "module", diff --git a/src/documentEnvelope.ts b/src/documentEnvelope.ts index 82d8d273..b76311fc 100644 --- a/src/documentEnvelope.ts +++ b/src/documentEnvelope.ts @@ -1,5 +1,5 @@ import type { JSONContent } from '@tiptap/core'; -import { containsDuplicateJsonObjectNames } from './jsonObjectNameScanner.js'; +import { inspectJsonText } from './jsonObjectNameScanner.js'; /** Canonical identifier for Inkspan's first portable document envelope. */ export const DOCUMENT_ENVELOPE_SCHEMA_ID = @@ -182,11 +182,31 @@ function parseDocumentEnvelopeWithLimits( 'Document envelope JSON text exceeds the supported length', ); } - if (containsDuplicateJsonObjectNames(source)) { + const inspection = inspectJsonText(source, { + maxValues: Math.min( + Number.MAX_SAFE_INTEGER, + resolvedLimits.maxJsonValues + 3, + ), + maxDepth: Math.min( + Number.MAX_SAFE_INTEGER, + resolvedLimits.maxNestingDepth + 1, + ), + }); + if (inspection === 'duplicate-object-name') { throw new DocumentEnvelopeError( 'Document envelope JSON must not contain duplicate object names', ); } + if (inspection === 'value-count-limit') { + throw new DocumentEnvelopeError( + 'Document envelope exceeds the supported JSON value count', + ); + } + if (inspection === 'nesting-depth-limit') { + throw new DocumentEnvelopeError( + 'Document envelope exceeds the supported document nesting depth', + ); + } try { value = JSON.parse(source) as unknown; } catch { diff --git a/src/documentEnvelopeLimits.test.ts b/src/documentEnvelopeLimits.test.ts index 9137bf2d..6eff2bbc 100644 --- a/src/documentEnvelopeLimits.test.ts +++ b/src/documentEnvelopeLimits.test.ts @@ -38,6 +38,20 @@ describe('document envelope resource limits', () => { ).toEqual(envelope); }); + it('keeps raw envelope wrapper values outside document ceilings', () => { + const envelope = createDocumentEnvelope( + { type: 'doc' }, + { maxJsonValues: 2, maxNestingDepth: 1 }, + ); + + expect( + parseDocumentEnvelope(JSON.stringify(envelope), { + maxJsonValues: 2, + maxNestingDepth: 1, + }), + ).toEqual(envelope); + }); + it('uses defaults for omitted and explicitly undefined overrides', () => { expect( createDocumentEnvelope( @@ -65,6 +79,24 @@ describe('document envelope resource limits', () => { ).toThrow('JSON text exceeds'); }); + it('enforces structural ceilings before materializing raw JSON text', () => { + const source = JSON.stringify({ + schemaId: DOCUMENT_ENVELOPE_SCHEMA_ID, + schemaVersion: DOCUMENT_ENVELOPE_SCHEMA_VERSION, + documentJson: { + type: 'doc', + content: [{ type: 'paragraph' }], + }, + }); + + expect(() => + parseDocumentEnvelope(source, { maxJsonValues: 2 }), + ).toThrow('JSON value count'); + expect(() => + parseDocumentEnvelope(source, { maxNestingDepth: 1 }), + ).toThrow('nesting depth'); + }); + it('rejects object and array expansion past the JSON-value ceiling', () => { expect(() => createDocumentEnvelope( diff --git a/src/jsonObjectNameScanner.test.ts b/src/jsonObjectNameScanner.test.ts index 46760f89..a0cf2e28 100644 --- a/src/jsonObjectNameScanner.test.ts +++ b/src/jsonObjectNameScanner.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { containsDuplicateJsonObjectNames } from './jsonObjectNameScanner.js'; +import { + containsDuplicateJsonObjectNames, + inspectJsonText, +} from './jsonObjectNameScanner.js'; describe('duplicate JSON object-name scanning', () => { it('detects duplicate names at the root and in nested containers', () => { @@ -76,3 +79,55 @@ describe('duplicate JSON object-name scanning', () => { }, ); }); + +describe('bounded JSON text inspection', () => { + it('accepts exact value-count and depth ceilings', () => { + const source = '{"document":{"content":[true,null]}}'; + + expect( + inspectJsonText(source, { maxValues: 5, maxDepth: 3 }), + ).toBe('valid'); + }); + + it('fails before native parsing when object or array values exceed the budget', () => { + expect( + inspectJsonText('{"left":true,"right":false}', { + maxValues: 2, + maxDepth: 10, + }), + ).toBe('value-count-limit'); + expect( + inspectJsonText('[true,false]', { + maxValues: 2, + maxDepth: 10, + }), + ).toBe('value-count-limit'); + }); + + it('fails before native parsing when scalar or container depth is exceeded', () => { + expect( + inspectJsonText('{"outer":{"inner":true}}', { + maxValues: 10, + maxDepth: 1, + }), + ).toBe('nesting-depth-limit'); + expect( + inspectJsonText('[[]]', { maxValues: 10, maxDepth: 0 }), + ).toBe('nesting-depth-limit'); + }); + + it('reports duplicate and malformed inputs distinctly', () => { + expect( + inspectJsonText('{"name":1,"name":2}', { + maxValues: 10, + maxDepth: 10, + }), + ).toBe('duplicate-object-name'); + expect( + inspectJsonText('{"name":1,}', { + maxValues: 10, + maxDepth: 10, + }), + ).toBe('malformed'); + }); +}); diff --git a/src/jsonObjectNameScanner.ts b/src/jsonObjectNameScanner.ts index 6e349b03..ed9b63de 100644 --- a/src/jsonObjectNameScanner.ts +++ b/src/jsonObjectNameScanner.ts @@ -8,12 +8,14 @@ type ArrayFrameState = 'value-or-end' | 'value' | 'comma-or-end'; interface ObjectFrame { readonly kind: 'object'; + readonly depth: number; state: ObjectFrameState; readonly names: Set; } interface ArrayFrame { readonly kind: 'array'; + readonly depth: number; state: ArrayFrameState; } @@ -29,6 +31,22 @@ interface JsonValueStart { readonly frame?: ContainerFrame; } +/** Bounds applied while scanning JSON text before native parsing. */ +export interface JsonTextInspectionLimits { + /** Maximum total number of scalar and container values. */ + readonly maxValues: number; + /** Maximum value depth, with the root value at depth zero. */ + readonly maxDepth: number; +} + +/** Result of bounded duplicate-name and structure inspection. */ +export type JsonTextInspectionResult = + | 'valid' + | 'malformed' + | 'duplicate-object-name' + | 'value-count-limit' + | 'nesting-depth-limit'; + const JSON_WHITESPACE = new Set([' ', '\t', '\n', '\r']); const JSON_VALUE_DELIMITERS = new Set([ ...JSON_WHITESPACE, @@ -36,29 +54,49 @@ const JSON_VALUE_DELIMITERS = new Set([ ']', '}', ]); +const UNBOUNDED_INSPECTION_LIMITS = Object.freeze({ + maxValues: Number.MAX_SAFE_INTEGER, + maxDepth: Number.MAX_SAFE_INTEGER, +}) satisfies JsonTextInspectionLimits; /** - * Detect duplicate object names in JSON text without recursively parsing it. + * Inspect duplicate names and structural resource use before `JSON.parse()`. * - * Invalid JSON returns `false` and remains the responsibility of the canonical - * `JSON.parse()` call. Valid JSON is scanned with an explicit container stack, - * and escaped-equivalent names such as `"name"` and `"\u006eame"` compare as - * the same decoded property name. + * Syntax-invalid input returns `malformed` and remains the responsibility of + * the canonical parser. Valid-looking values are counted with an explicit + * stack so callers can reject pathological value counts and nesting before a + * native parser materializes an attacker-controlled object graph. */ -export function containsDuplicateJsonObjectNames(source: string): boolean { +export function inspectJsonText( + source: string, + limits: JsonTextInspectionLimits, +): JsonTextInspectionResult { const stack: ContainerFrame[] = []; let index = 0; let rootComplete = false; + let valueCount = 0; + + const beginValue = ( + startIndex: number, + depth: number, + ): JsonValueStart | JsonTextInspectionResult => { + const valueStart = readJsonValueStart(source, startIndex, depth); + if (valueStart === null) return 'malformed'; + valueCount += 1; + if (valueCount > limits.maxValues) return 'value-count-limit'; + if (depth > limits.maxDepth) return 'nesting-depth-limit'; + return valueStart; + }; while (true) { index = skipJsonWhitespace(source, index); - if (index >= source.length) return false; - if (rootComplete) return false; + if (index >= source.length) return rootComplete ? 'valid' : 'malformed'; + if (rootComplete) return 'malformed'; const frame = stack.at(-1); if (frame === undefined) { - const valueStart = readJsonValueStart(source, index); - if (valueStart === null) return false; + const valueStart = beginValue(index, 0); + if (typeof valueStart === 'string') return valueStart; index = valueStart.endIndex; if (valueStart.frame === undefined) { rootComplete = true; @@ -77,22 +115,24 @@ export function containsDuplicateJsonObjectNames(source: string): boolean { } if (frame.state === 'key-or-end' || frame.state === 'key') { const keyToken = readJsonString(source, index, true); - if (keyToken?.decodedValue === undefined) return false; - if (frame.names.has(keyToken.decodedValue)) return true; + if (keyToken?.decodedValue === undefined) return 'malformed'; + if (frame.names.has(keyToken.decodedValue)) { + return 'duplicate-object-name'; + } frame.names.add(keyToken.decodedValue); frame.state = 'colon'; index = keyToken.endIndex; continue; } if (frame.state === 'colon') { - if (source[index] !== ':') return false; + if (source[index] !== ':') return 'malformed'; frame.state = 'value'; index += 1; continue; } if (frame.state === 'value') { - const valueStart = readJsonValueStart(source, index); - if (valueStart === null) return false; + const valueStart = beginValue(index, frame.depth + 1); + if (typeof valueStart === 'string') return valueStart; index = valueStart.endIndex; if (valueStart.frame === undefined) { frame.state = 'comma-or-end'; @@ -106,7 +146,7 @@ export function containsDuplicateJsonObjectNames(source: string): boolean { index += 1; continue; } - if (source[index] !== '}') return false; + if (source[index] !== '}') return 'malformed'; index += 1; stack.pop(); rootComplete = completeParentValue(stack); @@ -120,8 +160,8 @@ export function containsDuplicateJsonObjectNames(source: string): boolean { continue; } if (frame.state === 'value-or-end' || frame.state === 'value') { - const valueStart = readJsonValueStart(source, index); - if (valueStart === null) return false; + const valueStart = beginValue(index, frame.depth + 1); + if (typeof valueStart === 'string') return valueStart; index = valueStart.endIndex; if (valueStart.frame === undefined) { frame.state = 'comma-or-end'; @@ -135,13 +175,27 @@ export function containsDuplicateJsonObjectNames(source: string): boolean { index += 1; continue; } - if (source[index] !== ']') return false; + if (source[index] !== ']') return 'malformed'; index += 1; stack.pop(); rootComplete = completeParentValue(stack); } } +/** + * Detect duplicate object names in JSON text without recursively parsing it. + * + * Invalid JSON returns `false` and remains the responsibility of the canonical + * `JSON.parse()` call. Escaped-equivalent names such as `"name"` and + * `"\u006eame"` compare as the same decoded property name. + */ +export function containsDuplicateJsonObjectNames(source: string): boolean { + return ( + inspectJsonText(source, UNBOUNDED_INSPECTION_LIMITS) === + 'duplicate-object-name' + ); +} + function completeParentValue(stack: ContainerFrame[]): boolean { const parent = stack.at(-1); if (parent === undefined) return true; @@ -152,18 +206,24 @@ function completeParentValue(stack: ContainerFrame[]): boolean { function readJsonValueStart( source: string, startIndex: number, + depth: number, ): JsonValueStart | null { const firstCharacter = source[startIndex]; if (firstCharacter === '{') { return { endIndex: startIndex + 1, - frame: { kind: 'object', state: 'key-or-end', names: new Set() }, + frame: { + kind: 'object', + depth, + state: 'key-or-end', + names: new Set(), + }, }; } if (firstCharacter === '[') { return { endIndex: startIndex + 1, - frame: { kind: 'array', state: 'value-or-end' }, + frame: { kind: 'array', depth, state: 'value-or-end' }, }; } if (firstCharacter === '"') {