From 51f9edfdff7de9072cafa8cebaf068dc39f92208 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:17:38 +0000 Subject: [PATCH 01/16] test(reliability): prove clipboard preflight allocation gaps Add exact RED regressions for oversized UTF-8 encoding and broad-source child materialization on current protected main. Co-authored-by: Seongho Bae --- .../SafeClipboardPreflightSize.test.ts | 23 +++++++++++++ .../SafeClipboardTraversalBudget.test.ts | 34 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/extensions/SafeClipboardPreflightSize.test.ts create mode 100644 src/extensions/SafeClipboardTraversalBudget.test.ts diff --git a/src/extensions/SafeClipboardPreflightSize.test.ts b/src/extensions/SafeClipboardPreflightSize.test.ts new file mode 100644 index 000000000..5dd1b504b --- /dev/null +++ b/src/extensions/SafeClipboardPreflightSize.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; + +describe('rich clipboard size preflight', () => { + it('rejects an obviously oversized string before allocating a UTF-8 copy', () => { + const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode'); + + try { + expect(() => + sanitizeRichClipboardHtml('x'.repeat(9), { maxHtmlBytes: 8 }, document), + ).toThrowError( + expect.objectContaining({ + code: 'input_too_large', + message: 'Rich clipboard HTML exceeds the configured byte limit.', + }), + ); + expect(encodeSpy).not.toHaveBeenCalled(); + } finally { + encodeSpy.mockRestore(); + } + }); +}); diff --git a/src/extensions/SafeClipboardTraversalBudget.test.ts b/src/extensions/SafeClipboardTraversalBudget.test.ts new file mode 100644 index 000000000..cd682827f --- /dev/null +++ b/src/extensions/SafeClipboardTraversalBudget.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; + +describe('rich clipboard traversal budget', () => { + it('rejects a broad source before materializing children beyond maxNodes', () => { + const originalItem = NodeList.prototype.item; + let broadChildReads = 0; + const itemSpy = vi + .spyOn(NodeList.prototype, 'item') + .mockImplementation(function (this: NodeList, index: number) { + if (this.length === 3) broadChildReads += 1; + return originalItem.call(this, index); + }); + + try { + expect(() => + sanitizeRichClipboardHtml( + '

A

B

C

', + { maxNodes: 2 }, + document, + ), + ).toThrowError( + expect.objectContaining({ + code: 'node_limit_exceeded', + message: 'Rich clipboard HTML exceeds the configured node limit.', + }), + ); + expect(broadChildReads).toBe(0); + } finally { + itemSpy.mockRestore(); + } + }); +}); From 71654a8e59eecd72f2a23ebec173e4e537c927d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:20:48 +0000 Subject: [PATCH 02/16] fix(reliability): preflight clipboard size and traversal budget Reject oversized UTF-16 clipboard HTML before UTF-8 allocation and reject broad source trees before child materialization, preserving existing redacted error codes on current protected main. Co-authored-by: Seongho Bae --- CHANGELOG.md | 3 + docs/TRACEABILITY.md | 9 ++ docs/clipboard-security.md | 23 ++++ .../doctoring/clipboard-resource-preflight.md | 103 ++++++++++++++++++ ...oardResourcePreflightDocumentation.test.ts | 43 ++++++++ src/extensions/SafeClipboard.ts | 45 +++++++- 6 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 docs/doctoring/clipboard-resource-preflight.md create mode 100644 src/clipboardResourcePreflightDocumentation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d1d3dcb..373935b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim ## [Unreleased] +### Reliability +- Reject obviously oversized rich clipboard HTML from UTF-16 code-unit length before allocating a UTF-8 copy, and reject broad source trees before materializing children beyond `maxNodes`. + ## [0.6.0] — 2026-08-10 ### Release diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index d120cec77..65c142efb 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -19,6 +19,7 @@ This record maps durable Inkspan product decisions to authoritative standards, p | Provenance semantics | Local transition/release evidence keeps content lineage separate from actor/authorization/durable claims | W3C PROV family | transition evidence, release evidence, canonical data model | Inkspan does not claim complete PROV conformance or host audit provenance | | Accessibility | Native controls, keyboard semantics, shortcut metadata, semantic placeholder guidance, and host-facing status state support accessible embedding | W3C WCAG 2.2; WAI-ARIA 1.2 where used | protected toolbar/accessibility tests, SSR tests, autosave lifecycle data, protected #131 placeholder tests/packed consumer and `docs/doctoring/editor-placeholder-accessibility.md` | Component evidence alone is not a full host WCAG conformance claim; `aria-placeholder` supplements but never replaces the accessible name | | Browser clipboard behavior | Security-relevant rich HTML handling requires actual paste-pipeline integration and bounded semantic reconstruction before editor state | WHATWG HTML parsing; W3C Clipboard API | protected-main rich-clipboard unit/integration corpus and SafeClipboard ADR | Protected jsdom/TipTap integration success is not universal browser-engine conformance | +| Clipboard resource preflight | Reject UTF-16 length above `maxHtmlBytes` before UTF-8 allocation, and reject `visited + queued + enqueueable > maxNodes` before child materialization | Unicode Standard 16.0 §3.9; ECMA-262 string length; WHATWG DOM `NodeList`; CWE-770; W3C Clipboard API | Active-PR doctoring `docs/doctoring/clipboard-resource-preflight.md`, operator guide, and SafeClipboard preflight/traversal regressions | Proposed until protected `main`; jsdom proof is not cross-engine conformance or a claim that remaining HTML is trusted | | Cross-engine release assurance | The same committed synthetic adversarial corpus runs under required Chromium, Firefox, and WebKit projects; exact package-lock and packed npm artifact SHA-256 digests are required, and only focused standards-grounded safe differences may be admitted | WHATWG HTML Living Standard; W3C Clipboard API and events; Playwright 1.62 release notes and browser/project documentation | ADR 0016, protected-main browser evidence source/workflows, TEST_STRATEGY, OPERABILITY and UML | Protected-main implementation is the release-policy authority; every release candidate must regenerate fresh exact-source/lock/run/browser evidence bound to the exact packed npm artifact SHA-256 and does not claim byte-identical browser serialization or branded enterprise-policy coverage | | CSS paged-media output | Shipped editor CSS has a declarative print boundary that removes interactive chrome and screen clipping while preserving authored document flow and bounded fragmentation behavior | W3C Media Queries Level 3; CSS Fragmentation Level 3; CSS Paged Media Level 3 as tracked draft input | protected-main #116 packaged stylesheet, real-browser print-media evidence, ADR 0021, print doctoring and tests | `implemented_on_protected_main`; browser print styling does not create a durable PDF service, page-number/header authority, persistence, signing, or PDF-conformance claim | | Editor integration | Public behavior must exercise the actual TipTap/ProseMirror integration path, not an inert extension field or test-only hook | official TipTap and ProseMirror documentation for the locked dependency line | integration tests and package consumers | Inkspan does not claim compatibility with untested major-version integration semantics | @@ -40,6 +41,8 @@ Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) Data Interchange Ecma International. (2021). *ECMA-376: Office Open XML file formats* (5th ed.). https://ecma-international.org/publications-and-standards/standards/ecma-376/ +Ecma International. (2025). *ECMAScript® 2025 language specification* (ECMA-262, 16th ed.). https://tc39.es/ecma262/2025/ + Ecma International. (2026). *ECMA-402: ECMAScript 2026 internationalization API specification* (13th ed.). https://402.ecma-international.org/ Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP Semantics* (RFC 9110; STD 97). RFC Editor. https://doi.org/10.17487/RFC9110 @@ -58,6 +61,8 @@ Microsoft. (n.d.-e). *Release notes: Version 1.62*. Playwright. Retrieved August Microsoft. (n.d.-f). *Working with paragraphs*. Microsoft Learn. Retrieved August 10, 2026, from https://learn.microsoft.com/en-us/office/open-xml/word/working-with-paragraphs +MITRE. (2024). *CWE-770: Allocation of resources without limits or throttling*. https://cwe.mitre.org/data/definitions/770.html + Node.js contributors. (2026). *Modules: Packages*. Node.js documentation. https://nodejs.org/api/packages.html ProseMirror. (n.d.). *ProseMirror reference manual*. Retrieved August 10, 2026, from https://prosemirror.net/docs/ref/ @@ -70,6 +75,10 @@ Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme (J Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for Mitigating the Risk of Software Vulnerabilities* (NIST SP 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 +Unicode Consortium. (2024). *The Unicode Standard, Version 16.0.0*. https://www.unicode.org/versions/Unicode16.0.0/ + +Web Hypertext Application Technology Working Group. (2026). *DOM Standard*. Retrieved August 16, 2026, from https://dom.spec.whatwg.org/ + Web Hypertext Application Technology Working Group. (2026). *HTML Standard: Parsing HTML documents* (Living Standard). Retrieved August 10, 2026, from https://html.spec.whatwg.org/multipage/parsing.html World Wide Web Consortium. (2013). *PROV-DM: The PROV Data Model*. https://www.w3.org/TR/prov-dm/ diff --git a/docs/clipboard-security.md b/docs/clipboard-security.md index 7e72f6d99..1e081ff6e 100644 --- a/docs/clipboard-security.md +++ b/docs/clipboard-security.md @@ -42,6 +42,29 @@ Error observers are live: replacing `onClipboardError` does not recreate the editor or the Yjs binding. A host callback failure is contained and cannot make rejected HTML enter the document. +## Resource preflight + +If a paste is rejected with `input_too_large` or `node_limit_exceeded`, raise +the matching ceiling only after measuring a trusted source. Do not disable the +limits to “make paste work.” + +Inkspan rejects an obviously oversized string when its UTF-16 code-unit length +already exceeds `maxHtmlBytes`, before allocating a UTF-8 copy. Every UTF-16 +code unit encodes to at least one UTF-8 byte, so that length check is a safe +lower bound. Strings whose code-unit length is within the ceiling still receive +the exact UTF-8 byte-length check, because non-ASCII text can expand. + +Inkspan also rejects a broad source tree when already visited nodes, already +queued frames, and newly enqueueable children would exceed `maxNodes`, before +materializing those children. Dropped or hidden subtrees are never traversed +and therefore do not consume descendant budget. The closed-`details` summary +path uses the same queue invariant. + +These preflight checks do not change the redacted error codes or messages. They +do not authorize the remaining HTML. See +`docs/doctoring/clipboard-resource-preflight.md` for the standards basis, +test-first evidence, residual risk, and rollback. + ## Preserved structure Inkspan reconstructs a new fragment containing only: diff --git a/docs/doctoring/clipboard-resource-preflight.md b/docs/doctoring/clipboard-resource-preflight.md new file mode 100644 index 000000000..6e099abee --- /dev/null +++ b/docs/doctoring/clipboard-resource-preflight.md @@ -0,0 +1,103 @@ +# Doctoring record: rich clipboard resource preflight + +**Date:** 2026-08-16 +**Status:** Active PR / Proposed +**Decision owner:** ContextualWisdomLab +**Scope:** Allocation bounds inside `sanitizeRichClipboardHtml()` before UTF-8 +encoding and before DOM child materialization. + +## Buyer-visible gap + +Hosts paste untrusted HTML from Word, browsers, mail, and support tools. A +caller-controlled string can already be larger than `maxHtmlBytes` in UTF-16 +code units, yet a complete `TextEncoder` copy was still allocated before +rejection. A broad surviving source node could also enqueue every child before +`maxNodes` was enforced. Buyers therefore saw the configured ceilings as +weaker than their names: Inkspan could still amplify memory on rejected paste. + +If a paste fails with `input_too_large` or `node_limit_exceeded`, measure the +trusted source and raise only that ceiling. Do not remove the limits. + +## Decision + +1. Reject `sourceHtml.length > maxHtmlBytes` with the existing redacted + `input_too_large` error before constructing `TextEncoder`. Retain the exact + UTF-8 `byteLength` check when code-unit length alone cannot reject. +2. Reject when `visited + queued + newly enqueueable` source nodes would exceed + `maxNodes`, before `NodeList.item()` materializes those children. The + closed-`details` first-summary path uses the same invariant. +3. Keep dropped and hidden subtrees unvisited so their descendants do not + consume budget. Preserve source order, allowlist, SafeLink, depth, and + redacted error text. + +No network, persistence, credential, model, tenant, collaboration-provider, or +durable-audit authority is added. + +## Standards rationale + +The Unicode Standard defines UTF-8 as a variable-width encoding in which every +scalar value uses one or more 8-bit code units, and never zero +(Unicode Consortium, 2024, §3.9). ECMA-262 exposes `String` length as UTF-16 +code units (Ecma International, 2025, §6.1.4). A UTF-16 code unit therefore +contributes at least one UTF-8 byte, so `sourceHtml.length > maxHtmlBytes` is a +conservative lower-bound rejection. Non-ASCII text can still expand, so the +exact encoder check remains required inside the ceiling. + +WHATWG DOM defines `Node.childNodes` as a live `NodeList` whose members are +retrieved by index (Web Hypertext Application Technology Working Group, 2026). +Counting enqueueable children against the remaining node budget before index +access prevents the sanitizer from allocating a traversal stack larger than the +configured ceiling. + +CWE-770 records allocation without a matching limit as a reliability and +availability defect (MITRE, 2024). The W3C Clipboard API Working Draft treats +HTML clipboard payloads as untrusted input (World Wide Web Consortium, 2026). +The cited Working Draft is work in progress and is not a conformance claim. + +## Test-first evidence + +- RED `51f9edfdff7de9072cafa8cebaf068dc39f92208` on current protected + `main@e8109ec2a17de8bd6594487aa12c8c8a93cb2c03` proved an ASCII + nine-code-unit string under `maxHtmlBytes: 8` still called + `TextEncoder.prototype.encode`, and a three-child fragment under + `maxNodes: 2` performed three `NodeList.item()` reads before + `node_limit_exceeded`. +- GREEN on this branch rejects both cases at the preflight boundary without + changing codes or messages. + +Predecessor Draft #164 remains historical. It is not current-main +implementation authority. + +## Residual risk + +The UTF-16 lower bound does not replace the exact UTF-8 check. Queue preflight +counts enqueueable children of a surviving parent; it does not invent a second +hidden-content policy. jsdom success is not Chromium, Firefox, or WebKit +conformance. Cross-engine corpus evidence remains a 0.6.0 release-acceptance +gate. + +## Rollback + +Rollback must remove the length preflight, the traversal-capacity guard, this +record, the operator guidance, the documentation contract, and the changelog +entry together. It must restore the previous encode-then-compare and +visit-then-reject behavior only as one change. Rollback requires the same +exact-head review, coverage, security, packaging, and independent-approval +gates. + +## References (APA 7th edition) + +Ecma International. (2025). *ECMAScript® 2025 language specification* +(ECMA-262, 16th ed.). https://tc39.es/ecma262/2025/ + +MITRE. (2024). *CWE-770: Allocation of resources without limits or throttling*. +https://cwe.mitre.org/data/definitions/770.html + +Unicode Consortium. (2024). *The Unicode Standard, Version 16.0.0*. +https://www.unicode.org/versions/Unicode16.0.0/ + +Web Hypertext Application Technology Working Group. (2026). *DOM Standard*. +Retrieved August 16, 2026, from https://dom.spec.whatwg.org/ + +World Wide Web Consortium. (2026, June 24). *Clipboard API and events* (W3C +Working Draft). https://www.w3.org/TR/2026/WD-clipboard-apis-20260624/ diff --git a/src/clipboardResourcePreflightDocumentation.test.ts b/src/clipboardResourcePreflightDocumentation.test.ts new file mode 100644 index 000000000..d18e2667f --- /dev/null +++ b/src/clipboardResourcePreflightDocumentation.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** Read one repository document and normalize layout whitespace. */ +function normalizedDocument(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8') + .replace(/\s+/gu, ' ') + .trim(); +} + +describe('clipboard resource-preflight documentation contract', () => { + it('tells hosts how to respond to size and node-limit rejections', () => { + const operatorGuide = normalizedDocument('docs/clipboard-security.md'); + const doctoring = normalizedDocument( + 'docs/doctoring/clipboard-resource-preflight.md', + ); + const changelog = normalizedDocument('CHANGELOG.md'); + + expect(operatorGuide).toContain( + 'raise the matching ceiling only after measuring a trusted source', + ); + expect(operatorGuide).toContain( + 'UTF-16 code-unit length already exceeds `maxHtmlBytes`', + ); + expect(operatorGuide).toContain( + 'before materializing those children', + ); + expect(doctoring).toContain('Unicode Standard'); + expect(doctoring).toContain('CWE-770'); + expect(doctoring).toContain('https://www.unicode.org/versions/Unicode16.0.0/'); + expect(doctoring).toContain( + 'https://www.w3.org/TR/2026/WD-clipboard-apis-20260624/', + ); + expect(changelog).toContain( + 'Reject obviously oversized rich clipboard HTML from UTF-16 code-unit length', + ); + expect(changelog).toContain( + 'before materializing children beyond `maxNodes`', + ); + }); +}); diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index a3eaef4c6..cae3cc3b6 100644 --- a/src/extensions/SafeClipboard.ts +++ b/src/extensions/SafeClipboard.ts @@ -443,14 +443,30 @@ function normalizedOutputElement(sourceName: string): string | null { return ALLOWED_ELEMENTS.has(normalized) ? normalized : null; } +/** Reject before queued traversal frames can exceed the configured node budget. */ +function assertTraversalCapacity( + stack: TraversalFrame[], + visitedNodes: number, + additionalNodes: number, + maxNodes: number, +): void { + if (additionalNodes > maxNodes - visitedNodes - stack.length) { + throw new ClipboardSanitizationError('node_limit_exceeded'); + } +} + /** Push child frames in reverse so iterative traversal preserves source order. */ function pushChildren( stack: TraversalFrame[], sourceNode: globalThis.Node, outputParent: globalThis.Node, depth: number, + visitedNodes: number, + maxNodes: number, ): void { - for (let index = sourceNode.childNodes.length - 1; index >= 0; index -= 1) { + const childCount = sourceNode.childNodes.length; + assertTraversalCapacity(stack, visitedNodes, childCount, maxNodes); + for (let index = childCount - 1; index >= 0; index -= 1) { const child = sourceNode.childNodes.item(index); if (child) stack.push({ sourceNode: child, outputParent, depth }); } @@ -462,10 +478,13 @@ function pushClosedDetailsSummary( sourceElement: Element, outputParent: globalThis.Node, depth: number, + visitedNodes: number, + maxNodes: number, ): void { for (let index = 0; index < sourceElement.children.length; index += 1) { const child = sourceElement.children.item(index); if (child?.localName.toLowerCase() !== 'summary') continue; + assertTraversalCapacity(stack, visitedNodes, 1, maxNodes); stack.push({ sourceNode: child, outputParent, depth }); return; } @@ -487,6 +506,7 @@ export function sanitizeRichClipboardHtml( throw new ClipboardSanitizationError('invalid_html'); } if ( + sourceHtml.length > resolvedConfig.maxHtmlBytes || new TextEncoder().encode(sourceHtml).byteLength > resolvedConfig.maxHtmlBytes ) { throw new ClipboardSanitizationError('input_too_large'); @@ -504,7 +524,14 @@ export function sanitizeRichClipboardHtml( sourceTemplate.innerHTML = sourceHtml; const outputContainer = inertDocument.createElement('div'); const stack: TraversalFrame[] = []; - pushChildren(stack, sourceTemplate.content, outputContainer, 1); + pushChildren( + stack, + sourceTemplate.content, + outputContainer, + 1, + 0, + resolvedConfig.maxNodes, + ); let visitedNodes = 0; while (stack.length > 0) { @@ -512,9 +539,6 @@ export function sanitizeRichClipboardHtml( /* v8 ignore next -- stack length guarantees a frame. */ if (!frame) continue; visitedNodes += 1; - if (visitedNodes > resolvedConfig.maxNodes) { - throw new ClipboardSanitizationError('node_limit_exceeded'); - } if (frame.depth > resolvedConfig.maxDepth) { throw new ClipboardSanitizationError('depth_limit_exceeded'); } @@ -544,6 +568,8 @@ export function sanitizeRichClipboardHtml( sourceElement, frame.outputParent, frame.depth + 1, + visitedNodes, + resolvedConfig.maxNodes, ); continue; } @@ -572,7 +598,14 @@ export function sanitizeRichClipboardHtml( } if (sourceName !== 'br' && sourceName !== 'hr') { - pushChildren(stack, sourceElement, childParent, frame.depth + 1); + pushChildren( + stack, + sourceElement, + childParent, + frame.depth + 1, + visitedNodes, + resolvedConfig.maxNodes, + ); } } return outputContainer.innerHTML; From 58f004e5053e2a9b686814b4b9c724ab389a83ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:20:57 +0000 Subject: [PATCH 03/16] docs(clipboard): bind preflight doctoring to GREEN head Co-authored-by: Seongho Bae --- docs/doctoring/clipboard-resource-preflight.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/clipboard-resource-preflight.md b/docs/doctoring/clipboard-resource-preflight.md index 6e099abee..e13b16966 100644 --- a/docs/doctoring/clipboard-resource-preflight.md +++ b/docs/doctoring/clipboard-resource-preflight.md @@ -62,8 +62,8 @@ The cited Working Draft is work in progress and is not a conformance claim. `TextEncoder.prototype.encode`, and a three-child fragment under `maxNodes: 2` performed three `NodeList.item()` reads before `node_limit_exceeded`. -- GREEN on this branch rejects both cases at the preflight boundary without - changing codes or messages. +- GREEN `71654a8e59eecd72f2a23ebec173e4e537c927d9` rejects both cases at the + preflight boundary without changing codes or messages. Predecessor Draft #164 remains historical. It is not current-main implementation authority. From 37105d712c774b7ef638315d7d691311fd52c1ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:21:37 -0700 Subject: [PATCH 04/16] test(clipboard): require actionable customer guidance --- .../SafeClipboardCustomerGuidance.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/extensions/SafeClipboardCustomerGuidance.test.ts diff --git a/src/extensions/SafeClipboardCustomerGuidance.test.ts b/src/extensions/SafeClipboardCustomerGuidance.test.ts new file mode 100644 index 000000000..a3531ab7b --- /dev/null +++ b/src/extensions/SafeClipboardCustomerGuidance.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { ClipboardSanitizationError } from './SafeClipboard.js'; + +describe('SafeClipboard customer guidance', () => { + it.each([ + [ + 'input_too_large', + 'The pasted content is too large to insert. Try pasting less content at once.', + ], + [ + 'node_limit_exceeded', + 'The pasted content is too complex to insert. Try pasting less content at once.', + ], + [ + 'depth_limit_exceeded', + 'The pasted content is too deeply nested to insert. Try pasting less content at once.', + ], + [ + 'invalid_html', + "This content can't be inserted here. Try pasting as plain text instead.", + ], + ] as const)('gives %s rejection an actionable next step', (code, message) => { + expect(new ClipboardSanitizationError(code)).toMatchObject({ code, message }); + }); +}); From d0255ecb05e77b3c98f365c51fc3a15c630c736a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:42:07 -0700 Subject: [PATCH 05/16] fix(ux): make clipboard rejection guidance actionable --- src/extensions/SafeClipboard.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index cae3cc3b6..d2f603f82 100644 --- a/src/extensions/SafeClipboard.ts +++ b/src/extensions/SafeClipboard.ts @@ -49,13 +49,15 @@ const ERROR_MESSAGES: Readonly> = Object.freeze({ dom_unavailable: 'Rich clipboard sanitization requires a DOM-capable document.', - input_too_large: 'Rich clipboard HTML exceeds the configured byte limit.', + input_too_large: + 'The pasted content is too large to insert. Try pasting less content at once.', node_limit_exceeded: - 'Rich clipboard HTML exceeds the configured node limit.', + 'The pasted content is too complex to insert. Try pasting less content at once.', depth_limit_exceeded: - 'Rich clipboard HTML exceeds the configured depth limit.', + 'The pasted content is too deeply nested to insert. Try pasting less content at once.', invalid_configuration: 'Rich clipboard configuration is invalid.', - invalid_html: 'Rich clipboard HTML could not be sanitized.', + invalid_html: + "This content can't be inserted here. Try pasting as plain text instead.", }); /** Error whose stable code and message never disclose clipboard content. */ From 4cfa7eff674c8d66dd1e10b61beba6324ca307a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:42:30 -0700 Subject: [PATCH 06/16] test(clipboard): align coverage contract with actionable guidance --- src/extensions/SafeClipboard.coverageContract.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/SafeClipboard.coverageContract.test.ts b/src/extensions/SafeClipboard.coverageContract.test.ts index 5d3bcd0b1..34f7db10a 100644 --- a/src/extensions/SafeClipboard.coverageContract.test.ts +++ b/src/extensions/SafeClipboard.coverageContract.test.ts @@ -32,7 +32,7 @@ describe('SafeClipboard fail-closed coverage contract', () => { ).toThrowError( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", }), ); }); @@ -59,7 +59,7 @@ describe('SafeClipboard fail-closed coverage contract', () => { it('keeps the redacted sanitizer error class stable', () => { expect(new ClipboardSanitizationError('invalid_html')).toMatchObject({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", name: 'ClipboardSanitizationError', }); }); From 8185d86a413c216335b4f9a10521ef5beead8d3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:44:26 -0700 Subject: [PATCH 07/16] test(clipboard): align sanitizer expectations with actionable guidance --- src/extensions/SafeClipboard.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/extensions/SafeClipboard.test.ts b/src/extensions/SafeClipboard.test.ts index 95e1c73af..a6a1ab866 100644 --- a/src/extensions/SafeClipboard.test.ts +++ b/src/extensions/SafeClipboard.test.ts @@ -165,7 +165,8 @@ describe('sanitizeRichClipboardHtml', () => { ).toThrowError( expect.objectContaining({ code: 'input_too_large', - message: 'Rich clipboard HTML exceeds the configured byte limit.', + message: + 'The pasted content is too large to insert. Try pasting less content at once.', }), ); }); @@ -176,7 +177,8 @@ describe('sanitizeRichClipboardHtml', () => { ).toThrowError( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: + "This content can't be inserted here. Try pasting as plain text instead.", }), ); }); @@ -373,7 +375,8 @@ describe('SafeClipboard extension', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: + "This content can't be inserted here. Try pasting as plain text instead.", }), ); From 47032a59c2cd2fc040c00793dcf819f44577acd3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:44:55 -0700 Subject: [PATCH 08/16] test(clipboard): align adapter expectation with actionable guidance --- src/extensions/SafeClipboardExtension.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/extensions/SafeClipboardExtension.test.ts b/src/extensions/SafeClipboardExtension.test.ts index 6932326a1..41401f282 100644 --- a/src/extensions/SafeClipboardExtension.test.ts +++ b/src/extensions/SafeClipboardExtension.test.ts @@ -114,7 +114,8 @@ describe('SafeClipboard TipTap v2 adapter', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: + "This content can't be inserted here. Try pasting as plain text instead.", }), ); expect(String(onError.mock.calls[0]?.[0])).not.toContain('private option'); From b6108e276e4841f52df2b1ed3dafcdae4f9dc41e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:46:45 -0700 Subject: [PATCH 09/16] test(docs): require truthful clipboard guidance contract --- src/clipboardResourcePreflightDocumentation.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/clipboardResourcePreflightDocumentation.test.ts b/src/clipboardResourcePreflightDocumentation.test.ts index d18e2667f..a51bb9ef5 100644 --- a/src/clipboardResourcePreflightDocumentation.test.ts +++ b/src/clipboardResourcePreflightDocumentation.test.ts @@ -27,6 +27,12 @@ describe('clipboard resource-preflight documentation contract', () => { expect(operatorGuide).toContain( 'before materializing those children', ); + expect(operatorGuide).toContain( + 'The machine-readable error codes remain stable, while customer-facing rejection messages give a bounded next action', + ); + expect(doctoring).toContain( + 'preserve the stable error codes while allowing the customer-facing messages to remain actionable', + ); expect(doctoring).toContain('Unicode Standard'); expect(doctoring).toContain('CWE-770'); expect(doctoring).toContain('https://www.unicode.org/versions/Unicode16.0.0/'); From b35acb47a97dbdccb17d12bbb1eb0acfa202e8cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:47:20 -0700 Subject: [PATCH 10/16] docs(clipboard): keep preflight record truthful about guidance --- .../doctoring/clipboard-resource-preflight.md | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/doctoring/clipboard-resource-preflight.md b/docs/doctoring/clipboard-resource-preflight.md index e13b16966..4ef26f506 100644 --- a/docs/doctoring/clipboard-resource-preflight.md +++ b/docs/doctoring/clipboard-resource-preflight.md @@ -28,7 +28,7 @@ trusted source and raise only that ceiling. Do not remove the limits. closed-`details` first-summary path uses the same invariant. 3. Keep dropped and hidden subtrees unvisited so their descendants do not consume budget. Preserve source order, allowlist, SafeLink, depth, and - redacted error text. + preserve the stable error codes while allowing the customer-facing messages to remain actionable. No network, persistence, credential, model, tenant, collaboration-provider, or durable-audit authority is added. @@ -56,14 +56,21 @@ The cited Working Draft is work in progress and is not a conformance claim. ## Test-first evidence -- RED `51f9edfdff7de9072cafa8cebaf068dc39f92208` on current protected +- RED `51f9edfdff7de9072cafa8cebaf068dc39f92208` on protected `main@e8109ec2a17de8bd6594487aa12c8c8a93cb2c03` proved an ASCII nine-code-unit string under `maxHtmlBytes: 8` still called `TextEncoder.prototype.encode`, and a three-child fragment under `maxNodes: 2` performed three `NodeList.item()` reads before `node_limit_exceeded`. - GREEN `71654a8e59eecd72f2a23ebec173e4e537c927d9` rejects both cases at the - preflight boundary without changing codes or messages. + preflight boundary without changing the stable error codes. +- Customer-guidance RED `37105d712c774b7ef638315d7d691311fd52c1ad` + required actionable, privacy-safe messages while the source still emitted + implementation-oriented text. +- Customer-guidance GREEN `d0255ecb05e77b3c98f365c51fc3a15c630c736a` + keeps those codes stable while making size, complexity, nesting, and invalid + HTML rejections tell the customer what to do next. Exact-message regression + contracts remain redacted and contain no pasted source content. Predecessor Draft #164 remains historical. It is not current-main implementation authority. @@ -72,18 +79,20 @@ implementation authority. The UTF-16 lower bound does not replace the exact UTF-8 check. Queue preflight counts enqueueable children of a surviving parent; it does not invent a second -hidden-content policy. jsdom success is not Chromium, Firefox, or WebKit -conformance. Cross-engine corpus evidence remains a 0.6.0 release-acceptance -gate. +hidden-content policy. Actionable customer-facing messages do not authorize +rejected HTML and do not weaken the fail-closed empty-fragment behavior. jsdom +success is not Chromium, Firefox, or WebKit conformance. Cross-engine corpus +evidence remains a 0.6.0 release-acceptance gate. ## Rollback Rollback must remove the length preflight, the traversal-capacity guard, this record, the operator guidance, the documentation contract, and the changelog entry together. It must restore the previous encode-then-compare and -visit-then-reject behavior only as one change. Rollback requires the same -exact-head review, coverage, security, packaging, and independent-approval -gates. +visit-then-reject behavior only as one change. Any rollback of customer-facing +message text must keep the stable codes and privacy-redaction contract intact. +Rollback requires the same exact-head review, coverage, security, packaging, +and independent-approval gates. ## References (APA 7th edition) From 9ab698046519ef55915ca7d1e362e59041adfcb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:48:05 -0700 Subject: [PATCH 11/16] docs(clipboard): describe stable codes and actionable messages --- docs/clipboard-security.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/clipboard-security.md b/docs/clipboard-security.md index 1e081ff6e..25e20b0d9 100644 --- a/docs/clipboard-security.md +++ b/docs/clipboard-security.md @@ -60,10 +60,10 @@ materializing those children. Dropped or hidden subtrees are never traversed and therefore do not consume descendant budget. The closed-`details` summary path uses the same queue invariant. -These preflight checks do not change the redacted error codes or messages. They -do not authorize the remaining HTML. See -`docs/doctoring/clipboard-resource-preflight.md` for the standards basis, -test-first evidence, residual risk, and rollback. +The machine-readable error codes remain stable, while customer-facing rejection +messages give a bounded next action. The preflight checks do not authorize the +remaining HTML. See `docs/doctoring/clipboard-resource-preflight.md` for the +standards basis, test-first evidence, residual risk, and rollback. ## Preserved structure From d7a8a3212421c7990cb4e4e8f580e651742d8cf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:06:10 +0900 Subject: [PATCH 12/16] test(clipboard): align stacked guidance contract --- src/extensions/SafeClipboardExtension.hostileThrow.test.ts | 6 ++++-- src/extensions/SafeClipboardPreflightSize.test.ts | 3 ++- src/extensions/SafeClipboardTraversalBudget.test.ts | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/extensions/SafeClipboardExtension.hostileThrow.test.ts b/src/extensions/SafeClipboardExtension.hostileThrow.test.ts index 6624c620b..7ef12248e 100644 --- a/src/extensions/SafeClipboardExtension.hostileThrow.test.ts +++ b/src/extensions/SafeClipboardExtension.hostileThrow.test.ts @@ -53,7 +53,8 @@ describe('SafeClipboard hostile thrown-value containment', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: + "This content can't be inserted here. Try pasting as plain text instead.", }), ); }); @@ -90,7 +91,8 @@ describe('SafeClipboard hostile thrown-value containment', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: + "This content can't be inserted here. Try pasting as plain text instead.", }), ); }); diff --git a/src/extensions/SafeClipboardPreflightSize.test.ts b/src/extensions/SafeClipboardPreflightSize.test.ts index 5dd1b504b..6f68613a9 100644 --- a/src/extensions/SafeClipboardPreflightSize.test.ts +++ b/src/extensions/SafeClipboardPreflightSize.test.ts @@ -12,7 +12,8 @@ describe('rich clipboard size preflight', () => { ).toThrowError( expect.objectContaining({ code: 'input_too_large', - message: 'Rich clipboard HTML exceeds the configured byte limit.', + message: + 'The pasted content is too large to insert. Try pasting less content at once.', }), ); expect(encodeSpy).not.toHaveBeenCalled(); diff --git a/src/extensions/SafeClipboardTraversalBudget.test.ts b/src/extensions/SafeClipboardTraversalBudget.test.ts index cd682827f..1f3e901ca 100644 --- a/src/extensions/SafeClipboardTraversalBudget.test.ts +++ b/src/extensions/SafeClipboardTraversalBudget.test.ts @@ -23,7 +23,8 @@ describe('rich clipboard traversal budget', () => { ).toThrowError( expect.objectContaining({ code: 'node_limit_exceeded', - message: 'Rich clipboard HTML exceeds the configured node limit.', + message: + 'The pasted content is too complex to insert. Try pasting less content at once.', }), ); expect(broadChildReads).toBe(0); From 9061addbfec9d3880f6e5daac31fc1364cbf57cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:05:17 +0900 Subject: [PATCH 13/16] test(clipboard): expose repeated child-list reads Signed-off-by: Seongho Bae --- .../SafeClipboardTraversalBudget.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/extensions/SafeClipboardTraversalBudget.test.ts b/src/extensions/SafeClipboardTraversalBudget.test.ts index 1f3e901ca..387a310dc 100644 --- a/src/extensions/SafeClipboardTraversalBudget.test.ts +++ b/src/extensions/SafeClipboardTraversalBudget.test.ts @@ -3,6 +3,31 @@ import { describe, expect, it, vi } from 'vitest'; import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; describe('rich clipboard traversal budget', () => { + it('reads the accepted source child list once while preserving order', () => { + const originalGetter = Object.getOwnPropertyDescriptor( + Node.prototype, + 'childNodes', + )?.get; + if (!originalGetter) throw new Error('The test DOM has no childNodes getter.'); + let fragmentListReads = 0; + const childListSpy = vi.spyOn(Node.prototype, 'childNodes', 'get') + .mockImplementation(function (this: Node) { + const children = originalGetter.call(this); + if (this.nodeType === Node.DOCUMENT_FRAGMENT_NODE && children.length === 3) { + fragmentListReads += 1; + } + return children; + }); + + try { + const sourceHtml = '

A

B

C

'; + expect(sanitizeRichClipboardHtml(sourceHtml, {}, document)).toBe(sourceHtml); + expect(fragmentListReads).toBe(1); + } finally { + childListSpy.mockRestore(); + } + }); + it('rejects a broad source before materializing children beyond maxNodes', () => { const originalItem = NodeList.prototype.item; let broadChildReads = 0; From ea65e3bf0776646f83aaf7c27b156cd6697174be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:06:00 +0900 Subject: [PATCH 14/16] perf(clipboard): reuse the bounded source child list Signed-off-by: Seongho Bae --- src/extensions/SafeClipboard.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index 51d8f3b57..463fb662f 100644 --- a/src/extensions/SafeClipboard.ts +++ b/src/extensions/SafeClipboard.ts @@ -478,10 +478,11 @@ function pushChildren( visitedNodes: number, maxNodes: number, ): void { - const childCount = sourceNode.childNodes.length; + const sourceChildren = sourceNode.childNodes; + const childCount = sourceChildren.length; assertTraversalCapacity(stack, visitedNodes, childCount, maxNodes); for (let index = childCount - 1; index >= 0; index -= 1) { - const child = sourceNode.childNodes.item(index); + const child = sourceChildren.item(index); if (child) stack.push({ sourceNode: child, outputParent, depth }); } } From 1ca6ed3c94c3d5f052cc9436f4c8ecdb8f3ad1d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:08:24 +0900 Subject: [PATCH 15/16] docs(clipboard): record bounded child-list reuse evidence Signed-off-by: Seongho Bae --- .../doctoring/clipboard-resource-preflight.md | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/clipboard-resource-preflight.md b/docs/doctoring/clipboard-resource-preflight.md index 4ef26f506..b8b9acec8 100644 --- a/docs/doctoring/clipboard-resource-preflight.md +++ b/docs/doctoring/clipboard-resource-preflight.md @@ -75,6 +75,29 @@ The cited Working Draft is work in progress and is not a conformance claim. Predecessor Draft #164 remains historical. It is not current-main implementation authority. +## Accepted-child list reuse finding — 2026-09-07 + +Status remains Active PR / Proposed (#359), not protected-main behavior. +The shared child-enqueue loop read `sourceNode.childNodes` once to count the +children and again for every accepted child. RED `9061addb` records four getter +reads for a three-child source fragment while preserving its exact output. +Candidate `ea65e3b` retains one local reference and uses it for both the existing +capacity check and reverse-order item reads. The DOM Standard marks this +attribute `[SameObject]`; the reference remains a live `NodeList`, not a copied +snapshot or a cache (Web Hypertext Application Technology Working Group, 2026). + +The original over-budget rejection still happens before child item reads. +Source order, hidden-subtree handling, error containment, limits, and public +contracts are unchanged. No new traversal implementation or dependency is added. +Expanded clipboard coverage retained 50 passing tests and one existing Word +capacity timeout; it is not full acceptance. Independent TypeScript and the +four-test getter/preflight/full-3,000-paragraph capacity diagnostic passed. +This establishes fewer property reads, not a measured buyer speedup or the +cause of the earlier capacity timeout. Whole-suite, packed-consumer, browser, +and protected-integration proof remain separate requirements. Reverting only +the local-reference change restores the prior lookup pattern without removing +the resource guards. + ## Residual risk The UTF-16 lower bound does not replace the exact UTF-8 check. Queue preflight @@ -106,7 +129,7 @@ Unicode Consortium. (2024). *The Unicode Standard, Version 16.0.0*. https://www.unicode.org/versions/Unicode16.0.0/ Web Hypertext Application Technology Working Group. (2026). *DOM Standard*. -Retrieved August 16, 2026, from https://dom.spec.whatwg.org/ +Retrieved September 7, 2026, from https://dom.spec.whatwg.org/#dom-node-childnodes World Wide Web Consortium. (2026, June 24). *Clipboard API and events* (W3C Working Draft). https://www.w3.org/TR/2026/WD-clipboard-apis-20260624/ From 36037015e3715d9c1590438bafde888d62487f86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:34:40 +0900 Subject: [PATCH 16/16] test(clipboard): clarify the source child-list binding Signed-off-by: Seongho Bae --- src/extensions/SafeClipboardTraversalBudget.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extensions/SafeClipboardTraversalBudget.test.ts b/src/extensions/SafeClipboardTraversalBudget.test.ts index 387a310dc..1e93a6ada 100644 --- a/src/extensions/SafeClipboardTraversalBudget.test.ts +++ b/src/extensions/SafeClipboardTraversalBudget.test.ts @@ -12,11 +12,11 @@ describe('rich clipboard traversal budget', () => { let fragmentListReads = 0; const childListSpy = vi.spyOn(Node.prototype, 'childNodes', 'get') .mockImplementation(function (this: Node) { - const children = originalGetter.call(this); - if (this.nodeType === Node.DOCUMENT_FRAGMENT_NODE && children.length === 3) { + const sourceChildren = originalGetter.call(this); + if (this.nodeType === Node.DOCUMENT_FRAGMENT_NODE && sourceChildren.length === 3) { fragmentListReads += 1; } - return children; + return sourceChildren; }); try {