Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions docs/document-envelope.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
24 changes: 22 additions & 2 deletions src/documentEnvelope.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions src/documentEnvelopeLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
57 changes: 56 additions & 1 deletion src/jsonObjectNameScanner.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
Loading
Loading