From d6f07f42eb3ac5bf2cabc05524ce569b43a09a9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 11:04:39 +0900 Subject: [PATCH 01/11] security: preflight JSON structure before parsing --- src/jsonObjectNameScanner.ts | 102 +++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 21 deletions(-) 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 === '"') { From 67a3a3d6cf352d1b2d9614c0f1c5fed6dc16799e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 11:05:06 +0900 Subject: [PATCH 02/11] test: cover bounded JSON text inspection --- src/jsonObjectNameScanner.test.ts | 51 ++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/jsonObjectNameScanner.test.ts b/src/jsonObjectNameScanner.test.ts index 46760f89..38a87cf7 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,49 @@ 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: 6, maxDepth: 3 }), + ).toBe('valid'); + }); + + it('fails before native parsing when the value count is exceeded', () => { + expect( + inspectJsonText('{"left":true,"right":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'); + }); +}); From ae16a43c92959312bc85a5293a9de9ae1130e9de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 11:05:38 +0900 Subject: [PATCH 03/11] ci: apply preparse envelope limits once --- .../apply-preparse-envelope-limits.yml | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .github/workflows/apply-preparse-envelope-limits.yml diff --git a/.github/workflows/apply-preparse-envelope-limits.yml b/.github/workflows/apply-preparse-envelope-limits.yml new file mode 100644 index 00000000..a19b6e55 --- /dev/null +++ b/.github/workflows/apply-preparse-envelope-limits.yml @@ -0,0 +1,103 @@ +name: Apply preparse envelope limits once + +on: + push: + branches: [security/preparse-envelope-limits] + paths: + - .github/workflows/apply-preparse-envelope-limits.yml + +permissions: + contents: write + +concurrency: + group: apply-preparse-envelope-limits + cancel-in-progress: false + +jobs: + apply: + if: github.repository == 'ContextualWisdomLab/inkspan' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: security/preparse-envelope-limits + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + - name: Patch parser integration and validate focused contracts + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + path = Path('src/documentEnvelope.ts') + source = path.read_text(encoding='utf-8') + old_import = "import { containsDuplicateJsonObjectNames } from './jsonObjectNameScanner.js';" + new_import = "import { inspectJsonText } from './jsonObjectNameScanner.js';" + old_block = """ if (containsDuplicateJsonObjectNames(source)) { + throw new DocumentEnvelopeError( + 'Document envelope JSON must not contain duplicate object names', + ); + } + try { + """ + new_block = """ 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 { + """ + if old_import not in source or old_block not in source: + raise SystemExit('Expected document-envelope integration markers are missing') + source = source.replace(old_import, new_import, 1) + source = source.replace(old_block, new_block, 1) + path.write_text(source, encoding='utf-8') + + test_path = Path('src/jsonObjectNameScanner.test.ts') + tests = test_path.read_text(encoding='utf-8') + exact = "inspectJsonText(source, { maxValues: 6, maxDepth: 3 })" + if exact not in tests: + raise SystemExit('Expected exact-limit test marker is missing') + test_path.write_text( + tests.replace( + exact, + "inspectJsonText(source, { maxValues: 5, maxDepth: 3 })", + 1, + ), + encoding='utf-8', + ) + PY + pnpm install --frozen-lockfile + pnpm typecheck + pnpm vitest run src/jsonObjectNameScanner.test.ts src/documentEnvelopeLimits.test.ts src/documentEnvelopeDuplicateNames.test.ts + rm .github/workflows/apply-preparse-envelope-limits.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add src/documentEnvelope.ts src/jsonObjectNameScanner.test.ts .github/workflows/apply-preparse-envelope-limits.yml + git diff --cached --check + git commit -m "security: enforce envelope limits before JSON parsing" + git push origin HEAD:security/preparse-envelope-limits From 05a5db10d8aaf8dada53a3931711a92c1396ee13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 11:07:42 +0900 Subject: [PATCH 04/11] ci: validate preparse envelope release once --- .../apply-preparse-envelope-limits.yml | 134 +++++++++++++++--- 1 file changed, 118 insertions(+), 16 deletions(-) diff --git a/.github/workflows/apply-preparse-envelope-limits.yml b/.github/workflows/apply-preparse-envelope-limits.yml index a19b6e55..79be01a2 100644 --- a/.github/workflows/apply-preparse-envelope-limits.yml +++ b/.github/workflows/apply-preparse-envelope-limits.yml @@ -5,6 +5,12 @@ on: branches: [security/preparse-envelope-limits] paths: - .github/workflows/apply-preparse-envelope-limits.yml + pull_request: + types: [opened, synchronize, reopened] + paths: + - .github/workflows/apply-preparse-envelope-limits.yml + - src/jsonObjectNameScanner.ts + - src/jsonObjectNameScanner.test.ts permissions: contents: write @@ -15,7 +21,9 @@ concurrency: jobs: apply: - if: github.repository == 'ContextualWisdomLab/inkspan' + if: >- + github.repository == 'ContextualWisdomLab/inkspan' && + (github.event_name == 'push' || github.event.pull_request.head.ref == 'security/preparse-envelope-limits') runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -26,15 +34,15 @@ jobs: with: node-version: 22 cache: pnpm - - name: Patch parser integration and validate focused contracts + - name: Patch release slice and validate all TypeScript gates shell: bash run: | set -euo pipefail python - <<'PY' from pathlib import Path - path = Path('src/documentEnvelope.ts') - source = path.read_text(encoding='utf-8') + envelope_path = Path('src/documentEnvelope.ts') + envelope_source = envelope_path.read_text(encoding='utf-8') old_import = "import { containsDuplicateJsonObjectNames } from './jsonObjectNameScanner.js';" new_import = "import { inspectJsonText } from './jsonObjectNameScanner.js';" old_block = """ if (containsDuplicateJsonObjectNames(source)) { @@ -71,33 +79,127 @@ jobs: } try { """ - if old_import not in source or old_block not in source: + if old_import not in envelope_source or old_block not in envelope_source: raise SystemExit('Expected document-envelope integration markers are missing') - source = source.replace(old_import, new_import, 1) - source = source.replace(old_block, new_block, 1) - path.write_text(source, encoding='utf-8') + envelope_source = envelope_source.replace(old_import, new_import, 1) + envelope_source = envelope_source.replace(old_block, new_block, 1) + envelope_path.write_text(envelope_source, encoding='utf-8') - test_path = Path('src/jsonObjectNameScanner.test.ts') - tests = test_path.read_text(encoding='utf-8') + scanner_test_path = Path('src/jsonObjectNameScanner.test.ts') + scanner_tests = scanner_test_path.read_text(encoding='utf-8') exact = "inspectJsonText(source, { maxValues: 6, maxDepth: 3 })" - if exact not in tests: - raise SystemExit('Expected exact-limit test marker is missing') - test_path.write_text( - tests.replace( + if exact not in scanner_tests: + raise SystemExit('Expected exact-limit scanner test marker is missing') + scanner_test_path.write_text( + scanner_tests.replace( exact, "inspectJsonText(source, { maxValues: 5, maxDepth: 3 })", 1, ), encoding='utf-8', ) + + limit_test_path = Path('src/documentEnvelopeLimits.test.ts') + limit_tests = limit_test_path.read_text(encoding='utf-8') + limit_marker = " it('rejects object and array expansion past the JSON-value ceiling', () => {\n" + integration_test = """ 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'); + }); + + """ + if limit_marker not in limit_tests: + raise SystemExit('Expected document-envelope limit test marker is missing') + limit_test_path.write_text( + limit_tests.replace(limit_marker, integration_test + limit_marker, 1), + encoding='utf-8', + ) + + package_path = Path('package.json') + package_text = package_path.read_text(encoding='utf-8') + package_text = package_text.replace('"version": "0.5.18"', '"version": "0.5.19"', 1) + package_text = package_text.replace( + 'resource-bounded duplicate-name-safe versioned persistence envelopes', + 'preparse-resource-bounded duplicate-name-safe versioned persistence envelopes', + 1, + ) + package_path.write_text(package_text, encoding='utf-8') + + docs_path = Path('docs/document-envelope.md') + docs = docs_path.read_text(encoding='utf-8') + old_docs = """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. + """ + new_docs = """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. + """ + if old_docs not in docs: + raise SystemExit('Expected document-envelope documentation marker is missing') + docs_path.write_text(docs.replace(old_docs, new_docs, 1), encoding='utf-8') + + changelog_path = Path('CHANGELOG.md') + changelog = changelog_path.read_text(encoding='utf-8') + marker = '## [Unreleased]\n' + release = '''## [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 + + ''' + if '## [0.5.19]' not in changelog: + if marker not in changelog: + raise SystemExit('CHANGELOG Unreleased marker is missing') + changelog_path.write_text( + changelog.replace(marker, marker + '\n' + release, 1), + encoding='utf-8', + ) PY pnpm install --frozen-lockfile pnpm typecheck - pnpm vitest run src/jsonObjectNameScanner.test.ts src/documentEnvelopeLimits.test.ts src/documentEnvelopeDuplicateNames.test.ts + pnpm coverage + pnpm build + pnpm verify:package + pnpm build:demo rm .github/workflows/apply-preparse-envelope-limits.yml git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add src/documentEnvelope.ts src/jsonObjectNameScanner.test.ts .github/workflows/apply-preparse-envelope-limits.yml + git add src/documentEnvelope.ts src/jsonObjectNameScanner.test.ts src/documentEnvelopeLimits.test.ts package.json docs/document-envelope.md CHANGELOG.md .github/workflows/apply-preparse-envelope-limits.yml git diff --cached --check git commit -m "security: enforce envelope limits before JSON parsing" git push origin HEAD:security/preparse-envelope-limits From 3b2bf45a3b0e2463568c64af2553fad4a2087be5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 11:09:04 +0900 Subject: [PATCH 05/11] ci: repair preparse envelope integration once --- .../repair-preparse-envelope-limits.yml | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 .github/workflows/repair-preparse-envelope-limits.yml diff --git a/.github/workflows/repair-preparse-envelope-limits.yml b/.github/workflows/repair-preparse-envelope-limits.yml new file mode 100644 index 00000000..2adeea07 --- /dev/null +++ b/.github/workflows/repair-preparse-envelope-limits.yml @@ -0,0 +1,203 @@ +name: Repair preparse envelope integration once + +on: + pull_request: + types: [synchronize] + paths: + - .github/workflows/repair-preparse-envelope-limits.yml + +permissions: + contents: write + +concurrency: + group: repair-preparse-envelope-integration + cancel-in-progress: false + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/inkspan' && + github.event.pull_request.head.ref == 'security/preparse-envelope-limits' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: security/preparse-envelope-limits + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + - name: Apply release slice and validate all TypeScript gates + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + envelope_path = Path('src/documentEnvelope.ts') + envelope_source = envelope_path.read_text(encoding='utf-8') + old_import = "import { containsDuplicateJsonObjectNames } from './jsonObjectNameScanner.js';" + if old_import not in envelope_source: + raise SystemExit('Expected scanner import is missing') + envelope_source = envelope_source.replace( + old_import, + "import { inspectJsonText } from './jsonObjectNameScanner.js';", + 1, + ) + start_marker = ' if (containsDuplicateJsonObjectNames(source)) {' + end_marker = ' try {' + start_index = envelope_source.find(start_marker) + end_index = envelope_source.find(end_marker, start_index) + if start_index < 0 or end_index < 0: + raise SystemExit('Expected duplicate-name parser block is missing') + replacement = """ 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', + ); + } + """ + envelope_source = ( + envelope_source[:start_index] + + replacement + + envelope_source[end_index:] + ) + envelope_path.write_text(envelope_source, encoding='utf-8') + + scanner_test_path = Path('src/jsonObjectNameScanner.test.ts') + scanner_tests = scanner_test_path.read_text(encoding='utf-8') + scanner_tests = scanner_tests.replace( + 'inspectJsonText(source, { maxValues: 6, maxDepth: 3 })', + 'inspectJsonText(source, { maxValues: 5, maxDepth: 3 })', + 1, + ) + scanner_test_path.write_text(scanner_tests, encoding='utf-8') + + limit_test_path = Path('src/documentEnvelopeLimits.test.ts') + limit_tests = limit_test_path.read_text(encoding='utf-8') + limit_marker = " it('rejects object and array expansion past the JSON-value ceiling', () => {\n" + integration_test = """ 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'); + }); + + """ + if 'enforces structural ceilings before materializing raw JSON text' not in limit_tests: + if limit_marker not in limit_tests: + raise SystemExit('Expected limit-test insertion marker is missing') + limit_tests = limit_tests.replace( + limit_marker, + integration_test + limit_marker, + 1, + ) + limit_test_path.write_text(limit_tests, encoding='utf-8') + + package_path = Path('package.json') + package_text = package_path.read_text(encoding='utf-8') + package_text = package_text.replace('"version": "0.5.18"', '"version": "0.5.19"', 1) + package_text = package_text.replace( + 'resource-bounded duplicate-name-safe versioned persistence envelopes', + 'preparse-resource-bounded duplicate-name-safe versioned persistence envelopes', + 1, + ) + package_path.write_text(package_text, encoding='utf-8') + + docs_path = Path('docs/document-envelope.md') + docs = docs_path.read_text(encoding='utf-8') + old_docs = """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. + """ + new_docs = """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. + """ + if old_docs not in docs: + raise SystemExit('Expected documentation replacement marker is missing') + docs_path.write_text(docs.replace(old_docs, new_docs, 1), encoding='utf-8') + + changelog_path = Path('CHANGELOG.md') + changelog = changelog_path.read_text(encoding='utf-8') + marker = '## [Unreleased]\n' + release = '''## [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 + + ''' + if '## [0.5.19]' not in changelog: + if marker not in changelog: + raise SystemExit('CHANGELOG Unreleased marker is missing') + changelog_path.write_text( + changelog.replace(marker, marker + '\n' + release, 1), + encoding='utf-8', + ) + PY + pnpm install --frozen-lockfile + pnpm typecheck + pnpm coverage + pnpm build + pnpm verify:package + pnpm build:demo + rm .github/workflows/apply-preparse-envelope-limits.yml + rm .github/workflows/repair-preparse-envelope-limits.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add -A + git diff --cached --check + git commit -m "security: enforce envelope limits before JSON parsing" + git push origin HEAD:security/preparse-envelope-limits From 8b9899783ff7670a077c02affbc49794f3612e67 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:10:27 +0000 Subject: [PATCH 06/11] security: enforce envelope limits before JSON parsing --- .../apply-preparse-envelope-limits.yml | 205 ------------------ .../repair-preparse-envelope-limits.yml | 203 ----------------- CHANGELOG.md | 18 ++ docs/document-envelope.md | 15 +- package.json | 4 +- src/documentEnvelope.ts | 32 ++- src/documentEnvelopeLimits.test.ts | 18 ++ src/jsonObjectNameScanner.test.ts | 2 +- 8 files changed, 76 insertions(+), 421 deletions(-) delete mode 100644 .github/workflows/apply-preparse-envelope-limits.yml delete mode 100644 .github/workflows/repair-preparse-envelope-limits.yml diff --git a/.github/workflows/apply-preparse-envelope-limits.yml b/.github/workflows/apply-preparse-envelope-limits.yml deleted file mode 100644 index 79be01a2..00000000 --- a/.github/workflows/apply-preparse-envelope-limits.yml +++ /dev/null @@ -1,205 +0,0 @@ -name: Apply preparse envelope limits once - -on: - push: - branches: [security/preparse-envelope-limits] - paths: - - .github/workflows/apply-preparse-envelope-limits.yml - pull_request: - types: [opened, synchronize, reopened] - paths: - - .github/workflows/apply-preparse-envelope-limits.yml - - src/jsonObjectNameScanner.ts - - src/jsonObjectNameScanner.test.ts - -permissions: - contents: write - -concurrency: - group: apply-preparse-envelope-limits - cancel-in-progress: false - -jobs: - apply: - if: >- - github.repository == 'ContextualWisdomLab/inkspan' && - (github.event_name == 'push' || github.event.pull_request.head.ref == 'security/preparse-envelope-limits') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: security/preparse-envelope-limits - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 22 - cache: pnpm - - name: Patch release slice and validate all TypeScript gates - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - envelope_path = Path('src/documentEnvelope.ts') - envelope_source = envelope_path.read_text(encoding='utf-8') - old_import = "import { containsDuplicateJsonObjectNames } from './jsonObjectNameScanner.js';" - new_import = "import { inspectJsonText } from './jsonObjectNameScanner.js';" - old_block = """ if (containsDuplicateJsonObjectNames(source)) { - throw new DocumentEnvelopeError( - 'Document envelope JSON must not contain duplicate object names', - ); - } - try { - """ - new_block = """ 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 { - """ - if old_import not in envelope_source or old_block not in envelope_source: - raise SystemExit('Expected document-envelope integration markers are missing') - envelope_source = envelope_source.replace(old_import, new_import, 1) - envelope_source = envelope_source.replace(old_block, new_block, 1) - envelope_path.write_text(envelope_source, encoding='utf-8') - - scanner_test_path = Path('src/jsonObjectNameScanner.test.ts') - scanner_tests = scanner_test_path.read_text(encoding='utf-8') - exact = "inspectJsonText(source, { maxValues: 6, maxDepth: 3 })" - if exact not in scanner_tests: - raise SystemExit('Expected exact-limit scanner test marker is missing') - scanner_test_path.write_text( - scanner_tests.replace( - exact, - "inspectJsonText(source, { maxValues: 5, maxDepth: 3 })", - 1, - ), - encoding='utf-8', - ) - - limit_test_path = Path('src/documentEnvelopeLimits.test.ts') - limit_tests = limit_test_path.read_text(encoding='utf-8') - limit_marker = " it('rejects object and array expansion past the JSON-value ceiling', () => {\n" - integration_test = """ 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'); - }); - - """ - if limit_marker not in limit_tests: - raise SystemExit('Expected document-envelope limit test marker is missing') - limit_test_path.write_text( - limit_tests.replace(limit_marker, integration_test + limit_marker, 1), - encoding='utf-8', - ) - - package_path = Path('package.json') - package_text = package_path.read_text(encoding='utf-8') - package_text = package_text.replace('"version": "0.5.18"', '"version": "0.5.19"', 1) - package_text = package_text.replace( - 'resource-bounded duplicate-name-safe versioned persistence envelopes', - 'preparse-resource-bounded duplicate-name-safe versioned persistence envelopes', - 1, - ) - package_path.write_text(package_text, encoding='utf-8') - - docs_path = Path('docs/document-envelope.md') - docs = docs_path.read_text(encoding='utf-8') - old_docs = """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. - """ - new_docs = """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. - """ - if old_docs not in docs: - raise SystemExit('Expected document-envelope documentation marker is missing') - docs_path.write_text(docs.replace(old_docs, new_docs, 1), encoding='utf-8') - - changelog_path = Path('CHANGELOG.md') - changelog = changelog_path.read_text(encoding='utf-8') - marker = '## [Unreleased]\n' - release = '''## [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 - - ''' - if '## [0.5.19]' not in changelog: - if marker not in changelog: - raise SystemExit('CHANGELOG Unreleased marker is missing') - changelog_path.write_text( - changelog.replace(marker, marker + '\n' + release, 1), - encoding='utf-8', - ) - PY - pnpm install --frozen-lockfile - pnpm typecheck - pnpm coverage - pnpm build - pnpm verify:package - pnpm build:demo - rm .github/workflows/apply-preparse-envelope-limits.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add src/documentEnvelope.ts src/jsonObjectNameScanner.test.ts src/documentEnvelopeLimits.test.ts package.json docs/document-envelope.md CHANGELOG.md .github/workflows/apply-preparse-envelope-limits.yml - git diff --cached --check - git commit -m "security: enforce envelope limits before JSON parsing" - git push origin HEAD:security/preparse-envelope-limits diff --git a/.github/workflows/repair-preparse-envelope-limits.yml b/.github/workflows/repair-preparse-envelope-limits.yml deleted file mode 100644 index 2adeea07..00000000 --- a/.github/workflows/repair-preparse-envelope-limits.yml +++ /dev/null @@ -1,203 +0,0 @@ -name: Repair preparse envelope integration once - -on: - pull_request: - types: [synchronize] - paths: - - .github/workflows/repair-preparse-envelope-limits.yml - -permissions: - contents: write - -concurrency: - group: repair-preparse-envelope-integration - cancel-in-progress: false - -jobs: - repair: - if: >- - github.repository == 'ContextualWisdomLab/inkspan' && - github.event.pull_request.head.ref == 'security/preparse-envelope-limits' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: security/preparse-envelope-limits - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 22 - cache: pnpm - - name: Apply release slice and validate all TypeScript gates - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - envelope_path = Path('src/documentEnvelope.ts') - envelope_source = envelope_path.read_text(encoding='utf-8') - old_import = "import { containsDuplicateJsonObjectNames } from './jsonObjectNameScanner.js';" - if old_import not in envelope_source: - raise SystemExit('Expected scanner import is missing') - envelope_source = envelope_source.replace( - old_import, - "import { inspectJsonText } from './jsonObjectNameScanner.js';", - 1, - ) - start_marker = ' if (containsDuplicateJsonObjectNames(source)) {' - end_marker = ' try {' - start_index = envelope_source.find(start_marker) - end_index = envelope_source.find(end_marker, start_index) - if start_index < 0 or end_index < 0: - raise SystemExit('Expected duplicate-name parser block is missing') - replacement = """ 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', - ); - } - """ - envelope_source = ( - envelope_source[:start_index] - + replacement - + envelope_source[end_index:] - ) - envelope_path.write_text(envelope_source, encoding='utf-8') - - scanner_test_path = Path('src/jsonObjectNameScanner.test.ts') - scanner_tests = scanner_test_path.read_text(encoding='utf-8') - scanner_tests = scanner_tests.replace( - 'inspectJsonText(source, { maxValues: 6, maxDepth: 3 })', - 'inspectJsonText(source, { maxValues: 5, maxDepth: 3 })', - 1, - ) - scanner_test_path.write_text(scanner_tests, encoding='utf-8') - - limit_test_path = Path('src/documentEnvelopeLimits.test.ts') - limit_tests = limit_test_path.read_text(encoding='utf-8') - limit_marker = " it('rejects object and array expansion past the JSON-value ceiling', () => {\n" - integration_test = """ 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'); - }); - - """ - if 'enforces structural ceilings before materializing raw JSON text' not in limit_tests: - if limit_marker not in limit_tests: - raise SystemExit('Expected limit-test insertion marker is missing') - limit_tests = limit_tests.replace( - limit_marker, - integration_test + limit_marker, - 1, - ) - limit_test_path.write_text(limit_tests, encoding='utf-8') - - package_path = Path('package.json') - package_text = package_path.read_text(encoding='utf-8') - package_text = package_text.replace('"version": "0.5.18"', '"version": "0.5.19"', 1) - package_text = package_text.replace( - 'resource-bounded duplicate-name-safe versioned persistence envelopes', - 'preparse-resource-bounded duplicate-name-safe versioned persistence envelopes', - 1, - ) - package_path.write_text(package_text, encoding='utf-8') - - docs_path = Path('docs/document-envelope.md') - docs = docs_path.read_text(encoding='utf-8') - old_docs = """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. - """ - new_docs = """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. - """ - if old_docs not in docs: - raise SystemExit('Expected documentation replacement marker is missing') - docs_path.write_text(docs.replace(old_docs, new_docs, 1), encoding='utf-8') - - changelog_path = Path('CHANGELOG.md') - changelog = changelog_path.read_text(encoding='utf-8') - marker = '## [Unreleased]\n' - release = '''## [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 - - ''' - if '## [0.5.19]' not in changelog: - if marker not in changelog: - raise SystemExit('CHANGELOG Unreleased marker is missing') - changelog_path.write_text( - changelog.replace(marker, marker + '\n' + release, 1), - encoding='utf-8', - ) - PY - pnpm install --frozen-lockfile - pnpm typecheck - pnpm coverage - pnpm build - pnpm verify:package - pnpm build:demo - rm .github/workflows/apply-preparse-envelope-limits.yml - rm .github/workflows/repair-preparse-envelope-limits.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add -A - git diff --cached --check - git commit -m "security: enforce envelope limits before JSON parsing" - git push origin HEAD:security/preparse-envelope-limits 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..45a43407 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)) { - throw new DocumentEnvelopeError( - 'Document envelope JSON must not contain duplicate object names', - ); - } + 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..67fce40c 100644 --- a/src/documentEnvelopeLimits.test.ts +++ b/src/documentEnvelopeLimits.test.ts @@ -65,6 +65,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 38a87cf7..99cc5c07 100644 --- a/src/jsonObjectNameScanner.test.ts +++ b/src/jsonObjectNameScanner.test.ts @@ -85,7 +85,7 @@ describe('bounded JSON text inspection', () => { const source = '{"document":{"content":[true,null]}}'; expect( - inspectJsonText(source, { maxValues: 6, maxDepth: 3 }), + inspectJsonText(source, { maxValues: 5, maxDepth: 3 }), ).toBe('valid'); }); From 02813ca80e3a959f255639917d1f7912d85ce5aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 11:11:15 +0900 Subject: [PATCH 07/11] test: lock preparse array value budgeting --- src/jsonObjectNameScanner.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/jsonObjectNameScanner.test.ts b/src/jsonObjectNameScanner.test.ts index 99cc5c07..a0cf2e28 100644 --- a/src/jsonObjectNameScanner.test.ts +++ b/src/jsonObjectNameScanner.test.ts @@ -89,13 +89,19 @@ describe('bounded JSON text inspection', () => { ).toBe('valid'); }); - it('fails before native parsing when the value count is exceeded', () => { + 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', () => { From ee27456f8148e1900657ffbf29621cafdee38918 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 11:12:14 +0900 Subject: [PATCH 08/11] ci: format preparse envelope limits once --- .../format-preparse-envelope-limits.yml | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/format-preparse-envelope-limits.yml diff --git a/.github/workflows/format-preparse-envelope-limits.yml b/.github/workflows/format-preparse-envelope-limits.yml new file mode 100644 index 00000000..19e16dd9 --- /dev/null +++ b/.github/workflows/format-preparse-envelope-limits.yml @@ -0,0 +1,95 @@ +name: Format preparse envelope limits once + +on: + pull_request: + types: [synchronize] + paths: + - .github/workflows/format-preparse-envelope-limits.yml + +permissions: + contents: write + +concurrency: + group: format-preparse-envelope-limits + cancel-in-progress: false + +jobs: + format: + if: >- + github.repository == 'ContextualWisdomLab/inkspan' && + github.event.pull_request.head.ref == 'security/preparse-envelope-limits' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: security/preparse-envelope-limits + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + - name: Normalize parser constants and indentation + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + path = Path('src/documentEnvelope.ts') + source = path.read_text(encoding='utf-8') + constant_marker = """const REDACTED_INSPECTION_ERROR = + 'Document envelope could not be inspected safely'; + """ + constant_replacement = """const REDACTED_INSPECTION_ERROR = + 'Document envelope could not be inspected safely'; + const DOCUMENT_ENVELOPE_WRAPPER_VALUE_ALLOWANCE = 3; + const DOCUMENT_ENVELOPE_WRAPPER_DEPTH_ALLOWANCE = 1; + """ + start_marker = ' const inspection = inspectJsonText(source, {' + end_marker = ' try {' + start_index = source.find(start_marker) + end_index = source.find(end_marker, start_index) + if constant_marker not in source or start_index < 0 or end_index < 0: + raise SystemExit('Expected parser-format markers are missing') + block = """ const inspection = inspectJsonText(source, { + maxValues: Math.min( + Number.MAX_SAFE_INTEGER, + resolvedLimits.maxJsonValues + + DOCUMENT_ENVELOPE_WRAPPER_VALUE_ALLOWANCE, + ), + maxDepth: Math.min( + Number.MAX_SAFE_INTEGER, + resolvedLimits.maxNestingDepth + + DOCUMENT_ENVELOPE_WRAPPER_DEPTH_ALLOWANCE, + ), + }); + 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', + ); + } + """ + source = source.replace(constant_marker, constant_replacement, 1) + source = source[:start_index] + block + source[end_index:] + path.write_text(source, encoding='utf-8') + PY + pnpm install --frozen-lockfile + pnpm typecheck + pnpm coverage + rm .github/workflows/format-preparse-envelope-limits.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add -A + git diff --cached --check + git commit -m "refactor: name envelope wrapper allowances" + git push origin HEAD:security/preparse-envelope-limits From 1c38a4d4bd33121900be4fa97bb223d8f0570c58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 11:13:27 +0900 Subject: [PATCH 09/11] ci: repair preparse parser formatting once --- .github/workflows/repair-preparse-format.yml | 88 ++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/repair-preparse-format.yml diff --git a/.github/workflows/repair-preparse-format.yml b/.github/workflows/repair-preparse-format.yml new file mode 100644 index 00000000..6256a152 --- /dev/null +++ b/.github/workflows/repair-preparse-format.yml @@ -0,0 +1,88 @@ +name: Repair preparse parser formatting once + +on: + pull_request: + types: [synchronize] + paths: + - .github/workflows/repair-preparse-format.yml + +permissions: + contents: write + +concurrency: + group: repair-preparse-parser-formatting + cancel-in-progress: false + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/inkspan' && + github.event.pull_request.head.ref == 'security/preparse-envelope-limits' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: security/preparse-envelope-limits + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + - name: Repair indentation and validate coverage + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + path = Path('src/documentEnvelope.ts') + source = path.read_text(encoding='utf-8') + start_marker = ' const inspection = inspectJsonText(source, {' + end_marker = ' try {' + start_index = source.find(start_marker) + end_index = source.find(end_marker, start_index) + if start_index < 0 or end_index < 0: + raise SystemExit('Expected preparse block is missing') + block = ( + " const inspection = inspectJsonText(source, {\n" + " maxValues: Math.min(\n" + " Number.MAX_SAFE_INTEGER,\n" + " resolvedLimits.maxJsonValues + 3,\n" + " ),\n" + " maxDepth: Math.min(\n" + " Number.MAX_SAFE_INTEGER,\n" + " resolvedLimits.maxNestingDepth + 1,\n" + " ),\n" + " });\n" + " if (inspection === 'duplicate-object-name') {\n" + " throw new DocumentEnvelopeError(\n" + " 'Document envelope JSON must not contain duplicate object names',\n" + " );\n" + " }\n" + " if (inspection === 'value-count-limit') {\n" + " throw new DocumentEnvelopeError(\n" + " 'Document envelope exceeds the supported JSON value count',\n" + " );\n" + " }\n" + " if (inspection === 'nesting-depth-limit') {\n" + " throw new DocumentEnvelopeError(\n" + " 'Document envelope exceeds the supported document nesting depth',\n" + " );\n" + " }\n" + ) + path.write_text( + source[:start_index] + block + source[end_index:], + encoding='utf-8', + ) + PY + pnpm install --frozen-lockfile + pnpm typecheck + pnpm coverage + rm .github/workflows/format-preparse-envelope-limits.yml + rm .github/workflows/repair-preparse-format.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add -A + git diff --cached --check + git commit -m "style: normalize envelope preparse validation" + git push origin HEAD:security/preparse-envelope-limits From 735c3358c2dad7bf8d0b58e9297040e05d6b00e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:14:20 +0000 Subject: [PATCH 10/11] style: normalize envelope preparse validation --- .../format-preparse-envelope-limits.yml | 95 ------------------- .github/workflows/repair-preparse-format.yml | 88 ----------------- src/documentEnvelope.ts | 48 +++++----- 3 files changed, 24 insertions(+), 207 deletions(-) delete mode 100644 .github/workflows/format-preparse-envelope-limits.yml delete mode 100644 .github/workflows/repair-preparse-format.yml diff --git a/.github/workflows/format-preparse-envelope-limits.yml b/.github/workflows/format-preparse-envelope-limits.yml deleted file mode 100644 index 19e16dd9..00000000 --- a/.github/workflows/format-preparse-envelope-limits.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: Format preparse envelope limits once - -on: - pull_request: - types: [synchronize] - paths: - - .github/workflows/format-preparse-envelope-limits.yml - -permissions: - contents: write - -concurrency: - group: format-preparse-envelope-limits - cancel-in-progress: false - -jobs: - format: - if: >- - github.repository == 'ContextualWisdomLab/inkspan' && - github.event.pull_request.head.ref == 'security/preparse-envelope-limits' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: security/preparse-envelope-limits - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 22 - cache: pnpm - - name: Normalize parser constants and indentation - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - path = Path('src/documentEnvelope.ts') - source = path.read_text(encoding='utf-8') - constant_marker = """const REDACTED_INSPECTION_ERROR = - 'Document envelope could not be inspected safely'; - """ - constant_replacement = """const REDACTED_INSPECTION_ERROR = - 'Document envelope could not be inspected safely'; - const DOCUMENT_ENVELOPE_WRAPPER_VALUE_ALLOWANCE = 3; - const DOCUMENT_ENVELOPE_WRAPPER_DEPTH_ALLOWANCE = 1; - """ - start_marker = ' const inspection = inspectJsonText(source, {' - end_marker = ' try {' - start_index = source.find(start_marker) - end_index = source.find(end_marker, start_index) - if constant_marker not in source or start_index < 0 or end_index < 0: - raise SystemExit('Expected parser-format markers are missing') - block = """ const inspection = inspectJsonText(source, { - maxValues: Math.min( - Number.MAX_SAFE_INTEGER, - resolvedLimits.maxJsonValues + - DOCUMENT_ENVELOPE_WRAPPER_VALUE_ALLOWANCE, - ), - maxDepth: Math.min( - Number.MAX_SAFE_INTEGER, - resolvedLimits.maxNestingDepth + - DOCUMENT_ENVELOPE_WRAPPER_DEPTH_ALLOWANCE, - ), - }); - 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', - ); - } - """ - source = source.replace(constant_marker, constant_replacement, 1) - source = source[:start_index] + block + source[end_index:] - path.write_text(source, encoding='utf-8') - PY - pnpm install --frozen-lockfile - pnpm typecheck - pnpm coverage - rm .github/workflows/format-preparse-envelope-limits.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add -A - git diff --cached --check - git commit -m "refactor: name envelope wrapper allowances" - git push origin HEAD:security/preparse-envelope-limits diff --git a/.github/workflows/repair-preparse-format.yml b/.github/workflows/repair-preparse-format.yml deleted file mode 100644 index 6256a152..00000000 --- a/.github/workflows/repair-preparse-format.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Repair preparse parser formatting once - -on: - pull_request: - types: [synchronize] - paths: - - .github/workflows/repair-preparse-format.yml - -permissions: - contents: write - -concurrency: - group: repair-preparse-parser-formatting - cancel-in-progress: false - -jobs: - repair: - if: >- - github.repository == 'ContextualWisdomLab/inkspan' && - github.event.pull_request.head.ref == 'security/preparse-envelope-limits' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: security/preparse-envelope-limits - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 22 - cache: pnpm - - name: Repair indentation and validate coverage - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - path = Path('src/documentEnvelope.ts') - source = path.read_text(encoding='utf-8') - start_marker = ' const inspection = inspectJsonText(source, {' - end_marker = ' try {' - start_index = source.find(start_marker) - end_index = source.find(end_marker, start_index) - if start_index < 0 or end_index < 0: - raise SystemExit('Expected preparse block is missing') - block = ( - " const inspection = inspectJsonText(source, {\n" - " maxValues: Math.min(\n" - " Number.MAX_SAFE_INTEGER,\n" - " resolvedLimits.maxJsonValues + 3,\n" - " ),\n" - " maxDepth: Math.min(\n" - " Number.MAX_SAFE_INTEGER,\n" - " resolvedLimits.maxNestingDepth + 1,\n" - " ),\n" - " });\n" - " if (inspection === 'duplicate-object-name') {\n" - " throw new DocumentEnvelopeError(\n" - " 'Document envelope JSON must not contain duplicate object names',\n" - " );\n" - " }\n" - " if (inspection === 'value-count-limit') {\n" - " throw new DocumentEnvelopeError(\n" - " 'Document envelope exceeds the supported JSON value count',\n" - " );\n" - " }\n" - " if (inspection === 'nesting-depth-limit') {\n" - " throw new DocumentEnvelopeError(\n" - " 'Document envelope exceeds the supported document nesting depth',\n" - " );\n" - " }\n" - ) - path.write_text( - source[:start_index] + block + source[end_index:], - encoding='utf-8', - ) - PY - pnpm install --frozen-lockfile - pnpm typecheck - pnpm coverage - rm .github/workflows/format-preparse-envelope-limits.yml - rm .github/workflows/repair-preparse-format.yml - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add -A - git diff --cached --check - git commit -m "style: normalize envelope preparse validation" - git push origin HEAD:security/preparse-envelope-limits diff --git a/src/documentEnvelope.ts b/src/documentEnvelope.ts index 45a43407..b76311fc 100644 --- a/src/documentEnvelope.ts +++ b/src/documentEnvelope.ts @@ -183,30 +183,30 @@ function parseDocumentEnvelopeWithLimits( ); } 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', - ); - } + 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 { From c26a19ac67613ff2093074f7ca70ad6c80e90c80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 11:15:06 +0900 Subject: [PATCH 11/11] test: verify exact envelope wrapper allowances --- src/documentEnvelopeLimits.test.ts | 44 ++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/documentEnvelopeLimits.test.ts b/src/documentEnvelopeLimits.test.ts index 67fce40c..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( @@ -66,22 +80,22 @@ describe('document envelope resource limits', () => { }); 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' }], - }, - }); + 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'); -}); + 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(() =>