diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc35f43..0c1c20d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,7 +114,7 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim ### Security - Evidence and callback outcomes are validated fail-closed without evaluating accessors; hostile reflection failures become bounded redacted errors -- The public autosave boundary traverses candidate document JSON iteratively before scheduling, rejecting mutable nested nodes, accessors, symbols, non-finite numbers, unsupported prototypes, aliases, cycles, sparse arrays, excessive nesting, and values beyond the active envelope resource ceiling +- The public autosave boundary traverses candidate document JSON iteratively before scheduling, rejecting mutable nested nodes, accessors, symbols, non-finite numbers, unsupported prototypes, aliases, cycles, sparse arrays, excessive depth, and values beyond the active envelope resource ceiling - Partially frozen look-alike evidence cannot enter the host callback and mutate after its strong revision was selected - The queue includes no timer, network client, provider SDK, credential, tenant identifier, persistence adapter, React, TipTap, ProseMirror, or Yjs runtime dependency - Revision tags remain tenant-confidential equality validators rather than authorization, signatures, tenant membership, or proof of durable persistence @@ -133,3 +133,53 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim ### Documentation - Added the autosave architecture and doctoring record with APA 7th references to RFC 9110, RFC 8785, Herlihy and Wing (1990), and ISO/IEC 25010:2023 - Added an operator guide covering correct durable-base `If-Match` usage, local versus durable ownership, SSR/worker compatibility, observability minimization, and CWL/naruon modular integration boundaries + +## Pending 0.6.0 — Safe rich clipboard ingestion + +### Added +- Added a default `SafeClipboard` TipTap extension and `sanitizeRichClipboardHtml()` API that reconstruct browser-provided `text/html` through a strict semantic allowlist before ProseMirror parsing +- Added a registered TipTap v2.27.2 ProseMirror plugin adapter and public plugin key so SafeClipboard participates in the actual `transformPastedHTML` editor-property chain before parsing +- Added `clipboard` byte/node/depth limits and a live `onClipboardError` observer to standalone and provider-neutral Yjs editor surfaces +- Added operator guidance, design, implementation-plan, and APA 7th doctoring records for the clipboard trust boundary + +### Changed +- Rich HTML pasted from Word, Google Docs, email, and web pages keeps supported structure and narrowly mapped bold/italic/underline/strike semantics while discarding arbitrary source styling and proprietary metadata +- Standalone and collaborative editors use one shared clipboard policy through `buildExtensions()` +- The runtime integration now uses the locked TipTap v2.27.2 `addProseMirrorPlugins` contract instead of relying on a manually invoked direct extension field that the installed v2 ExtensionManager does not register +- Nested clipboard policy objects use accessor-safe paste-time configuration validation at the exact rich-paste boundary +- Because default rich-HTML paste behavior changes, publication targets **0.6.0** only after a separate verified release pull request + +### Security +- Active, embedded, form, metadata, media, SVG/MathML, template, resource-bearing, hidden, and HTML-image subtrees are removed before insertion +- Exact `content-visibility: hidden` subtrees are removed from bounded raw style declarations, including case, whitespace, terminal `!important`, CSS-comment, and CSS-escaped forms, while `visible`, `auto`, `hiddenly`, and prefixed property names remain visible +- Closed `details` elements preserve only their first rendered summary, closed `dialog` subtrees are removed, and open variants are unwrapped through the ordinary sanitizer +- Every subtree bearing a `popover` attribute is removed because static clipboard HTML cannot prove the source element's separate runtime showing state; empty, `auto`, `manual`, `hint`, and invalid-value forms are covered +- Native `progress` and `meter` widget subtrees and obsolete `noframes` and `noembed` fallback subtrees are removed +- Hidden `datalist` suggestion and down-level fallback subtrees are removed +- Raw `mso-hide` declarations are parsed with exact case-insensitive property/value matching, closed and EOF-terminated CSS-comment removal, optional terminal `!important`, and false-positive guards +- CSS-escaped property and keyword forms of `mso-hide: all` are decoded for exact comparison, while invalid or look-alike declarations remain visible +- SafeClipboard is the final ordinary TipTap paste transform through a low-priority ProseMirror plugin; a host that deliberately installs an even later transform remains outside the supported safety contract +- Unsafe and credential-bearing links are unwrapped while visible text remains; approved links retain only exact `href` and fixed `noopener noreferrer nofollow` +- IDs, classes, styles, event handlers, `data-*`, arbitrary ARIA, `contenteditable`, remote resources, local-file references, and Office/Google attributes never reach the output fragment +- UTF-8 bytes, traversed nodes, source depth, and configuration reflection are bounded and fail closed with static redacted errors +- Hostile direct-API `Document` accessors, proxies, and revoked proxies now fail closed as the stable `dom_unavailable` error before parsing, without exposing private capability exceptions + +### Performance +- Accepted clipboard traversal is iterative and linear in the bounded source tree; defaults are 1 MiB, 10,000 nodes, and 64 levels +- The feature adds no runtime dependency and performs no network, storage, clipboard-permission, model, provider, credential, or database operation + +### Tests +- Added realistic Word-like and Google-Docs-like fixtures, Office conditional comments, semantic conversion, structural, malformed, active-content, hidden-data, unsafe-link, resource-bound, hostile-configuration, DOM-unavailable, callback-failure, and error-redaction cases +- Added test-first regressions for closed interactive content, hidden popover state, native-widget fallback content, hidden `datalist` suggestions, `visibility: collapse`, and `content-visibility: hidden` +- Added a test-first direct-API regression proving hostile `createElement` and `implementation` property reads cannot leak arbitrary exception text +- Added RED integration commit `2e1d634bd819686b298256f9bac161e4f6e90067` and CI run `31172315841`, then real standalone, Yjs, competing-transform, and public-export tests against the complete ProseMirror paste pipeline +- Added standalone and Yjs integration tests, a 3,000-paragraph capacity fixture, final-transform ordering evidence, and repository-wide 100% production statement, branch, function, and line coverage gates +- Current jsdom results are not cross-engine browser evidence; version-pinned Chromium, Firefox, and WebKit differential fixtures remain a publication gate for 0.6.0 + +### Documentation +- Documented preserved and removed clipboard content, Base64Image handoff, SSR behavior, error codes, modular ownership, performance bounds, rollback, and buyer integration +- Documented `content-visibility: hidden` against CSS Containment Module Level 2, including exact raw-declaration matching, false-positive controls, test-first evidence, rollback, and the cross-engine assurance boundary +- Documented hidden popover content against the WHATWG runtime visibility-state model, including invalid-value behavior, deterministic loss boundary, test-first evidence, rollback, and cross-engine limitations +- Documented closed interactive, native-widget fallback, hidden suggestion-source, CSS escape, Office hidden-content, and final-transform composition decisions against primary specifications and official documentation +- Added an APA 7th doctoring record for DOM capability reflection redaction grounded in ECMAScript 2026 proxy abrupt-completion semantics, the WHATWG DOM capability surface, and OWASP generic-error guidance +- Added APA 7th doctoring for the TipTap v2.27.2 ProseMirror paste adapter, including locked-source authority, test-first root-cause evidence, priority ordering, residual host boundary, and rollback diff --git a/docs/clipboard-security.md b/docs/clipboard-security.md new file mode 100644 index 00000000..a7c841bb --- /dev/null +++ b/docs/clipboard-security.md @@ -0,0 +1,287 @@ +# Safe rich clipboard ingestion + +Inkspan sanitizes `text/html` clipboard content before TipTap/ProseMirror parses +or inserts it. The same policy is active in `CwlEditor` and +`CollaborativeCwlEditor`, so a host does not have to maintain separate paste +rules for local and Yjs-backed documents. + +## Default behavior + +The default limits are: + +| Limit | Default | +| --- | ---: | +| UTF-8 HTML bytes | 1,048,576 | +| Source nodes | 10,000 | +| Source depth | 64 | + +```tsx + { + // Safe for bounded telemetry or an accessible host notification. + console.warn(error.code); + }} +/> +``` + +The nested clipboard configuration is preserved by identity when the editor is +created and validated only when rich HTML is pasted. Inkspan copies no nested +configuration values and performs no nested spread, so the editor can be created +without evaluating accessors or proxy traps during editor construction. At the +paste boundary, Inkspan accepts only exact enumerable own data properties with +supported names and bounded positive safe-integer values. Accessors, symbols, +unknown keys, reflection failures, and malformed values fail closed with the +redacted `invalid_configuration` error. + +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. + +## Preserved structure + +Inkspan reconstructs a new fragment containing only: + +- paragraphs, divisions, headings, blockquotes, preformatted text, code, line + breaks, and horizontal rules; +- bold/strong, italic/emphasis, underline, strike, superscript, and subscript; +- ordered and unordered lists; +- tables, table sections, rows, header cells, and data cells; and +- hyperlinks that pass Inkspan's existing SafeLink URI policy. + +Word and Google Docs often represent visible semantics only through inline +styles. Inkspan converts four narrowly defined styles before discarding all +style attributes: + +- bold font weight → ``; +- italic or oblique font style → ``; +- underline → ``; and +- line-through → ``. + +No colors, fonts, sizes, backgrounds, positioning, hidden content, direction +overrides, or layout styles survive. + +## Hidden Office content + +Browser CSS object models do not expose every proprietary Office declaration +consistently. Inkspan therefore detects `mso-hide: all` from the bounded raw +`style` declaration rather than relying on `CSSStyleDeclaration` support for the +proprietary property. It removes closed CSS comments and a final comment that +runs to end of input, decodes bounded CSS escape sequences in the property name +and keyword value, compares both case-insensitively, tolerates ordinary +whitespace and a terminal `!important`, and requires the decoded property name +`mso-hide` and decoded value `all`. Escaped equivalents such as +`mso-\68 ide: \61ll` and EOF-commented `mso-hide: all/*` are hidden. Invalid +null, surrogate, out-of-range, trailing, or newline escapes fail to match instead +of being repaired. Values such as `none` or `alligator`, including escaped forms, +and properties such as `not-mso-hide`, remain visible and do not create false +hidden-subtree matches. The complete hidden subtree is dropped, and the source +style attribute is never copied to output. + +## Content containment hidden content + +Inkspan treats `content-visibility: hidden` as a complete hidden-subtree boundary +because the source document intentionally skips those descendants rather than +rendering them as ordinary content. Detection uses the same bounded raw `style` +declaration parser rather than CSSOM exposure, so supported case, whitespace, +terminal `!important`, CSS-comment, and CSS-escaped property or keyword forms +are handled consistently across controlled DOM environments. + +Only the exact decoded property `content-visibility` and exact decoded value +`hidden` match. Values such as `visible`, `auto`, and `hiddenly`, and prefixed +properties such as `not-content-visibility`, remain visible. The source style +attribute is discarded in every case. This policy prevents source-only text from +being unwrapped into ordinary editor prose while avoiding false positives for +content that the source leaves available or renders normally. The complete +decision, standards basis, compatibility boundary, and rollback are recorded in +`docs/doctoring/content-visibility-hidden-content.md`. + +## Closed interactive content + +HTML disclosure and dialog elements can contain text that is present in source +markup but not rendered to the user. Inkspan therefore treats their boolean +`open` state as part of the hidden-content boundary: + +- a closed `
` element preserves only the sanitized contents of its + first `` element child, when one exists, and drops the additional + information; +- a closed `
` element without a `` contributes no source text; +- an open `
` element unwraps and sanitizes its rendered summary and + additional information; +- a closed `` subtree is dropped completely; and +- an open `` unwraps and sanitizes its rendered contents. + +The interactive wrapper elements and every source attribute are removed in all +cases. This prevents pasted source-only disclosure or dialog text from becoming +ordinary visible editor content while retaining content that the source document +actually exposed. + +## Popover hidden content + +The HTML Living Standard defines `popover` as a global attribute whose element is +not rendered until a separate runtime visibility state becomes showing. Static +clipboard HTML retains the attribute but does not preserve whether the source +popover was showing when the content was copied. Removing the attribute and +unwrapping its descendants would therefore promote content hidden by default +into ordinary visible editor prose without evidence that the user saw it. + +Inkspan drops the complete subtree of every element that carries a `popover` +attribute. This includes the empty-value Auto state, the defined `auto`, `manual`, +and `hint` keywords, and unrecognized values because the standard maps an invalid +value to the Manual state. Inkspan does not execute invokers or script, call +`showPopover()`, or reconstruct top-layer state. A trusted host with stronger +source provenance may convert a verified visible popover before the content +reaches this untrusted boundary. The standards rationale, test-first evidence, +compatibility limitation, and rollback policy are recorded in +`docs/doctoring/popover-hidden-content.md`. + +## Native-widget, suggestion-source, and obsolete fallback content + +Current user agents render `` and `` as native progress and +gauge widgets from their attributes, while descendant text is intended as a +representation for user agents that do not support those elements. Inkspan does +not preserve the attributes required to reconstruct an equivalent accessible +widget. Unwrapping only the source element would therefore promote fallback text +to ordinary visible editor prose and change the source document's rendering +semantics. + +The HTML Living Standard also defines `` as a suggestion source for +another form control and states that the element and its children are hidden in +rendering. Its descendants can include fallback content for down-level clients. +Inkspan does not preserve the linked control or `list` relationship, so unwrapping +a `datalist` would promote hidden suggestions or legacy fallback text into +ordinary visible editor prose. + +Inkspan drops complete `progress`, `meter`, and `datalist` subtrees rather than +inventing partial conversions. It also drops complete `noframes` and `noembed` +subtrees: both elements are obsolete, and their expected default rendering does +not expose their descendants as ordinary page content. Ordinary content before +and after these elements remains intact. + +This is a fail-closed default, not a claim that fallback or suggestion text has +no value. A host that owns a trusted document format may perform an explicitly +reviewed, attribute-aware, accessible conversion before the content reaches the +untrusted clipboard boundary. See +`docs/doctoring/native-widget-fallback-content.md` and +`docs/doctoring/datalist-hidden-suggestion-content.md` for the decision records, +test-first evidence, references, residual risk, and rollback boundaries. + +## Removed content + +The sanitizer discards complete active, embedded, form, metadata, +resource-fetching, media, SVG, MathML, canvas, template, closed-dialog, +popover-hidden, native-widget fallback, hidden suggestion-source, obsolete +fallback, and hidden subtrees. Closed disclosure widgets retain only the first +rendered summary described above. It also removes comments, Office conditional +metadata, event handlers, IDs, classes, arbitrary ARIA, proprietary Office/Google +attributes, `data-*`, `contenteditable`, and unapproved link attributes. + +Rich HTML `` elements are always removed. This prevents remote tracking +pixels, local-file references, and unvalidated data URIs. A binary image copied +through the clipboard remains supported by the existing Base64Image pipeline, +which applies file type, byte, decode, dimension, and inline-source policies. + +Unsafe links are unwrapped while their visible text is retained. Safe links keep +only the exact `href` and receive +`rel="noopener noreferrer nofollow"`. Inkspan never trims or repairs an +untrusted link into a different browser interpretation. + +## Paste-transform ordering + +TipTap registers extension hooks by extension priority and chains +`transformPastedHTML` results. Inkspan assigns SafeClipboard the +lowest-practical TipTap extension priority so it is the final ordinary +`transformPastedHTML` transform in the shared extension set. A deterministic +integration regression installs a competing host transform that reintroduces an +image and script, then proves SafeClipboard receives that output last and removes +both before ProseMirror parsing. + +A deliberately hostile host can still install a lower-priority transform or +mutate the parsed transaction after the sanitizer. That lower-priority transform +is outside Inkspan's supported composition contract and voids the pre-parse +safety guarantee. Hosts must keep SafeClipboard as the final ordinary +`transformPastedHTML` transform and must subject any later transaction mutation +to an independently reviewed equivalent validation boundary. + +## Errors + +`ClipboardSanitizationError.code` is one of: + +```text +dom_unavailable +input_too_large +node_limit_exceeded +depth_limit_exceeded +invalid_configuration +invalid_html +``` + +Messages are static and do not contain the source HTML, URLs, clipboard text, +attribute values, private parser exceptions, tenant identifiers, or document +content. On rejection, the rich fragment becomes empty; Inkspan never falls back +to unsanitized HTML. + +## Direct sanitizer API + +Hosts can apply the exact same policy outside the React component: + +```ts +import { sanitizeRichClipboardHtml } from '@contextualwisdomlab/cwl-editor'; + +const safeHtml = sanitizeRichClipboardHtml(untrustedHtml); +``` + +The function needs a DOM-capable document at call time but does not touch DOM +globals at module import time. SSR can import Inkspan safely; invoke this API in +a browser, a jsdom-like controlled environment, or pass an explicit `Document` +as the third argument. + +## DOM capability failures + +The optional explicit `Document` is a runtime capability, not a trusted type +assertion. Inkspan treats property access and inert-document establishment as +executable behavior: accessors, proxies, revoked proxies, reflection failures +while selecting the ambient document or reading `createElement` or +`implementation.createHTMLDocument`, and failures while invoking +`createHTMLDocument()` fail closed with `dom_unavailable`. The original exception +is not returned, logged, persisted, or forwarded to the host observer. + +Inert-document creation failures remain `dom_unavailable` because parsing cannot +begin until that capability has succeeded. Once a usable inert document exists, +parsing, reconstruction, DOM mutation, or serialization failures remain +`invalid_html`. This preserves a stable operator distinction between unavailable +or hostile DOM capability and failure while processing clipboard HTML, without +exposing which property, adapter, proxy, factory, tenant, or private exception +caused rejection. See `docs/doctoring/dom-capability-reflection-redaction.md` for +test-first evidence, standards interpretation, residual risk, and rollback. + +## Browser evidence boundary + +The current deterministic corpus runs in jsdom and proves the repository's +allowlist, bounds, error redaction, integration wiring, transform ordering, and +known Office/Google fixtures. It does not by itself prove parser, CSS, or +serialization parity across Chromium, Firefox, and WebKit. The doctoring record +therefore treats cross-engine differential execution as a release-acceptance +gate for the future 0.6.0 publication rather than claiming browser conformance +from jsdom evidence. + +## Ownership boundary + +Inkspan owns clipboard HTML validation, semantic reconstruction, shared editor +integration, bounded errors, and deterministic tests. The host still owns: + +- clipboard permissions or custom clipboard APIs; +- user notification and recovery UX; +- authentication, authorization, and tenant isolation; +- persistence, retention, audit storage, and data residency; +- downstream HTML rendering and Content Security Policy; +- extension ordering outside the supported shared kit; +- model or AI use of pasted content; and +- legal, privacy, and information-governance policy. + +The feature introduces no network request, storage adapter, credential, +database object, model call, or provider dependency. diff --git a/docs/doctoring/closed-interactive-content.md b/docs/doctoring/closed-interactive-content.md new file mode 100644 index 00000000..9290d599 --- /dev/null +++ b/docs/doctoring/closed-interactive-content.md @@ -0,0 +1,99 @@ +# Closed interactive clipboard content + +## Status + +Implemented for the unreleased SafeClipboard boundary. Publication remains gated +on exact-current-head CI, security scanning, independent review, branch +protection, and the separately documented cross-engine corpus for 0.6.0. + +## Problem statement + +A rich clipboard payload can contain HTML whose source text is not visible in the +source document. Before this repair, Inkspan unwrapped unsupported interactive +containers and traversed every child. That behavior made the additional content +of a closed `details` element and the complete contents of a closed `dialog` +visible as ordinary editor text. + +This was a deterministic confidentiality and semantic-integrity defect. The +source markup did not grant the hidden text authority to become visible merely +because the target editor does not preserve the interactive wrapper. + +## Primary normative evidence + +The WHATWG HTML Living Standard defines the first `summary` element child as the +summary for a `details` element and the remaining contents as additional +information. Presence of the boolean `open` attribute means both the summary and +the additional information are shown. It also states that a `dialog` element +without an `open` attribute should not be shown to the user. + +Those rendering semantics are part of the sanitizer's hidden-content boundary. +Inkspan does not copy either interactive element or any of its attributes; it +uses only the source element name, the presence of `open`, and the first summary +child to decide which source subtree was rendered. + +## Decision + +SafeClipboard applies these deterministic rules before ordinary allowlist +reconstruction: + +1. A closed `dialog` contributes no subtree. +2. An open `dialog` is unwrapped and its children are sanitized normally. +3. A closed `details` contributes only its first `summary` element child, when + present; that summary is itself processed through all ordinary hidden, + active-content, link, style, depth, and node rules. +4. A closed `details` without a summary contributes no source text. Inkspan does + not invent a user-agent fallback label. +5. An open `details` is unwrapped and all children are sanitized normally. +6. The `details`, `summary`, and `dialog` wrappers and all source attributes are + absent from output. + +The implementation remains iterative. Skipped hidden subtrees are not traversed, +which avoids work on content that cannot enter output; the original UTF-8 byte +limit still bounds the complete source payload. + +## Test-first evidence + +- RED commit `eebf18a623b7702e55995c4d406fe07203edfd39` added a realistic closed/open + `details` and `dialog` regression. +- Exact-head CI run `31042227181` failed only the new disclosure test and showed + closed details, summaryless details, and closed dialog text leaking into the + sanitized fragment. +- GREEN implementation commit `e84261e74dcb07ed6afec9a934adcf5b8b1e41e3` + added state-aware iterative traversal without introducing a dependency, + network call, storage surface, credential, database object, model call, or + host-policy responsibility. + +Final acceptance evidence must be anchored to the current pull-request head, not +to either historical TDD commit. + +## Security and accessibility considerations + +Preserving the first summary of a closed disclosure retains the label that the +source exposed while preventing hidden supplementary information from becoming +visible. Dropping source-only dialog content avoids disclosure of text that the +source document did not show. Open variants retain their rendered content but +lose interactive semantics because Inkspan's deterministic document schema does +not claim to preserve those widgets. + +Hosts that need live disclosure or dialog controls must create authorized target +widgets outside this clipboard conversion boundary. They must not infer control +authority, dialog modality, focus management, or event behavior from pasted +markup. + +## Uncertainty boundary + +The repository regression runs in jsdom. It proves Inkspan's own reconstruction +rules, but it is not represented as Chromium, Firefox, or WebKit conformance. +The 0.6.0 release gate still requires version-pinned cross-engine differential +fixtures for accepted and rejected clipboard cases. + +## Rollback + +Rollback is the removal of the state-aware branches and their tests, docs, and +changelog entry. Such a rollback reopens the hidden-text exposure and therefore +requires an explicit security decision; silently restoring unconditional +unwrapping is not acceptable. + +## References + +WHATWG. (2026). *HTML Living Standard: Interactive elements*. https://html.spec.whatwg.org/multipage/interactive-elements.html diff --git a/docs/doctoring/content-visibility-hidden-content.md b/docs/doctoring/content-visibility-hidden-content.md new file mode 100644 index 00000000..74e35a07 --- /dev/null +++ b/docs/doctoring/content-visibility-hidden-content.md @@ -0,0 +1,114 @@ +# Doctoring record: `content-visibility: hidden` clipboard content + +## Decision + +Inkspan treats an exact decoded inline declaration of +`content-visibility: hidden` as a complete hidden-subtree boundary during rich +clipboard HTML reconstruction. The element and all descendants are omitted +before ProseMirror parses the sanitized fragment. + +This decision applies only to the bounded untrusted clipboard-ingress surface. +It does not change host rendering policy, Content Security Policy, persistence, +authorization, collaboration transport, model use, or tenant governance. + +## Standards rationale + +CSS Containment Module Level 2 defines `content-visibility` with the values +`visible`, `auto`, and `hidden`. For `hidden`, the element skips its contents; +the specification states that skipped contents must not be available to +user-agent features such as find-in-page and tab-order navigation, and must not +be selectable or focusable. It also explains that descendants cannot override +the ancestor and make themselves visible while the ancestor remains +`content-visibility: hidden`. + +Unwrapping such an element while discarding its source `style` attribute would +therefore change source-only descendants into ordinary visible editor prose. A +complete subtree drop is the narrow deterministic conversion that preserves the +source's hidden-content intent without attempting to reproduce browser layout +or containment state inside the editor schema. + +The cited W3C document is a Working Draft and is work in progress. This record +uses it as the current primary specification for the property, not as evidence +that Inkspan or any deployed host conforms to the complete specification. + +## Implementation boundary + +The sanitizer reads the bounded raw `style` attribute rather than relying on +engine-specific CSSOM exposure. It removes closed CSS comments and a final +comment that runs to end of input, decodes bounded CSS escape sequences, trims +ordinary whitespace, strips one terminal `!important`, and compares the decoded +property and value case-insensitively. + +A subtree is removed only when both comparisons are exact: + +- property: `content-visibility`; +- value: `hidden`. + +Supported equivalent forms include case and whitespace variation, comments +inside the property or keyword, and bounded CSS escapes. Values `visible`, +`auto`, and `hiddenly`, and property names such as `not-content-visibility`, do +not match. Invalid null, surrogate, out-of-range, trailing, or newline escapes +also do not match. The original `style` attribute is never copied to output. + +The same parser retains the existing exact `mso-hide: all` boundary, preventing +two competing hidden-style parsers from drifting. + +## Test-first evidence + +The regression was introduced before the production repair: + +- RED commit `3e369cb33c1716fc2795493747b28d2b8b7a7648` added ordinary, case, + whitespace, terminal `!important`, CSS-comment, CSS-escaped, and false-positive + cases; +- GREEN commit `28df0b99de3eeeacb7a3d696b834de0e832c2f13` generalized the bounded + raw-style hidden-declaration parser and made the regression pass. + +The deterministic test asserts that the four supported hidden forms contribute +no text while `visible`, `auto`, `hiddenly`, and prefixed-property controls remain +visible. Repository acceptance still requires exact-current-head TypeScript, +100% production statement/branch/function/line coverage, packaging, security, +SAST, automated review, independent approval, and branch-protection evidence. + +## Security and privacy effect + +Dropping the subtree prevents source-only or deliberately suppressed text from +being promoted into visible editor content after the source style is discarded. +This reduces accidental disclosure, misleading review output, and semantic +changes in copied material. It does not authorize the remaining content or make +it safe for a different output context; SafeLink, image, schema, host +authorization, persistence, and downstream rendering controls remain separate. + +No clipboard content, declaration value, tenant identifier, credential, or +parser exception is added to telemetry. Rejections continue to use bounded, +redacted error categories. + +## Compatibility and assurance boundary + +The repository regression runs in jsdom and proves Inkspan's own raw-declaration +parser and reconstruction policy. It is not browser-conformance evidence. A +Cross-engine Chromium, Firefox, and WebKit differential corpus remains a +release-acceptance gate before publication of the default behavior in version +0.6.0. + +The policy intentionally does not drop `content-visibility: auto`. That value +can skip rendering based on relevance while retaining availability to user-agent +features, so treating it as always hidden would create false-positive data loss. + +## Rollback + +Rollback must remove the `content-visibility: hidden` matcher, its regression, +this record, operator guidance, documentation contract, and changelog entry as +one change. It must not weaken the existing `mso-hide: all`, `display: none`, +`visibility: hidden`, `visibility: collapse`, HTML `hidden`, or +`aria-hidden="true"` boundaries. Rollback requires the same exact-head review, +coverage, security, packaging, and independent-approval gates as the original +change. + +## References (APA 7th edition) + +World Wide Web Consortium. (2022, September 17). *CSS Containment Module Level +2* (W3C Working Draft). https://www.w3.org/TR/2022/WD-css-contain-2-20220917/ + +World Wide Web Consortium. (n.d.). *CSS Containment Module Level 2: Latest +published version*. Retrieved August 6, 2026, from +https://www.w3.org/TR/css-contain-2/ diff --git a/docs/doctoring/css-escaped-office-hidden-content.md b/docs/doctoring/css-escaped-office-hidden-content.md new file mode 100644 index 00000000..a451b4e5 --- /dev/null +++ b/docs/doctoring/css-escaped-office-hidden-content.md @@ -0,0 +1,76 @@ +# Doctoring addendum: CSS-escaped Office hidden content + +- **Status:** Accepted +- **Decision date:** 2026-08-05 +- **Parent record:** `safe-rich-clipboard.md` +- **Scope:** Recognition of CSS-escaped `mso-hide: all` declarations in bounded clipboard HTML + +## Problem + +The first raw-style parser handled case, whitespace, comments, and terminal +`!important`, but compared the proprietary property and keyword before decoding +CSS escape sequences. CSS Syntax Level 3 permits escaped code points inside +identifiers. Therefore an Office-compatible declaration such as +`mso-\68 ide: \61ll` could be semantically equivalent to `mso-hide: all` while +avoiding the exact raw comparison. Because Inkspan removes the source style +attribute, failing to recognize that declaration would convert hidden clipboard +content into visible editor text rather than preserving the source's hidden +state or dropping it. + +The same tokenizer defines a CSS comment as continuing through the first closing +`*/` or the end of input. An EOF-terminated declaration such as +`mso-hide: all/*` is a parse error, but its trailing comment content is still +consumed. A remover that recognizes only closed comments would retain the `/*` +suffix, fail the exact `all` comparison, and disclose text that the source marked +as hidden. + +## Decision + +Decode only the bounded CSS escape grammar needed for exact property and keyword +comparison before testing for `mso-hide` and `all`. Remove both closed comments +and a final EOF-terminated comment before performing that exact comparison. + +The decoder: + +- accepts a simple escape of one non-newline code point; +- accepts one through six hexadecimal digits and consumes at most one following + CSS whitespace code point; +- rejects null, surrogate, and code points above U+10FFFF; +- rejects trailing escapes and escaped newlines; and +- never repairs an invalid escape into a hidden-content match. + +After decoding, the property and value are compared case-insensitively against +the exact strings `mso-hide` and `all`. Other properties and values, including +escaped `alligator`, remain visible. No decoded source style is copied into the +output fragment. + +## Security and performance consequences + +This closes hidden-data disclosure paths without adding a CSS parser, runtime +dependency, network operation, credential, provider, storage adapter, or database +object. Work remains linear in the already bounded inline-style length. The +helper allocates one decoded string per tested property or value and reads at +most six hexadecimal digits for each escape. + +The decoder and comment remover are intentionally narrower than a complete CSS +declaration parser. Cross-engine differential browser fixtures remain required +before the 0.6.0 publication claim, especially for malformed declarations, +tokenizer recovery, and proprietary Office rendering behavior. + +## Verification + +Deterministic regressions cover ordinary, case-varied, closed-commented, +EOF-commented, hexadecimal, simple, and six-digit escaped hidden declarations. +They also cover escaped `alligator`, prefixed properties, null, surrogate, +out-of-range, trailing, and newline escapes so false positives and invalid repair +remain fail-closed. + +Repository-wide exact-head TypeScript, 100% production statement/branch/function/ +line coverage, package, Office, security, review, and release gates remain +authoritative. + +## APA 7 reference + +CSS Working Group. (2021, December 24). *CSS Syntax Module Level 3* (W3C +Candidate Recommendation Draft). World Wide Web Consortium. +https://www.w3.org/TR/css-syntax-3/ diff --git a/docs/doctoring/datalist-hidden-suggestion-content.md b/docs/doctoring/datalist-hidden-suggestion-content.md new file mode 100644 index 00000000..2d10ab63 --- /dev/null +++ b/docs/doctoring/datalist-hidden-suggestion-content.md @@ -0,0 +1,77 @@ +# Doctoring record: hidden datalist suggestion content + +- **Status:** Accepted +- **Decision date:** 2026-08-06 +- **Scope:** SafeClipboard semantic reconstruction +- **Runtime change:** Drop complete `datalist` subtrees from rich clipboard HTML + +## Problem + +SafeClipboard unwraps unknown elements so ordinary semantic text is not lost only +because Inkspan does not preserve a source wrapper. That general rule is unsafe +for `datalist`. The HTML Living Standard defines `datalist` as a suggestion +source for another form control and states that, in rendering, the element +represents nothing and it and its children should be hidden. The standard also +allows fallback descendants for down-level clients that do not support +`datalist`. + +Removing only the wrapper therefore creates a deterministic visibility expansion: +suggestion labels or legacy fallback content that a current conforming browser +does not render as ordinary page prose can become visible editor text. Clipboard +input is untrusted, so that hidden region can contain confidential, misleading, +or workflow-inappropriate content. + +## Decision + +Drop the complete `datalist` subtree before semantic reconstruction. This removes +its `option` suggestions and any down-level fallback descendants together. +Inkspan does not preserve the linked form control, `list` relationship, suggestion +semantics, or legacy-client context needed to create an equivalent accessible +conversion. It must not invent ordinary prose from those hidden descendants. + +The decision is provider-neutral and applies identically to standalone and Yjs +collaborative editors through the shared SafeClipboard extension. It introduces +no network, storage, credential, model, database, transport, authorization, +tenancy, migration, retention, or host-policy behavior. + +## Test-first evidence + +- RED `a48d81eac541effea5d7742a47c0ca6bd01d04ed` added a mixed fragment containing + visible paragraph text, direct `datalist` fallback text, and an `option` label. + Exact-head CI run `31055341495` failed as intended because the direct fallback + text was promoted into the sanitized fragment. Security Scan `31055341447` and + SAST Semgrep `31055341617` succeeded on the same head. +- GREEN `ec5d602bcf4f8e82fcc3136a82e354816913f6df` added `datalist` to the complete- + subtree denylist without changing ordinary paragraph preservation. +- The permanent regression proves visible neighboring content remains while the + fallback text, option text, `datalist`, and `option` are absent. + +Repository-wide exact-head TypeScript, 100% production statement/branch/function/ +line coverage, package-consumer, SSR, Office, security, SAST, automated review, +independent approval, and branch-protection gates remain authoritative. + +## Claim boundary and residual risk + +This change preserves the current-browser rendered visibility boundary for one +specified element. It does not claim that jsdom has parser, rendering, CSS, or +serialization parity with Chromium, Firefox, and WebKit. The version-pinned +cross-engine differential corpus remains a release-acceptance gate for 0.6.0. + +Dropping the subtree can discard content intended for legacy clients. That loss +is intentional because Inkspan cannot preserve the source form relationship or +prove which client context the author intended. A trusted format-specific import +may implement an independently reviewed accessible conversion before content +reaches the untrusted clipboard boundary. + +## Rollback + +Revert the source, regression, operator guidance, changelog evidence, and this +record together. Do not restore generic unwrapping for `datalist` without a +reviewed conversion that preserves equivalent suggestion and accessibility +semantics across supported engines. + +## References — APA 7th edition + +WHATWG. (2026, July 15). *HTML Living Standard: The datalist element*. https://html.spec.whatwg.org/multipage/form-elements.html#the-datalist-element + +WHATWG. (2026, July 15). *HTML Living Standard: The input element*. https://html.spec.whatwg.org/multipage/input.html diff --git a/docs/doctoring/dom-capability-reflection-redaction.md b/docs/doctoring/dom-capability-reflection-redaction.md new file mode 100644 index 00000000..c514c251 --- /dev/null +++ b/docs/doctoring/dom-capability-reflection-redaction.md @@ -0,0 +1,119 @@ +# Doctoring record: DOM capability reflection redaction + +**Date:** 2026-08-06 +**Target release:** Pending Inkspan 0.6.0 +**Decision owner:** ContextualWisdomLab +**Scope:** The explicit or ambient `Document` capability accepted by `sanitizeRichClipboardHtml()`. + +## Buyer-visible risk + +Inkspan exposes a direct sanitizer API so a controlled host can provide a DOM +`Document` in browsers, SSR-adjacent test environments, and isolated conversion +surfaces. That host-supplied value crosses the same untrusted boundary as the +clipboard payload. A JavaScript accessor, revoked proxy, proxy `get` trap, or +failing DOM factory can throw while Inkspan establishes the inert parsing +capability. + +Before this repair, hostile capability reads could propagate arbitrary host +exceptions and disclose private implementation, tenant, adapter, or test-fixture +text even though the public contract promises stable content-free errors. + +## Test-first evidence + +- RED commit `486846ae88710b87458712cf8da80eb5677cf031` added two deterministic hostile + `Document` proxies: one throws while `createElement` is read and one throws + while `implementation` is read. +- Exact-head CI run `31087687909` failed only at + `SafeClipboard.domReflection.test.ts`, proving the first private exception was + propagated instead of becoming `ClipboardSanitizationError`. +- GREEN commit `e4387658f3b3b936967fd9b0101203dbdc25c55a` moved ambient-document selection + and capability inspection into one fail-closed boundary. Abrupt completion + while resolving the required DOM capability becomes the stable + `dom_unavailable` code and message. +- Later exact-head CI exposed a stale regression expectation that treated a + throwing `createHTMLDocument()` call as malformed clipboard HTML even though + no inert parsing document had been established. The corrected contract keeps + inert-document establishment inside the DOM-capability boundary: property + access and the factory invocation both fail as `dom_unavailable`; only failures + after a usable inert document exists are classified as `invalid_html`. + +Parsing and traversal remain a separate boundary. Once inert-document creation +succeeds, unexpected template parsing, reconstruction, DOM mutation, or +serialization failure becomes `invalid_html`; already classified resource-limit +errors retain their existing stable codes. + +## Standards interpretation + +ECMAScript defines proxy `[[Get]]` as returning either a normal completion or a +throw completion. Property access is therefore executable behavior and cannot be +assumed to be a passive shape check. The regression exercises the exact +`createElement` and `implementation` reads used by Inkspan. + +The WHATWG DOM Standard defines `Document.implementation` and +`DOMImplementation.createHTMLDocument()`, but a host-provided object is not +trusted merely because TypeScript types it as `Document`. Runtime capability +validation remains necessary at the public JavaScript boundary. A callable +property is not yet a usable capability until the factory invocation succeeds +and returns the inert document required by the sanitizer. + +OWASP error-handling guidance recommends generic externally observable errors +rather than exposing internal exception details. Inkspan applies that principle +locally: it does not log, transport, persist, or return the original exception; +it returns only the bounded product error category. + +## Security and compatibility decision + +The selected classification is `dom_unavailable` for both hostile capability +resolution and inert-document creation failure because both occur before a usable +inert parsing document has been established. This gives operators a stable +distinction between missing/unsafe DOM capability and malformed or failing HTML +processing after parsing capability exists, without revealing which property, +proxy, adapter, factory, or private exception caused rejection. + +The repair does not: + +- accept a previously rejected `Document` shape; +- invoke getters to extract diagnostic detail; +- fall back to unsanitized or plain HTML; +- add network, persistence, credential, tenant, model, database, or scheduler + behavior; or +- change the standalone versus host-owned MSA responsibility boundary. + +A hostile `Document` can still consume time inside a capability function after it +has been obtained. Hosts must provide a controlled DOM implementation; Inkspan's +byte, node, and depth ceilings bound clipboard traversal but cannot preempt +arbitrary host code. + +## Rollback boundary + +Rollback is safe only by reverting the focused implementation and regressions +together. Removing the catch while retaining the direct `Document` override would +restore exception disclosure and contradict `docs/clipboard-security.md`. +Changing the public error code requires a separately reviewed compatibility +change because hosts may use the stable code for accessible recovery or bounded +telemetry. + +## Acceptance gates + +- hostile `createElement`/`implementation` reads and a throwing + `createHTMLDocument()` invocation produce `ClipboardSanitizationError` with the + exact `dom_unavailable` code and message; +- no private exception string appears in the public error; +- malformed and absent DOM capability tests continue to pass; +- failures after successful inert-document establishment remain `invalid_html`; +- repository production statement and branch coverage remain 100%; and +- exact-head CI, security, Semgrep, review, independent approval where actually + required, and branch protection all succeed before merge or release. + +## References + +Ecma International. (2026). *ECMAScript® 2026 language specification* +(ECMA-262, 17th ed.), § 10.5.8, Proxy `[[Get]]`. +https://tc39.es/ecma262/2026/multipage/ordinary-and-exotic-objects-behaviours.html#sec-proxy-object-internal-methods-and-internal-slots-get-p-receiver + +OWASP Foundation. (n.d.). *Error handling cheat sheet*. OWASP Cheat Sheet +Series. Retrieved August 6, 2026, from +https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html + +WHATWG. (2026). *DOM standard*, § 4.5.1, Interface `DOMImplementation`. +https://dom.spec.whatwg.org/#interface-domimplementation diff --git a/docs/doctoring/native-widget-fallback-content.md b/docs/doctoring/native-widget-fallback-content.md new file mode 100644 index 00000000..dcfc0f46 --- /dev/null +++ b/docs/doctoring/native-widget-fallback-content.md @@ -0,0 +1,97 @@ +# Doctoring record: native-widget and obsolete fallback content + +- **Status:** Accepted +- **Decision date:** 2026-08-05 +- **Scope:** SafeClipboard semantic reconstruction +- **Runtime change:** Drop complete `progress`, `meter`, `noframes`, and `noembed` + subtrees from rich clipboard HTML + +## Problem + +SafeClipboard unwraps unknown elements so that ordinary semantic text is not lost +merely because Inkspan does not preserve a source wrapper. That general rule is +unsafe for elements whose descendant text is not the ordinary rendered surface. + +Modern user agents render `progress` and `meter` as native progress and gauge +widgets derived primarily from their attributes. The HTML Standard encourages +inline descendant text for users of legacy user agents that do not support those +elements. Removing only the wrapper therefore changes the source rendering +contract: legacy fallback text that a current browser ordinarily represents as a +widget can become ordinary visible editor prose. + +The same semantic-integrity problem applies to obsolete `noframes` and `noembed` +content. The HTML Standard classifies both elements as entirely obsolete, and its +expected default rendering hides their subtrees. Unwrapping them would surface +content that the source document did not ordinarily display. + +Clipboard input is untrusted and may contain confidential, misleading, or +tracking-oriented text in these fallback regions. Inkspan must not invent a new +visible representation by stripping only the element boundary. + +## Decision + +Drop the complete subtree for: + +- `progress`; +- `meter`; +- `noframes`; and +- `noembed`. + +This is deliberately fail-closed. Inkspan does not preserve the widget attributes +needed to reconstruct an equivalent accessible progress or gauge representation, +and it must not infer which descendant text a source author intended current +versus legacy user agents to expose. Hosts that need a trusted business-specific +conversion can transform a known document format before it reaches the untrusted +clipboard boundary. + +The change introduces no network, storage, credential, model, database, +collaboration, or host-policy behavior. It applies identically to standalone and +provider-neutral collaborative editors through the shared SafeClipboard +extension. + +## Test-first evidence + +- RED `6e6d48ff8a377bab875d6a520580e65d3489f78e` added a realistic mixed fragment + and proved that the existing unknown-element unwrapping surfaced all four + fallback texts. Exact-head CI run `31047047213` failed as intended while + Security Scan `31047046529` and SAST Semgrep `31047045777` remained successful. +- GREEN `01683cc89faadfcacf05f358e34b6a1812e61e77` added the four elements to the + complete-subtree denylist without changing ordinary paragraph preservation. +- The permanent regression parses the sanitized fragment, proves ordinary visible + content remains, proves every fallback text is absent, and proves none of the + source wrapper elements survives. + +Repository-wide exact-head TypeScript, 100% production statement/branch/function/ +line coverage, package-consumer, SSR, Office, security, SAST, automated review, +independent approval, and branch-protection gates remain authoritative. + +## Claim boundary and residual risk + +This decision prevents one deterministic source-to-editor visibility expansion. +It does not claim that jsdom has parser, rendering, CSS, or serialization parity +with Chromium, Firefox, and WebKit. The version-pinned cross-engine differential +corpus remains a release-acceptance gate for 0.6.0. + +Dropping these subtrees can discard text that a legacy or specialized user agent +might expose. That loss is intentional: preserving untrusted text without the +source widget semantics would be a different and potentially misleading +representation. A future feature may add an explicitly reviewed, attribute-aware, +accessible conversion contract, but it must not weaken this default fail-closed +boundary. + +## Rollback + +Revert the source, regression, operator documentation, changelog, and this record +together. Do not restore generic unwrapping for these elements without a reviewed +accessible conversion that preserves equivalent semantics across supported +engines and retains the exact-head security and coverage gates. + +## References — APA 7th edition + +WHATWG. (2026, July 15). *HTML Living Standard: The meter element*. https://html.spec.whatwg.org/multipage/form-elements.html#the-meter-element + +WHATWG. (2026, July 15). *HTML Living Standard: The progress element*. https://html.spec.whatwg.org/multipage/form-elements.html#the-progress-element + +WHATWG. (2026, July 15). *HTML Living Standard: Obsolete features*. https://html.spec.whatwg.org/multipage/obsolete.html + +WHATWG. (2026, July 15). *HTML Living Standard: Rendering*. https://html.spec.whatwg.org/multipage/rendering.html diff --git a/docs/doctoring/popover-hidden-content.md b/docs/doctoring/popover-hidden-content.md new file mode 100644 index 00000000..54ebb51a --- /dev/null +++ b/docs/doctoring/popover-hidden-content.md @@ -0,0 +1,107 @@ +# Doctoring record: hidden popover clipboard content + +## Decision + +Inkspan drops the complete subtree of every element that carries a `popover` +content attribute during rich clipboard HTML reconstruction. The element and all +descendants are omitted before ProseMirror parses the sanitized fragment. + +This decision applies only to the bounded untrusted clipboard-ingress surface. +It does not change host rendering policy, authorization, tenant isolation, +persistence, collaboration transport, model use, retention, or audit policy. + +## Standards rationale + +The WHATWG HTML Living Standard defines `popover` as a global enumerated +attribute. When the attribute is specified, the element is not rendered until it +becomes shown. The empty value defaults to the Auto state, recognized keywords +include `auto`, `manual`, and `hint`, and an invalid value defaults to the Manual +state. Each element also has a separate runtime popover visibility state that is +initially hidden and can later become showing. + +Static clipboard HTML retains the content attribute but does not carry that +runtime visibility state. Therefore, Inkspan cannot prove from the serialized +fragment that a popover subtree was visible when copied. Unwrapping the element +while discarding its attribute would promote content that is hidden by default +into ordinary visible editor prose. Dropping the subtree is the deterministic, +fail-closed conversion that avoids inventing visibility evidence. + +This is an Inkspan security-policy inference grounded in the standard's +attribute and runtime-state model. It is not a claim that every clipboard source +serializes popovers identically or that jsdom implements the complete Popover +API. + +## Implementation boundary + +`isHiddenClipboardElement()` checks for the presence of the `popover` content +attribute before CSSOM or raw-style checks. The value is intentionally not +whitelisted: + +- an empty attribute is an Auto popover; +- `auto`, `manual`, and `hint` are defined popover states; and +- an unrecognized value uses the standard's invalid-value default, Manual. + +The complete subtree is dropped. Inkspan does not copy the attribute, attempt to +reconstruct top-layer state, evaluate invoker relationships, execute script, or +call `showPopover()`. Ordinary elements without the attribute continue through +the existing semantic allowlist. + +## Test-first evidence + +The regression was introduced before the production repair: + +- RED commit `d69575ead51f734de974c459aa2b5e710bb3e134` added default, + `auto`, `manual`, and `hint` popover cases and failed exact-head CI because all + hidden descendants were promoted into visible output; +- GREEN commit `ee14bdb6a9c651d8921a5aa425081aad241d2f49` added the + attribute-presence hidden-subtree boundary and made those cases pass; and +- commit `0af1410af60489bd282e97619ea0ff482573380a` added an + unrecognized-value case to prove the invalid-value default is also removed. + +Repository acceptance still requires exact-current-head TypeScript, 100% +production statement/branch/function/line coverage, packaging, security, SAST, +automated review, independent approval, and branch-protection evidence. + +## Security and privacy effect + +The policy prevents default-hidden popover text from being silently converted +into visible editor content after the runtime state and attribute are lost. This +reduces accidental disclosure, misleading semantic review, and source-to-editor +rendering drift. It does not authorize the remaining content or make it safe for +another output context; SafeLink, image, schema, host authorization, persistence, +and downstream rendering controls remain separate. + +No clipboard content, attribute value, tenant identifier, credential, or parser +exception is added to telemetry. Rejections continue to use bounded, redacted +error categories. + +## Compatibility and assurance boundary + +The deterministic regression runs in jsdom and proves Inkspan's own +attribute-presence policy. It is not Chromium, Firefox, or WebKit conformance +evidence. A version-pinned cross-engine differential corpus remains a +release-acceptance gate before publication of the default behavior in version +0.6.0. + +The fail-closed policy can discard content from a popover that was showing at the +moment a user copied it because static HTML does not preserve that runtime fact. +A trusted host that owns richer provenance may convert a verified visible +popover to ordinary semantic content before it reaches the untrusted clipboard +boundary. + +## Rollback + +Rollback must remove the `popover` attribute boundary, its regression, this +record, operator guidance, documentation contract, and changelog entry as one +change. It must not weaken the existing `hidden`, `aria-hidden`, CSS hidden, +closed-dialog, closed-details, Office hidden-content, or content-containment +boundaries. Rollback requires the same exact-head review, coverage, security, +packaging, and independent-approval gates as the original change. + +## References (APA 7th edition) + +WHATWG. (2026, July 20). *HTML Living Standard: The popover attribute*. +https://html.spec.whatwg.org/multipage/popover.html + +WHATWG. (n.d.). *HTML Living Standard: The popover attribute*. Retrieved August +6, 2026, from https://html.spec.whatwg.org/multipage/popover.html diff --git a/docs/doctoring/safe-rich-clipboard.md b/docs/doctoring/safe-rich-clipboard.md new file mode 100644 index 00000000..e8664604 --- /dev/null +++ b/docs/doctoring/safe-rich-clipboard.md @@ -0,0 +1,300 @@ +# Doctoring record: Safe rich clipboard ingestion + +**Date:** 2026-08-05 +**Target release:** Unreleased after Inkspan 0.5.29 +**Decision owner:** ContextualWisdomLab +**Scope:** Browser-provided `text/html` clipboard content before TipTap/ProseMirror parsing. + +## Decision summary + +Inkspan reconstructs rich clipboard HTML into a new allowlisted fragment before +ProseMirror parses it. The sanitizer is enabled by default through the shared +extension kit and therefore applies identically to the standalone React editor +and provider-neutral Yjs collaborative editor. + +The selected implementation uses a detached HTML template and iterative tree +reconstruction rather than trusting source nodes or applying regular expressions +to raw HTML. The boundary preserves only Inkspan-supported semantic content, +enforces explicit resource ceilings, reuses the existing SafeLink URI policy, +removes every HTML image and resource-bearing element, and reports only stable +redacted errors. + +The implementation remains a deliberately narrow bespoke sanitizer rather than +a claim that OWASP endorses this code. OWASP recommends maintained HTML +sanitization, specifically naming DOMPurify, and warns that modification after +sanitization can void the protection. The no-new-runtime-dependency decision is +therefore paired with explicit transform-order, differential-browser, corpus, +patch-response, and vulnerability-response obligations recorded below. + +## Buyer-visible gap + +Enterprise users paste from Microsoft Word, Google Docs, email clients, support +systems, wikis, and arbitrary web pages. Those sources commonly include Office +conditional metadata, proprietary classes, style-only formatting, hidden text, +remote images, tracking pixels, forms, embedded objects, event handlers, and +markup far larger or deeper than the visible document. + +Before this change, Inkspan's ProseMirror schema, SafeLink transaction filter, +and Base64Image source policy were downstream protections, but the product did +not define a complete pre-parse clipboard trust boundary. Buyers therefore had +to add inconsistent host-specific sanitizers or accept ambiguous paste behavior. + +## Standards interpretation + +The W3C *Clipboard API and events* Working Draft dated 24 June 2026 defines +clipboard event behavior and identifies security and privacy risks for HTML and +multi-part clipboard data. A paste event occurs before insertion and is +cancelable. Browser provenance is not evidence that supplied HTML is safe for a +product editor. The document is a Working Draft rather than a Recommendation; +W3C explicitly identifies it as a work in progress that may be updated, +replaced, or obsoleted. + +TipTap exposes extension-level `transformPastedHTML` before pasted HTML is parsed +and inserted. Current TipTap extension documentation also states that extensions +are sorted by priority, higher priority runs first, every transform receives the +prior transform's output, and the final transformed HTML is parsed. Inkspan +therefore assigns SafeClipboard the lowest-practical TipTap extension priority so +it is the final ordinary `transformPastedHTML` transform in the supported shared +kit. The exact TipTap extension priority and TipTap transformPastedHTML contracts +are represented in deterministic integration tests rather than inferred from +extension array order alone. + +A host can deliberately install a lower-priority transform or mutate the parsed +transaction later. Such a lower-priority transform is outside the supported +composition contract because it can reintroduce unsafe markup after +SafeClipboard. The host must preserve SafeClipboard as the final ordinary +`transformPastedHTML` transform or provide and independently verify an equivalent +later validation boundary. + +OWASP's XSS prevention guidance recommends HTML sanitization when untrusted rich +HTML must remain HTML, recommends DOMPurify, warns against modification after +sanitization, and requires regular sanitizer patching because browsers and +bypasses change. Inkspan follows the positive-allowlist principle but does not +claim parity with DOMPurify's maturity or vulnerability-response history. + +WHATWG HTML fragment parsing supplies defined error recovery for malformed +markup. A detached template is treated only as an input tree. Inkspan never +inserts that source tree, invokes scripts, follows resources, or copies arbitrary +attributes from it. + +## Rejected alternatives + +### Trust schema parsing alone + +Rejected because it does not state or test product behavior for hidden +subtrees, proprietary metadata, remote resource references, resource ceilings, +or style-only semantic formatting. + +### Regular-expression sanitization + +Rejected because HTML tokenization, nesting, foreign content, and malformed +markup require an HTML parser. Regular expressions are limited to already parsed +bounded integer attribute values and a bounded raw inline-style declaration scan +for the proprietary Office `mso-hide` property that browser CSS object models do +not expose consistently. + +### General-purpose sanitizer dependency + +Deferred, not dismissed. OWASP recommends DOMPurify for untrusted HTML. A future +maintained sanitizer may replace or precede the current reconstruction behind the +same public contract, but it would still require Inkspan's narrow +semantic/attribute policy, resource ceilings, HTML-image rejection, SafeLink +policy, final-transform guarantee, package review, deterministic fixtures, and +cross-engine evidence. The present slice introduces no runtime dependency, but +that supply-chain reduction transfers maintenance and vulnerability-response +obligation to ContextualWisdomLab. + +## Semantic allowlist + +The output may contain paragraphs, generic divisions, headings, blockquotes, +preformatted text, code, line breaks, horizontal rules, semantic emphasis, +ordered and unordered lists, tables, and SafeLink-approved hyperlinks. +Equivalent presentational tags are normalized (`b`→`strong`, `i`→`em`, +`strike`→`s`). Unsupported ordinary containers are unwrapped so visible text is +retained. + +Only these attributes survive: + +- exact SafeLink `href` and fixed `rel="noopener noreferrer nofollow"`; +- bounded integer `start` on ordered lists; and +- bounded positive integer `colspan` and `rowspan` on table cells. + +Four inline style semantics are converted before all style attributes are +removed: bold weight, italic/oblique style, underline, and line-through. Color, +font, size, background, positioning, visibility, generated content, and layout +are discarded. + +## Dropped subtrees and privacy boundary + +Active, embedded, executable, form, metadata, resource-fetching, template, SVG, +MathML, canvas, media, source, picture, and HTML image elements are removed with +all descendants. Native-widget and obsolete fallback subtrees (`progress`, +`meter`, `noframes`, and `noembed`) are also removed because unwrapping them +would promote fallback descendants into ordinary editor prose without preserving +the source widget semantics. Hidden `datalist` suggestion and down-level +fallback subtrees are removed because Inkspan does not preserve the linked form +control or its `list` relationship. + +Elements carrying `hidden`, case-insensitive `aria-hidden="true"`, or any +`popover` attribute are removed with descendants. Inline declarations of +`display:none`, `visibility:hidden`, or `visibility:collapse` are likewise +complete subtree-removal boundaries. The bounded raw-style parser also removes +exact decoded `content-visibility:hidden` and Office `mso-hide:all` +declarations. External stylesheets, inherited values, and computed-style +resolution are outside this deterministic network-free clipboard boundary. +Comments, including Office conditional comments, are omitted. A closed `dialog` +contributes no subtree. A closed `details` contributes only the sanitized +contents of its first `summary` element child, when present; open variants are +unwrapped and sanitized normally. + +Raw-style hidden-content detection reads the bounded `style` attribute, removes +closed CSS comments and a final comment that runs through end of input, decodes +bounded CSS escapes in the property name and keyword value, splits declarations, +and requires an exact case-insensitive property/value pair. The supported raw +pairs are `mso-hide:all` and `content-visibility:hidden`, each optionally followed +by terminal `!important`. This avoids relying on engine-specific CSSOM exposure +for those declarations while rejecting malformed escapes, misleading values such +as `alligator` or `hiddenly`, and prefixed property names. No source style +attribute survives reconstruction. + +The detailed standards, test-first evidence, residual-risk, and rollback +boundaries are recorded in +`docs/doctoring/closed-interactive-content.md`, +`docs/doctoring/native-widget-fallback-content.md`, +`docs/doctoring/datalist-hidden-suggestion-content.md`, +`docs/doctoring/css-escaped-office-hidden-content.md`, +`docs/doctoring/popover-hidden-content.md`, +`docs/doctoring/content-visibility-hidden-content.md`, and +`docs/doctoring/visibility-collapse-hidden-content.md`. + +All HTML images are removed even when their source appears to be a data URI. +Binary clipboard image items use the pre-existing Base64Image pipeline instead. +This prevents a rich HTML paste from introducing tracking requests, local-file +references, or image data that bypasses the image byte/decode/dimension policy. + +Errors never contain source HTML, plain text, URLs, attributes, parser details, +tenant identifiers, or document content. Host error observers are isolated so a +throwing observer cannot weaken the fail-closed paste result. + +## Resource and algorithmic bounds + +Default ceilings are one MiB of UTF-8 HTML, 10,000 traversed source nodes, and +64 source-tree levels. Public configuration has absolute maxima of 16 MiB, +100,000 nodes, and 256 levels. The original nested host configuration is +preserved by identity during editor construction and is inspected only at paste +through exact own data property descriptors. Accessors, symbols, unknown fields, +non-integers, and reflection failures are rejected without leaking private +errors. + +Traversal is iterative and preserves source order by pushing children in +reverse. Source nodes and attributes are never reused. Input bytes, traversed +nodes, and source depth are bounded. Parser and output DOM memory remain +implementation-dependent; traversal and output work are linear in the accepted +node and text volume. + +## Modular ownership + +| Concern | Inkspan | Host / naruon / CWL service | +| --- | --- | --- | +| HTML byte/node/depth ceilings | Owns | May choose lower valid limits | +| Semantic element and attribute allowlist | Owns | Cannot bypass through supported props | +| Final ordinary paste transform | Owns in shared kit | Must not install a later lower-priority transform | +| SafeLink validation | Reuses and owns | Supplies ordinary document links | +| HTML image rejection | Owns | Uses binary-image pipeline or separate upload UX | +| Error code and redaction | Owns | Chooses accessible notification/telemetry | +| Clipboard permissions | Does not own | Owns | +| Authentication and tenant isolation | Does not own | Owns | +| Persistence, retention, audit, residency | Does not own | Owns | +| Downstream CSP and rendering | Does not own | Owns | +| Model/AI handling of pasted content | Does not own | Owns | + +No database object, migration, scheduler, credential, provider, model call, +network client, or storage adapter is introduced. Standalone and collaborative +editors share the same extension and callback-liveness pattern. + +## Verification strategy + +Deterministic jsdom tests cover Word-like and Google-Docs-like markup, Office +conditional comments, semantic styles, tables and list attributes, hidden data, +raw `mso-hide` declaration variants and false positives, resource-bearing +elements, scripts, forms, SVG/MathML, remote images, unsafe and +credential-bearing links, malformed nesting, unsupported containers, UTF-8 byte +limits, node and depth limits, invalid/accessor/symbol/reflection-hostile config, +DOM-unavailable invocation, host callback failure, and exact error redaction. + +A real TipTap extension-manager regression installs a competing transform that +reintroduces a script and tracking image and proves SafeClipboard runs last and +removes both before parsing. React integration tests prove that standalone and +Yjs-backed editors preserve the untrusted configuration without evaluating +nested accessors during construction, validate it at paste time, install the same +extension, and route failures to the latest callback without recreating the +editor or collaboration binding. + +No Chromium, Firefox, or WebKit conformance claim is made by this slice. jsdom +evidence does not establish cross-engine HTML parsing, CSS declaration handling, +inertness, or serialization parity. The compensating acceptance plan is a +version-pinned Playwright differential corpus executed against current Chromium, +Firefox, and WebKit with identical source fixtures, semantic output assertions, +resource-request denial, and failure-output parity. That test infrastructure must +be dependency-locked and reproducible; an unpinned package download in CI is not +acceptable evidence. + +Repository acceptance remains 100% production statement, branch, function, and +line coverage; complete public documentation; TypeScript checking; +deterministic builds; packed consumers; Office package gates; security scans; +exact-head review; and branch protection. + +## Vulnerability response and maintenance + +Because this slice does not adopt DOMPurify, ContextualWisdomLab accepts a direct +vulnerability-response obligation for the bespoke boundary: + +- review upstream browser, TipTap, ProseMirror, jsdom, and HTML parsing security + changes on every dependency update; +- add every confirmed bypass as a failing non-customer regression before fixing; +- maintain differential browser fixtures for malformed HTML, foreign content, + CSS comments/escapes, hidden Office content, URL interpretation, and serializer + differences; +- keep transform ordering under integration test whenever extension composition + changes; +- publish security advisories and patched releases through the repository's + normal exact-head security and provenance gates; and +- reevaluate DOMPurify or another maintained sanitizer when the bespoke + maintenance burden, corpus variance, or buyer assurance cost exceeds the + dependency and policy cost. + +## Release boundary + +Default rich-HTML paste behavior changes, so this feature targets Inkspan 0.6.0. +It remains under `Unreleased` until the integrated feature head and a later +release-only head pass all required gates. A merge does not imply a tag, npm +publication, provenance, or immutable GitHub Release. + +Inkspan 0.6.0 must not be published until the cross-engine corpus passes on +version-pinned Chromium, Firefox, and WebKit, or an explicit security decision +records why one engine is technically unsupported and what equivalent evidence +replaces it. The release head must also prove the package, SBOM, provenance, +license, security, independent-review, rollback, and release-acceptance gates on +the exact published source head. + +## References — APA 7th edition + +Microsoft Corporation. (n.d.). *Browsers*. Playwright. Retrieved August 5, 2026, +from https://playwright.dev/docs/browsers + +Microsoft Corporation. (n.d.). *Continuous integration*. Playwright. Retrieved +August 5, 2026, from https://playwright.dev/docs/ci + +Open Worldwide Application Security Project Foundation. (n.d.). *Cross site +scripting prevention cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 5, +2026, from +https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html + +Tiptap GmbH. (n.d.). *Extension API*. Tiptap. Retrieved August 5, 2026, from +https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/extension + +WHATWG. (n.d.). *HTML living standard*. Retrieved August 5, 2026, from +https://html.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/docs/doctoring/tiptap-v2-prosemirror-paste-adapter.md b/docs/doctoring/tiptap-v2-prosemirror-paste-adapter.md new file mode 100644 index 00000000..51901260 --- /dev/null +++ b/docs/doctoring/tiptap-v2-prosemirror-paste-adapter.md @@ -0,0 +1,112 @@ +# TipTap v2 ProseMirror paste adapter + +## Decision + +Inkspan registers SafeClipboard through a TipTap v2 extension whose +`addProseMirrorPlugins` hook returns a ProseMirror `Plugin` with a +`transformPastedHTML` editor property. That property receives rich clipboard +HTML before ProseMirror parses it into an editor document. + +The adapter is shared by the standalone `CwlEditor` and provider-neutral Yjs +`CollaborativeCwlEditor` surfaces. The pure sanitizer remains independently +callable for headless conversion and verification. This preserves the ownership +boundary: Inkspan owns deterministic editor and conversion behavior, while a +host owns transport, authorization, tenant isolation, persistence, credentials, +retention, migration, and model-use policy. + +## Root cause + +Inkspan locks `@tiptap/core 2.27.2`. The first implementation placed a +`transformPastedHTML` field directly on a TipTap extension configuration and its +unit tests invoked that field manually. That appeared consistent with current +TipTap documentation, but it did not prove registration in the installed v2 +runtime. + +The exact versioned `ExtensionManager.ts` for 2.27.2 is decisive. Its plugin +assembly reads `addProseMirrorPlugins` and appends the returned ProseMirror +plugins. It does not collect an arbitrary direct extension +`transformPastedHTML` field into editor props. The locked source is authoritative +for the installed runtime, while current mutable documentation is useful design +context but not evidence that an API existed in this historical dependency +version. + +The practical result was a false assurance gap: direct sanitizer unit tests were +green, but the real `editor.view` paste pipeline had no SafeClipboard +`transformPastedHTML` property and could parse hostile HTML without the intended +pre-parse reconstruction. + +## Test-first evidence + +Commit `2e1d634bd819686b298256f9bac161e4f6e90067` changed the standalone +integration tests to invoke the complete ProseMirror editor-property chain via +`editor.view.someProp("transformPastedHTML", ...)` instead of calling an +extension configuration method directly. + +CI run `31172315841` failed all three standalone clipboard integration tests with +the deterministic message that the SafeClipboard paste transform was not +installed. This is the RED evidence for the actual runtime defect. + +The repair adds `SafeClipboardExtension.ts`, registers one ProseMirror plugin, +and exercises the same editor-property chain on standalone and collaborative +surfaces. A competing host plugin appends a script and a tracking image before +the safety plugin runs; the final HTML contains neither resource-bearing markup +nor fallback secret text. + +Repository CI remains authoritative. A predecessor-head test or locally reasoned +result is not completion evidence for a later exact head. + +## Ordering and residual host boundary + +TipTap v2.27.2 sorts extension priorities from higher to lower when resolving +extensions and again when assembling ProseMirror plugins. ProseMirror checks +plugin-provided editor properties in plugin order. SafeClipboard therefore uses +a deliberately low priority so ordinary host transforms run first and the +sanitizer is the final supported HTML transform before ProseMirror parses the +fragment. + +This is a modular-composition guarantee, not a sandbox against the host. A host +that deliberately installs a plugin with an even lower priority, replaces +editor props after construction, or bypasses the shared Inkspan extension kit +can execute after the sanitizer. Such a host already controls the editor +process and must be treated as part of the trusted integration boundary. +Inkspan's tests protect against accidental ordinary extension composition, not a +malicious host with arbitrary code execution. + +## Fail-closed behavior + +The adapter preserves the original nested `ClipboardConfig` object and validates +it only at rich-paste time. Accessor, proxy, symbol-key, non-enumerable-key, and +unknown-key failures are converted to stable redacted product errors. The +adapter returns an empty string when sanitization fails, and a throwing host +observer cannot restore or disclose rejected source HTML. + +Plain-text paste remains outside this HTML reconstruction path. HTML images and +other resource-bearing elements remain dropped rather than fetched or converted. +No network, persistence, credential, tenant, model, database, or authorization +responsibility is added to Inkspan. + +## Compatibility and rollback + +The public package exports the registered SafeClipboard adapter and its +`PluginKey`; the pure sanitizer and redacted error types remain available as +separate deterministic surfaces. Packed-package, strict TypeScript, ESM, +CommonJS, SSR-import, demo, and collaborative tests must continue to pass. + +A rollback must restore an equivalent pre-parse ProseMirror editor property and +real-pipeline tests. Reverting only the adapter while retaining direct sanitizer +unit tests would recreate the original false assurance condition. + +No formal TipTap, ProseMirror, W3C, OWASP, or NIST conformance is claimed. + +## APA 7 references + +Haverbeke, M. (n.d.). *ProseMirror reference manual: Editor props*. Retrieved +August 7, 2026, from https://prosemirror.net/docs/ref/ + +TipTap GmbH. (n.d.). *Extension API*. TipTap Editor Docs. Retrieved August 7, +2026, from +https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/extension + +TipTap GmbH. (2025). *ExtensionManager.ts (Version 2.27.2)* [Source code]. +GitHub. Retrieved August 7, 2026, from +https://github.com/ueberdosis/tiptap/blob/%40tiptap/core%402.27.2/packages/core/src/ExtensionManager.ts diff --git a/docs/doctoring/visibility-collapse-hidden-content.md b/docs/doctoring/visibility-collapse-hidden-content.md new file mode 100644 index 00000000..187e0669 --- /dev/null +++ b/docs/doctoring/visibility-collapse-hidden-content.md @@ -0,0 +1,72 @@ +# Visibility-collapse hidden-content boundary + +**Decision date:** 2026-08-06 +**Status:** Accepted for the SafeClipboard feature branch +**Scope:** Deterministic rich-HTML paste reconstruction only + +## Decision + +Inkspan treats an inline declared `visibility` value of either `hidden` or +`collapse` as a complete hidden-subtree marker during rich-clipboard +reconstruction. The sanitizer drops the marked element and every descendant +before ProseMirror parses the reconstructed fragment. + +This rule is limited to inline declarations exposed through the element's +`style` declaration. Inkspan does not resolve external stylesheets, inherited +values, or computed style for clipboard reconstruction. The rule applies +uniformly to ordinary elements and table-internal elements. It does not try to +preserve descendants that override visibility because doing so would require +browser layout, inherited-style resolution, author style-sheet execution, and +accessibility-tree interpretation outside Inkspan's bounded, network-free +clipboard contract. + +## Rationale + +CSS defines `visibility: collapse` as non-rendered table row or column content, +and otherwise gives it the same invisibility meaning as `hidden`. CSS Display +Level 3 also describes invisible boxes as not rendered, not interactive, removed +from navigation, and normally absent from speech rendering. Allowing descendant +text to become ordinary editor prose after discarding an inline hidden or +collapse declaration would therefore reveal content the source presentation +intentionally withheld and would break deterministic browser-to-editor meaning. + +The fail-closed choice is intentionally stricter than a browser layout engine: +Inkspan removes the whole source subtree rather than attempting to reconstruct +visible descendants. Hosts that need stylesheet-aware or computed-style-aware +document import must use a separate reviewed conversion pipeline and must not +bypass SafeClipboard for untrusted paste input. + +## Verification + +The permanent regression corpus contains collapsed table-row, table-cell, and +ordinary-element examples. It proves that hidden descendants are absent while a +visible sibling remains. The RED commit is +`77ea951c54230f896788157913a924186ae487f7`; the production repair begins at +`503ba7f74001c2bfc05bee76d154fc7b162200f3`. Exact-current-head CI, 100% +production statement and branch coverage, security scanning, SAST, automated +review, and independent approval remain mandatory merge evidence. + +## Operational and compatibility boundary + +- No network, credential, persistence, tenant, model, or host-policy behavior is + introduced. +- Standalone and provider-neutral collaborative editors receive the same rule + through the shared extension kit. +- External stylesheet, inherited, and computed-style resolution remain outside + this bounded sanitizer contract. +- The rule has no database object or migration impact. +- Rollback is the exact two-commit test-and-production pair; removing only the + test is prohibited. +- Cross-engine Chromium, Firefox, and WebKit differential evidence remains a + release gate for the broader bespoke-sanitizer conformance claim. + +## References + +World Wide Web Consortium. (2011, June 7). *Cascading Style Sheets Level 2 +Revision 1 (CSS 2.1) specification: Visual effects*. https://www.w3.org/TR/CSS2/visufx.html + +World Wide Web Consortium. (2023, December 7). *Cascading Style Sheets Level 2 +Revision 2 (CSS 2.2) specification: Tables*. https://www.w3.org/TR/CSS22/tables.html + +World Wide Web Consortium. (2026, June 5). *CSS Display Module Level 3*. +https://www.w3.org/TR/2026/CRD-css-display-3-20260605/ diff --git a/docs/superpowers/plans/2026-08-05-safe-rich-clipboard.md b/docs/superpowers/plans/2026-08-05-safe-rich-clipboard.md new file mode 100644 index 00000000..d5c07494 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-safe-rich-clipboard.md @@ -0,0 +1,174 @@ +# Safe Rich Clipboard Ingestion Implementation Plan + +> **Execution method:** test-driven development, exact-head verification, and +> serial branch writes only. Checked items describe implementation work already +> represented on this feature branch; repository, review, and release gates are +> checked only after evidence exists on the exact current head. + +**Goal:** Add a bounded, fail-closed semantic rich-HTML clipboard sanitizer that +is enabled consistently in standalone and provider-neutral collaborative Inkspan +editors. + +**Architecture:** `SafeClipboard` transforms `text/html` before ProseMirror +parsing. It parses into an inert template, iteratively reconstructs an allowlisted +fragment, reuses SafeLink, drops resource-bearing images and active/hidden +subtrees, and reports only redacted stable errors through the latest host +callback. The original `ClipboardConfig` object is preserved by identity until +paste-time validation. SafeClipboard uses the lowest-practical TipTap extension +priority so it remains the final ordinary paste transform in the supported kit. + +**Technology:** TypeScript 5.7, TipTap 2.27/ProseMirror, React 18/19, Yjs, +Vitest 3 with jsdom, Vite 6, the existing SafeLink and Base64Image boundaries, +and existing hash-locked Office Python verification. + +## Global constraints + +- Default maximum source HTML: 1,048,576 UTF-8 bytes. +- Default maximum traversed nodes: 10,000. +- Default maximum source depth: 64. +- No new runtime dependency. +- No network, filesystem, model, provider, credential, storage, or database + operation. +- Drop every HTML ``; binary clipboard images continue through Base64Image. +- Preserve only the elements and attributes in the accepted design. +- Convert only bold, italic/oblique, underline, and line-through styles to + semantic elements. +- Reuse `isSafeLinkHref()`; never trim or repair an untrusted link. +- Errors contain only stable codes and static bounded messages. +- Production statement, branch, function, and line coverage: 100%. +- Complete beginner-readable public API documentation. +- The behavior targets 0.6.0 but remains under `Unreleased` until a separate + exact-head release-only pull request passes publication gates. +- No cross-engine browser conformance claim is made from jsdom evidence. + +## Task 1: Public sanitizer contract + +- [x] Write failing configuration, resource-limit, malformed HTML, hidden + subtree, link, semantic-style, and missing-DOM tests. +- [x] Add `ClipboardConfig`, `ClipboardSanitizationErrorCode`, + `ClipboardSanitizationError`, `sanitizeRichClipboardHtml()`, and + `SafeClipboard`. +- [x] Validate exact own data properties without evaluating accessors, symbols, + or proxy traps. +- [x] Enforce hard byte, node, and depth ceilings. +- [x] Reconstruct newly created allowlisted nodes through iterative traversal. +- [x] Preserve only SafeLink hyperlinks, bounded list starts, and bounded table + spans. +- [x] Convert the four approved style semantics and discard all source style + attributes. +- [x] Drop scripts, embedded resources, forms, metadata, SVG/MathML, media, + templates, comments, hidden content, and all HTML images. +- [x] Detect proprietary Office `mso-hide: all` from bounded raw style + declarations with CSS-comment removal, case/whitespace handling, terminal + `!important`, and false-positive guards. + +## Task 2: Shared editor integration + +- [x] Add `clipboard` and `onClipboardError` to the public editor props. +- [x] Add exactly one SafeClipboard extension through `buildExtensions()` for + standalone and Yjs-backed editor surfaces. +- [x] Preserve the original `ClipboardConfig` object without nested reads during + editor construction. +- [x] Defer exact fail-closed configuration validation to rich paste. +- [x] Route errors to the latest host callback without recreating the editor or + collaboration binding. +- [x] Contain host callback exceptions so rejected HTML remains rejected. +- [x] Add standalone and collaborative regression tests for hostile accessors, + callback liveness, redaction, and editor identity. + +## Task 3: Transform-order security boundary + +- [x] Write a failing real TipTap extension-manager regression with a competing + host transform that reintroduces a script and tracking image. +- [x] Assign SafeClipboard the lowest-practical TipTap extension priority. +- [x] Prove it is the final ordinary `transformPastedHTML` transform in the + supported shared extension graph. +- [x] Document that a deliberately lower-priority host transform or later parsed + transaction mutation is outside the supported boundary and requires an + independently verified equivalent validation step. + +## Task 4: Public exports and operator evidence + +- [x] Export the sanitizer, extension, error class, error-code type, and config + type through the existing public package surface. +- [x] Add README discovery for Word/Google Docs rich paste, HTML-image rejection, + limits, and the host error callback. +- [x] Add `docs/clipboard-security.md` with preserved/removed content, SSR, + callback, ordering, ownership, privacy, and recovery guidance. +- [x] Add `docs/doctoring/safe-rich-clipboard.md` with APA 7 sources, rejected + alternatives, modular ownership, claim boundaries, maintenance obligations, + and rollback/release policy. +- [x] Update `ARCHITECTURE.md` and `CHANGELOG.md` under `Unreleased`. +- [x] Add deterministic documentation contract tests for the accepted behavior + and assurance limits. + +## Task 5: Browser assurance and sanitizer maintenance + +- [x] Record that current deterministic tests run in jsdom and do not establish + Chromium, Firefox, or WebKit parser/CSS/serialization parity. +- [x] Record OWASP's maintained-sanitizer guidance and DOMPurify recommendation + without implying endorsement of the bespoke implementation. +- [x] Record the direct vulnerability-response obligation created by retaining a + no-new-runtime-dependency bespoke sanitizer. +- [x] Make a version-pinned Playwright cross-engine differential corpus a 0.6.0 + publication gate rather than adding an unpinned one-shot browser download to + this feature branch. +- [ ] Implement and dependency-lock that cross-engine corpus in the later + release-acceptance slice before 0.6.0 publication. + +## Post-review reconciliation + +The first integrated review identified four valid implementation defects and +one assurance gap. They were handled test-first: + +- [x] **Configuration boundary:** the shared kit previously dereferenced nested + clipboard values at editor construction. Regressions now require identity + preservation and paste-time validation on both React surfaces. +- [x] **Office hidden content:** CSSOM access did not expose `mso-hide` reliably. + Regressions now cover raw declaration variants and false positives. +- [x] **Transform ordering:** default extension priority allowed a later transform + to reintroduce unsafe markup. A real TipTap chain regression now requires the + sanitizer to run last in the supported kit. +- [x] **Incorrect test assertion:** a raw regular expression matched the visible + phrase `font text`. Structural DOM assertions now verify removed element names + while preserving visible text. +- [x] **Browser assurance:** the feature claim is narrowed, and the cross-engine + differential corpus is an explicit publication gate. +- [x] **Standards correction:** a review mistakenly described the official W3C + 24 June 2026 dated Working Draft as nonexistent; that finding was withdrawn + after direct verification of the official publication and is not implemented. + +## Task 6: Exact-head repository verification + +The feature is not accepted merely because an earlier head or individual job +passed. Every item below must be established on the final exact head after all +writes stop. + +- [ ] TypeScript typecheck. +- [ ] 100% production statement, branch, function, and line coverage. +- [ ] Deterministic library and demo builds. +- [ ] Isolated packed ESM, CommonJS, and strict TypeScript consumers. +- [ ] Office Python 3.11 and 3.14 dependency, docstring, branch coverage, wheel, + schema, and license gates. +- [ ] Fixed-runner exact-head CI using immutable workflow-source pins and + non-persisted checkout credentials. +- [ ] Security Scan and SAST Semgrep. +- [ ] Current-head human, CodeRabbit, GitHub Advanced Security, Dependabot, + OpenCode, Noema, Strix, and other applicable automated feedback triage. +- [ ] Zero valid unresolved review threads. +- [ ] Current-head qualifying non-author independent approval. +- [ ] Branch protection permits merge without bypass. + +## Task 7: Integration and release + +- [ ] Reconcile with the modular architecture PR if it merges first, preserving + valid changes in both `ARCHITECTURE.md` and `CHANGELOG.md`. +- [ ] Move Draft to Ready only after implementation/docs and exact-head direct + gates are complete. +- [ ] Merge only when every protected exact-head check and independent review is + satisfied. +- [ ] Open a separate release-only PR for version 0.6.0, the dependency-locked + cross-engine corpus, package metadata, release notes, SBOM, provenance, + immutable artifacts, rollback evidence, and publication acceptance. +- [ ] Publish only from the exact reviewed release head; do not infer publication + from feature merge. diff --git a/docs/superpowers/specs/2026-08-05-safe-rich-clipboard-design.md b/docs/superpowers/specs/2026-08-05-safe-rich-clipboard-design.md new file mode 100644 index 00000000..23a29f03 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-safe-rich-clipboard-design.md @@ -0,0 +1,311 @@ +# Safe rich clipboard ingestion design + +**Date:** 2026-08-05 +**Status:** Accepted implementation design with reviewed assurance limits +**Target:** Inkspan 0.6.0 after a separate exact-head release acceptance + +## Buyer-visible problem + +Users routinely paste content from Microsoft Word, Google Docs, email clients, +and other web applications. Before this slice, Inkspan let ProseMirror parse the +browser-provided `text/html` representation without a complete product contract +for hidden content, active or embedded elements, proprietary metadata, remote +resource references, event handlers, oversized markup, style-only semantic +formatting, or extension-transform ordering. + +The product must preserve useful document structure while making clipboard HTML +safe, bounded, deterministic, explainable to integrators, and identical across +standalone and provider-neutral collaborative editors. + +## Standards and framework evidence + +The W3C *Clipboard API and Events* Working Draft dated 24 June 2026 identifies +clipboard security and privacy risks, including malicious HTML, hidden data, +referenced online resources, and excessive content. A browser-supplied HTML +representation is therefore untrusted input rather than evidence that the +product's insertion policy has already been satisfied. The dated publication is +a Working Draft and remains work in progress rather than a Recommendation. + +TipTap exposes extension-level `transformPastedHTML` hooks before pasted HTML is +parsed and inserted. TipTap extension priority is material: higher-priority +extensions run first and each later transform receives the prior output. Inkspan +therefore assigns SafeClipboard the lowest-practical TipTap extension priority so +it is the final ordinary `transformPastedHTML` transform in the supported shared +kit. + +OWASP's Cross Site Scripting Prevention guidance recommends maintained HTML +sanitization when untrusted rich HTML must remain HTML, specifically recommends +DOMPurify, warns that post-sanitization modification can void protection, and +requires regular patching as browser behavior and bypasses evolve. Inkspan uses +a deliberately narrow positive-allowlist reconstruction without adding a runtime +sanitizer dependency. This is not an OWASP endorsement of the bespoke code and +creates an explicit differential-testing, maintenance, and vulnerability-response +obligation. + +WHATWG HTML fragment parsing supplies defined error recovery for malformed +markup. Inkspan treats the detached parsed fragment only as an input tree and +creates new output nodes; no source node or arbitrary source attribute is reused. + +## Considered approaches + +### A. Trust ProseMirror schema parsing + +Rejected because it leaves hidden content, proprietary metadata, resource +references, resource exhaustion, and style-derived semantics without an explicit +pre-parse product contract. + +### B. Add a general-purpose sanitization dependency + +Deferred rather than dismissed. DOMPurify or another maintained sanitizer may +replace or precede the current reconstruction behind the same narrow public +contract. Any future backend still needs Inkspan's semantic allowlist, resource +ceilings, HTML-image rejection, SafeLink policy, final-transform guarantee, +package review, cross-engine fixtures, and exact-head release evidence. + +### C. Reconstruct an allowlisted fragment in a detached template + +Selected for the bounded first slice. Parse at most a bounded input into an inert +template, iteratively traverse it, and create a new output tree containing only +Inkspan-supported semantic elements and attributes. Unsupported ordinary +containers are unwrapped; dangerous or hidden subtrees are discarded. + +## Public contract + +### Configuration + +```ts +export interface ClipboardConfig { + maxHtmlBytes?: number; + maxNodes?: number; + maxDepth?: number; +} +``` + +Defaults: + +- `maxHtmlBytes`: 1,048,576 bytes; +- `maxNodes`: 10,000 nodes; and +- `maxDepth`: 64 levels. + +The original `ClipboardConfig` object is preserved by identity when an editor is +constructed. The shared kit does not spread it or read nested values. Exact own +data-property validation occurs only at the rich-paste boundary. Accessors, +symbols, unknown fields, reflection failures, non-safe integers, zero, negative, +and over-ceiling values fail closed with one redacted +`invalid_configuration` error and an empty rich fragment. + +### Errors and host callback + +```ts +export type ClipboardSanitizationErrorCode = + | 'dom_unavailable' + | 'input_too_large' + | 'node_limit_exceeded' + | 'depth_limit_exceeded' + | 'invalid_configuration' + | 'invalid_html'; + +export class ClipboardSanitizationError extends Error { + readonly code: ClipboardSanitizationErrorCode; +} +``` + +`CwlEditorProps` adds `clipboard?: ClipboardConfig` and +`onClipboardError?: (error: ClipboardSanitizationError) => void`. The callback +never receives source HTML, text, URLs, attributes, document content, tenant +identifiers, or private parser/reflection exceptions. A throwing host observer is +contained and cannot weaken the rejected-paste result. + +### Sanitizer + +```ts +sanitizeRichClipboardHtml( + sourceHtml: string, + config?: ClipboardConfig, + documentOverride?: Document | null, +): string +``` + +The third parameter supports deterministic tests and controlled DOM-capable +hosts. Ordinary consumers omit it. Calling the function without a DOM-capable +document fails closed without touching DOM globals at module import time. + +## Allowed content + +The reconstructed fragment may contain: + +- paragraphs and generic divisions; +- headings 1–6; +- blockquotes, preformatted text, code, line breaks, and horizontal rules; +- strong/bold, emphasis/italic, underline, strike, superscript, and subscript; +- ordered and unordered lists with list items; +- tables, sections, rows, header cells, and data cells; and +- safe hyperlinks accepted by the existing `isSafeLinkHref()` policy. + +Only these attributes survive: + +- exact SafeLink-approved `href` with fixed + `rel="noopener noreferrer nofollow"`; +- bounded integer `start` on ordered lists; and +- bounded positive integer `colspan` and `rowspan` on table cells. + +All IDs, classes, inline styles, event handlers, `data-*`, arbitrary ARIA, +`target`, `contenteditable`, and proprietary Office/Google attributes are +removed from output. + +## Semantic style conversion + +Many office applications express visible formatting only through inline style. +Before styles are discarded, four narrowly defined declarations are converted +to semantic wrappers: + +- bold font weight to ``; +- italic or oblique font style to ``; +- underline to ``; and +- line-through to ``. + +No color, font family, font size, background, positioning, visibility, generated +content, direction override, or layout style is preserved. + +## Dropped content and Office hidden-style handling + +The entire subtree is discarded for active, embedded, executable, form, +metadata, resource-fetching, or non-editor namespaces, including script, style, +iframe, object, embed, applet, form controls, template, SVG, MathML, canvas, +media, source, picture, metadata, and stylesheet/base elements. + +Elements are also discarded with descendants when they carry `hidden`, a +case-insensitive `aria-hidden="true"`, `display:none`, `visibility:hidden`, or +Office `mso-hide:all`. HTML comments, including Office conditional comments, are +omitted. + +Because browser CSS object models do not expose proprietary Office declarations +consistently, `mso-hide` is recognized from the bounded raw `style` declaration. +The parser removes CSS comments, performs exact case-insensitive property and +value comparison, tolerates ordinary whitespace and a terminal `!important`, +and requires property `mso-hide` with value exactly `all`. Values such as `none` +or `alligator`, and names such as `not-mso-hide`, do not create false hidden +matches. The source style attribute is never copied to output. + +Every `` in `text/html` is dropped. Binary clipboard image items remain +governed by Base64Image's file type, byte, decode, dimension, and inline-source +policy. This prevents rich HTML from introducing tracking requests, local-file +references, or image data that bypasses the binary-image boundary. + +## Integration and transform ordering + +`SafeClipboard` is enabled by default through `buildExtensions()`. The original +`ClipboardConfig` object and latest live error observer are supplied to the +extension without evaluating nested configuration during editor construction. +Both `CwlEditor` and `CollaborativeCwlEditor` use the same shared extension. + +SafeClipboard has the lowest-practical TipTap extension priority and is the final +ordinary `transformPastedHTML` transform in the supported extension graph. A +real TipTap integration test installs a competing host transform that adds a +script and tracking image, then proves SafeClipboard receives that output last +and removes both before ProseMirror parsing. + +A deliberately hostile host can still install a lower-priority transform or +mutate the parsed transaction afterward. Such a lower-priority transform is +outside Inkspan's supported composition contract and voids the claimed pre-parse +boundary unless the host supplies an independently reviewed equivalent later +validation step. + +On sanitizer failure, the extension reports one redacted error and returns an +empty rich fragment. It never falls back to unsanitized HTML. Plain-text paste +and binary image paste remain separate browser/ProseMirror and Base64Image paths. + +## Security, privacy, and performance invariants + +1. No active, embedded, resource-bearing, or hidden subtree survives. +2. No remote or local resource reference survives except a SafeLink hyperlink. +3. No source attribute outside the exact validated allowlist survives. +4. Source nodes are never inserted into the output tree. +5. Input bytes, node count, and depth are bounded before or during traversal. +6. Traversal is iterative to avoid hostile recursion depth. +7. Errors expose only stable codes and bounded static messages. +8. Nested host configuration is not evaluated during editor construction. +9. SafeClipboard is the final ordinary paste transform in the supported kit. +10. The sanitizer performs no network, filesystem, clipboard permission, model, + provider, storage, credential, or database operation. +11. The feature introduces no database objects. +12. Standalone and collaborative surfaces use the same extension and policy. + +## Realistic verification and evidence boundary + +Deterministic jsdom tests cover Word-like and Google-Docs-like markup, Office +conditional comments, raw `mso-hide` variants and false positives, semantic +styles, tables and lists, malformed nesting, scripts, forms, foreign content, +resource-bearing elements, remote images, unsafe and credential-bearing links, +UTF-8 byte limits, breadth/depth limits, hostile configuration, DOM-unavailable +execution, callback containment, and error redaction. + +Integration tests instantiate the actual TipTap extension manager and verify +priority order and final output. React and Yjs-backed tests prove the original +configuration is not evaluated during construction, paste-time failures are +redacted, the latest callback is used, and the editor/collaboration binding is +not recreated. + +No cross-engine browser conformance claim is made by this feature head. jsdom +cannot establish Chromium, Firefox, and WebKit parity for HTML fragment parsing, +CSS declaration handling, inertness, or serialization. Before 0.6.0 publication, +a dependency-locked Playwright differential corpus must pass on supported +Chromium, Firefox, and WebKit engines with identical fixtures, semantic output +assertions, resource-request denial, and failure-output parity. An unpinned +one-off browser download is not release evidence. + +Repository acceptance remains 100% production statement, branch, function, and +line coverage; complete public documentation; TypeScript checking; +deterministic builds; isolated packed consumers; Office package gates; security +scans; exact-head review; current non-author approval; and branch protection. + +## Maintenance and vulnerability response + +Because this slice does not adopt DOMPurify, ContextualWisdomLab accepts direct +maintenance responsibility for the bespoke boundary: + +- review relevant browser, TipTap, ProseMirror, jsdom, and HTML parser security + changes on dependency updates; +- add every confirmed bypass as a failing non-customer regression before fixing; +- keep transform ordering under integration test whenever extension composition + changes; +- maintain malformed HTML, foreign content, CSS comment/escape, Office hidden + content, URL interpretation, and serializer differential fixtures; +- publish security advisories and patched releases through exact-head security, + provenance, independent-review, and release gates; and +- reevaluate DOMPurify or another maintained sanitizer when bespoke maintenance, + browser variance, or buyer-assurance cost exceeds the dependency/policy cost. + +## Release policy + +This changes default rich-HTML paste behavior and therefore targets 0.6.0. The +feature remains under `Unreleased` until the integrated feature head passes its +merge gates. Version bumping, tagging, npm publication, provenance, immutable +release creation, and rollback evidence belong to a separate release-only pull +request. + +Inkspan 0.6.0 must not be published until the version-pinned cross-engine corpus +and all package, SBOM, provenance, license, security, independent-review, +rollback, and release-acceptance gates pass on the exact published source head. + +## References — APA 7th edition + +Microsoft Corporation. (n.d.). *Browsers*. Playwright. Retrieved August 5, 2026, +from https://playwright.dev/docs/browsers + +Microsoft Corporation. (n.d.). *Continuous integration*. Playwright. Retrieved +August 5, 2026, from https://playwright.dev/docs/ci + +Open Worldwide Application Security Project Foundation. (n.d.). *Cross site +scripting prevention cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 5, +2026, from +https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html + +Tiptap GmbH. (n.d.). *Extension API*. Tiptap. Retrieved August 5, 2026, from +https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/extension + +WHATWG. (n.d.). *HTML living standard*. Retrieved August 5, 2026, from +https://html.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/clipboardDocumentation.test.ts b/src/clipboardDocumentation.test.ts new file mode 100644 index 00000000..6c595452 --- /dev/null +++ b/src/clipboardDocumentation.test.ts @@ -0,0 +1,211 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** Read one authoritative repository document as UTF-8 text. */ +function repositoryDocument(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +/** Collapse Markdown layout whitespace without weakening semantic wording checks. */ +function normalizedRepositoryDocument(path: string): string { + return repositoryDocument(path).replace(/\s+/gu, ' ').trim(); +} + +describe('safe rich clipboard documentation contract', () => { + it('binds host configuration validation to the paste boundary', () => { + const operatorGuide = normalizedRepositoryDocument( + 'docs/clipboard-security.md', + ); + + expect(operatorGuide).toContain( + 'preserved by identity when the editor is created', + ); + expect(operatorGuide).toContain( + 'validated only when rich HTML is pasted', + ); + expect(operatorGuide).toContain( + 'without evaluating accessors or proxy traps during editor construction', + ); + }); + + it('documents final ordinary transform ordering and its hostile-host boundary', () => { + const operatorGuide = normalizedRepositoryDocument( + 'docs/clipboard-security.md', + ); + const doctoring = normalizedRepositoryDocument( + 'docs/doctoring/safe-rich-clipboard.md', + ); + + for (const document of [operatorGuide, doctoring]) { + expect(document).toContain('lowest-practical TipTap extension priority'); + expect(document).toContain( + 'final ordinary `transformPastedHTML` transform', + ); + expect(document).toContain('lower-priority transform'); + } + expect(doctoring).toContain('TipTap extension priority'); + expect(doctoring).toContain('TipTap transformPastedHTML'); + }); + + it('records Office hidden-style parsing and browser evidence limits', () => { + const operatorGuide = normalizedRepositoryDocument( + 'docs/clipboard-security.md', + ); + const doctoring = normalizedRepositoryDocument( + 'docs/doctoring/safe-rich-clipboard.md', + ); + const escapeDoctoring = normalizedRepositoryDocument( + 'docs/doctoring/css-escaped-office-hidden-content.md', + ); + + expect(operatorGuide).toContain('raw `style` declaration'); + expect(operatorGuide).toContain('CSS comments'); + expect(operatorGuide).toContain('CSS escape sequences'); + expect(escapeDoctoring).toContain('CSS Syntax Level 3'); + expect(escapeDoctoring).toContain('null, surrogate'); + expect(doctoring).toContain( + 'No Chromium, Firefox, or WebKit conformance claim is made by this slice.', + ); + expect(doctoring).toContain( + '0.6.0 must not be published until the cross-engine corpus', + ); + expect(doctoring).toContain('jsdom'); + expect(doctoring).toContain('DOMPurify'); + expect(doctoring).toContain('vulnerability-response obligation'); + }); + + it('records content visibility hidden as a complete hidden-subtree boundary', () => { + const operatorGuide = normalizedRepositoryDocument( + 'docs/clipboard-security.md', + ); + const doctoring = normalizedRepositoryDocument( + 'docs/doctoring/content-visibility-hidden-content.md', + ); + const changelog = normalizedRepositoryDocument('CHANGELOG.md'); + + expect(operatorGuide).toContain( + '`content-visibility: hidden` as a complete hidden-subtree boundary', + ); + expect(operatorGuide).toContain( + 'bounded raw `style` declaration parser rather than CSSOM exposure', + ); + expect(doctoring).toContain('CSS Containment Module Level 2'); + expect(doctoring).toContain( + 'skipped contents must not be available to user-agent features', + ); + expect(doctoring).toContain('`visible`, `auto`, and `hiddenly`'); + expect(doctoring).toContain('Cross-engine Chromium, Firefox, and WebKit'); + expect(changelog).toContain( + '`content-visibility: hidden` subtrees are removed', + ); + }); + + it('records visibility collapse as a hidden-content boundary', () => { + const doctoring = normalizedRepositoryDocument( + 'docs/doctoring/visibility-collapse-hidden-content.md', + ); + const changelog = normalizedRepositoryDocument('CHANGELOG.md'); + + expect(doctoring).toContain('`visibility` value of either `hidden` or'); + expect(doctoring).toContain('`collapse` as a complete hidden-subtree marker'); + expect(doctoring).toContain('CSS Display Module Level 3'); + expect(doctoring).toContain('Cross-engine Chromium, Firefox, and WebKit'); + expect(changelog).toContain('hidden, and HTML-image subtrees are removed'); + }); + + it('records closed interactive content as a hidden-content boundary', () => { + const operatorGuide = normalizedRepositoryDocument( + 'docs/clipboard-security.md', + ); + const doctoring = normalizedRepositoryDocument( + 'docs/doctoring/closed-interactive-content.md', + ); + const changelog = normalizedRepositoryDocument('CHANGELOG.md'); + + expect(operatorGuide).toContain( + 'a closed `
` element preserves only the sanitized contents of its first `` element child', + ); + expect(operatorGuide).toContain( + 'a closed `` subtree is dropped completely', + ); + expect(doctoring).toContain('WHATWG HTML Living Standard'); + expect(doctoring).toContain( + 'A closed `details` contributes only its first `summary` element child', + ); + expect(doctoring).toContain( + 'A closed `dialog` contributes no subtree', + ); + expect(changelog).toContain( + 'Closed `details` elements preserve only their first rendered summary', + ); + }); + + it('records native-widget and obsolete fallback content as non-visible by default', () => { + const operatorGuide = normalizedRepositoryDocument( + 'docs/clipboard-security.md', + ); + const doctoring = normalizedRepositoryDocument( + 'docs/doctoring/native-widget-fallback-content.md', + ); + const changelog = normalizedRepositoryDocument('CHANGELOG.md'); + + for (const element of ['progress', 'meter', 'noframes', 'noembed']) { + expect(operatorGuide).toContain(`\`${element}\``); + expect(doctoring).toContain(`\`${element}\``); + } + expect(operatorGuide).toContain( + 'promote fallback text to ordinary visible editor prose', + ); + expect(doctoring).toContain('HTML Living Standard: The progress element'); + expect(doctoring).toContain('HTML Living Standard: The meter element'); + expect(doctoring).toContain( + 'cross-engine differential corpus remains a release-acceptance gate', + ); + expect(changelog).toContain( + 'Native `progress` and `meter` widget subtrees and obsolete `noframes` and `noembed` fallback subtrees are removed', + ); + }); + + it('retains the current standards edition and explicit work-in-progress boundary', () => { + const doctoring = normalizedRepositoryDocument( + 'docs/doctoring/safe-rich-clipboard.md', + ); + + expect(doctoring).toContain( + 'https://www.w3.org/TR/2026/WD-clipboard-apis-20260624/', + ); + expect(doctoring).toContain('24 June 2026'); + expect(doctoring).toContain('work in progress'); + expect(doctoring).not.toContain('WD-clipboard-apis-20251124'); + }); + + it('keeps the design and implementation plan reconciled to reviewed behavior', () => { + const design = normalizedRepositoryDocument( + 'docs/superpowers/specs/2026-08-05-safe-rich-clipboard-design.md', + ); + const plan = normalizedRepositoryDocument( + 'docs/superpowers/plans/2026-08-05-safe-rich-clipboard.md', + ); + + for (const document of [design, plan]) { + expect(document).toContain('original `ClipboardConfig` object'); + expect(document).toContain('lowest-practical TipTap extension priority'); + expect(document).toContain('cross-engine'); + expect(document).toContain('DOMPurify'); + } + expect(plan).toContain('Post-review reconciliation'); + expect(design).toContain('No cross-engine browser conformance claim'); + }); + + it('records the unreleased security and assurance changes', () => { + const changelog = normalizedRepositoryDocument('CHANGELOG.md'); + + expect(changelog).toContain('Raw `mso-hide` declarations'); + expect(changelog).toContain('CSS-escaped property and keyword forms'); + expect(changelog).toContain('final ordinary TipTap paste transform'); + expect(changelog).toContain('accessor-safe paste-time configuration'); + expect(changelog).toContain('cross-engine browser evidence'); + }); +}); diff --git a/src/collaboration/CollaborativeCwlEditor.clipboard.test.tsx b/src/collaboration/CollaborativeCwlEditor.clipboard.test.tsx new file mode 100644 index 00000000..4bc157bd --- /dev/null +++ b/src/collaboration/CollaborativeCwlEditor.clipboard.test.tsx @@ -0,0 +1,109 @@ +import { cleanup, render, waitFor } from '@testing-library/react'; +import type { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as Y from 'yjs'; +import type { ClipboardConfig } from '../extensions/SafeClipboard.js'; +import { CollaborativeCwlEditor } from './CollaborativeCwlEditor.js'; + +afterEach(cleanup); + +/** Invoke the complete ProseMirror HTML-paste transform chain before parsing. */ +function transformRichClipboard(editor: Editor, html: string): string { + let transformed = html; + let applied = false; + editor.view.someProp('transformPastedHTML', (transform) => { + applied = true; + transformed = transform(transformed, editor.view); + }); + if (!applied) throw new Error('SafeClipboard paste transform is not installed'); + return transformed; +} + +describe('CollaborativeCwlEditor safe rich clipboard integration', () => { + it('uses the same sanitizer and latest callback without recreating the Yjs binding', async () => { + const collaborationDocument = new Y.Doc(); + const firstCallback = vi.fn(); + const latestCallback = vi.fn(); + const onReady = vi.fn(); + let editor: Editor | undefined; + const { rerender } = render( + { + editor = instance; + onReady(instance); + }} + />, + ); + await waitFor(() => expect(editor).toBeTruthy()); + const initialEditor = editor; + + rerender( + { + editor = instance; + onReady(instance); + }} + />, + ); + + expect(transformRichClipboard(editor!, '

collaboration private

')).toBe( + '', + ); + expect(editor).toBe(initialEditor); + expect(onReady).toHaveBeenCalledTimes(1); + expect(firstCallback).not.toHaveBeenCalled(); + expect(latestCallback).toHaveBeenCalledTimes(1); + expect(latestCallback.mock.calls[0]?.[0]).toMatchObject({ + code: 'input_too_large', + }); + expect(String(latestCallback.mock.calls[0]?.[0])).not.toContain( + 'collaboration private', + ); + }); + + it('defers hostile clipboard configuration validation until paste', async () => { + const collaborationDocument = new Y.Doc(); + const accessor = vi.fn(() => { + throw new Error('private collaboration configuration'); + }); + const clipboard = Object.defineProperty({}, 'maxNodes', { + configurable: true, + enumerable: true, + get: accessor, + }) as ClipboardConfig; + const onClipboardError = vi.fn(); + let editor: Editor | undefined; + + render( + { + editor = instance; + }} + />, + ); + await waitFor(() => expect(editor).toBeTruthy()); + + expect(accessor).not.toHaveBeenCalled(); + expect(transformRichClipboard(editor!, '

collaboration private

')).toBe( + '', + ); + expect(accessor).not.toHaveBeenCalled(); + expect(onClipboardError).toHaveBeenCalledTimes(1); + expect(onClipboardError.mock.calls[0]?.[0]).toMatchObject({ + code: 'invalid_configuration', + message: 'Rich clipboard configuration is invalid.', + }); + expect(String(onClipboardError.mock.calls[0]?.[0])).not.toContain( + 'private collaboration configuration', + ); + }); +}); diff --git a/src/collaboration/CollaborativeCwlEditor.tsx b/src/collaboration/CollaborativeCwlEditor.tsx index 2c3d3151..3a77ff4d 100644 --- a/src/collaboration/CollaborativeCwlEditor.tsx +++ b/src/collaboration/CollaborativeCwlEditor.tsx @@ -16,6 +16,7 @@ import { applyEditorFormReset } from '../components/editorFormReset.js'; import { editorHtmlToValue } from '../components/editorSerialization.js'; import { useEditorHandle } from '../components/useEditorHandle.js'; import { useLatestRef } from '../components/useLatestRef.js'; +import type { ClipboardSanitizationError } from '../extensions/SafeClipboard.js'; import { buildExtensions } from '../extensions/kit.js'; import type { CwlEditorHandle } from '../types.js'; import { @@ -70,6 +71,8 @@ export const CollaborativeCwlEditor = forwardRef< onBlur, onSelectionChange, onImageError, + clipboard, + onClipboardError, placeholder = 'Start writing…', editable = true, hideToolbar = false, @@ -127,12 +130,19 @@ export const CollaborativeCwlEditor = forwardRef< const onBlurRef = useLatestRef(onBlur); const onSelectionChangeRef = useLatestRef(onSelectionChange); const onImageErrorRef = useLatestRef(onImageError); + const onClipboardErrorRef = useLatestRef(onClipboardError); const onReadyRef = useLatestRef(onReady); const onDestroyRef = useLatestRef(onDestroy); const onFormResetRef = useLatestRef(onFormReset); const reportImageError = useCallback((error: Error) => { onImageErrorRef.current?.(error); }, [onImageErrorRef]); + const reportClipboardError = useCallback( + (error: ClipboardSanitizationError) => { + onClipboardErrorRef.current?.(error); + }, + [onClipboardErrorRef], + ); const editorAttributes = useMemo( () => buildEditorAccessibilityAttributes({ @@ -167,7 +177,9 @@ export const CollaborativeCwlEditor = forwardRef< extensions: buildExtensions({ placeholder, image, + clipboard, onImageError: reportImageError, + onClipboardError: reportClipboardError, disableHistory: true, additionalExtensions: [ Collaboration.configure({ diff --git a/src/components/CwlEditor.clipboard.test.tsx b/src/components/CwlEditor.clipboard.test.tsx new file mode 100644 index 00000000..a81d8e5a --- /dev/null +++ b/src/components/CwlEditor.clipboard.test.tsx @@ -0,0 +1,119 @@ +import { cleanup, render, waitFor } from '@testing-library/react'; +import type { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ClipboardConfig } from '../extensions/SafeClipboard.js'; +import { CwlEditor } from './CwlEditor.js'; + +afterEach(cleanup); + +/** Invoke the complete ProseMirror HTML-paste transform chain before parsing. */ +function transformRichClipboard(editor: Editor, html: string): string { + let transformed = html; + let applied = false; + editor.view.someProp('transformPastedHTML', (transform) => { + applied = true; + transformed = transform(transformed, editor.view); + }); + if (!applied) throw new Error('SafeClipboard paste transform is not installed'); + return transformed; +} + +describe('CwlEditor safe rich clipboard integration', () => { + it('sanitizes rich HTML before parsing on the standalone surface', async () => { + let editor: Editor | undefined; + render( + { + editor = instance; + }} + />, + ); + await waitFor(() => expect(editor).toBeTruthy()); + + const transformed = transformRichClipboard( + editor!, + '

safe

', + ); + + expect(transformed).toBe('

safe

'); + }); + + it('uses the latest host clipboard error callback without recreating the editor', async () => { + const firstCallback = vi.fn(); + const latestCallback = vi.fn(); + const onReady = vi.fn(); + let editor: Editor | undefined; + const { rerender } = render( + { + editor = instance; + onReady(instance); + }} + />, + ); + await waitFor(() => expect(editor).toBeTruthy()); + const initialEditor = editor; + + rerender( + { + editor = instance; + onReady(instance); + }} + />, + ); + + expect(transformRichClipboard(editor!, '

private source

')).toBe(''); + expect(editor).toBe(initialEditor); + expect(onReady).toHaveBeenCalledTimes(1); + expect(firstCallback).not.toHaveBeenCalled(); + expect(latestCallback).toHaveBeenCalledTimes(1); + expect(latestCallback.mock.calls[0]?.[0]).toMatchObject({ + code: 'input_too_large', + }); + expect(String(latestCallback.mock.calls[0]?.[0])).not.toContain( + 'private source', + ); + }); + + it('defers hostile clipboard configuration validation until paste', async () => { + const accessor = vi.fn(() => { + throw new Error('private configuration value'); + }); + const clipboard = Object.defineProperty({}, 'maxHtmlBytes', { + configurable: true, + enumerable: true, + get: accessor, + }) as ClipboardConfig; + const onClipboardError = vi.fn(); + let editor: Editor | undefined; + + render( + { + editor = instance; + }} + />, + ); + await waitFor(() => expect(editor).toBeTruthy()); + + expect(accessor).not.toHaveBeenCalled(); + expect(transformRichClipboard(editor!, '

private source

')).toBe(''); + expect(accessor).not.toHaveBeenCalled(); + expect(onClipboardError).toHaveBeenCalledTimes(1); + expect(onClipboardError.mock.calls[0]?.[0]).toMatchObject({ + code: 'invalid_configuration', + message: 'Rich clipboard configuration is invalid.', + }); + expect(String(onClipboardError.mock.calls[0]?.[0])).not.toContain( + 'private configuration value', + ); + }); +}); diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 59abb4e4..c0a1e596 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -6,6 +6,7 @@ import { useMemo, useRef, } from 'react'; +import type { ClipboardSanitizationError } from '../extensions/SafeClipboard.js'; import { buildExtensions } from '../extensions/kit.js'; import type { CwlEditorHandle, CwlEditorProps } from '../types.js'; import { EditorFrame } from './EditorFrame.js'; @@ -36,6 +37,8 @@ export const CwlEditor = forwardRef( onBlur, onSelectionChange, onImageError, + clipboard, + onClipboardError, placeholder = 'Start writing…', editable = true, hideToolbar = false, @@ -70,6 +73,7 @@ export const CwlEditor = forwardRef( const onBlurRef = useLatestRef(onBlur); const onSelectionChangeRef = useLatestRef(onSelectionChange); const onImageErrorRef = useLatestRef(onImageError); + const onClipboardErrorRef = useLatestRef(onClipboardError); const onReadyRef = useLatestRef(onReady); const onDestroyRef = useLatestRef(onDestroy); const formResetValueRef = useLatestRef(formResetValue); @@ -77,6 +81,12 @@ export const CwlEditor = forwardRef( const reportImageError = useCallback((error: Error) => { onImageErrorRef.current?.(error); }, [onImageErrorRef]); + const reportClipboardError = useCallback( + (error: ClipboardSanitizationError) => { + onClipboardErrorRef.current?.(error); + }, + [onClipboardErrorRef], + ); const editorAttributes = useMemo( () => buildEditorAccessibilityAttributes({ @@ -110,7 +120,9 @@ export const CwlEditor = forwardRef( extensions: buildExtensions({ placeholder, image, + clipboard, onImageError: reportImageError, + onClipboardError: reportClipboardError, }), content: editorValueToHtml(selectedDocumentValue, mode), editorProps: { diff --git a/src/datalistDocumentation.test.ts b/src/datalistDocumentation.test.ts new file mode 100644 index 00000000..1c9960dc --- /dev/null +++ b/src/datalistDocumentation.test.ts @@ -0,0 +1,38 @@ +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('datalist hidden-suggestion documentation contract', () => { + it('records the runtime, standards, release, and rollback boundaries', () => { + const operatorGuide = normalizedDocument('docs/clipboard-security.md'); + const doctoring = normalizedDocument( + 'docs/doctoring/datalist-hidden-suggestion-content.md', + ); + const changelog = normalizedDocument('CHANGELOG.md'); + + expect(operatorGuide).toContain( + 'The HTML Living Standard also defines `` as a suggestion source', + ); + expect(operatorGuide).toContain( + 'Inkspan drops complete `progress`, `meter`, and `datalist` subtrees', + ); + expect(doctoring).toContain( + 'Drop complete `datalist` subtrees from rich clipboard HTML', + ); + expect(doctoring).toContain('HTML Living Standard: The datalist element'); + expect(doctoring).toContain( + 'cross-engine differential corpus remains a release-acceptance gate', + ); + expect(changelog).toContain( + '`datalist` suggestion and down-level fallback subtrees are removed', + ); + }); +}); diff --git a/src/domCapabilityReflectionDocumentation.test.ts b/src/domCapabilityReflectionDocumentation.test.ts new file mode 100644 index 00000000..56857c4b --- /dev/null +++ b/src/domCapabilityReflectionDocumentation.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** Read one authoritative repository document with layout whitespace collapsed. */ +function normalizedRepositoryDocument(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8') + .replace(/\s+/gu, ' ') + .trim(); +} + +describe('DOM capability reflection documentation contract', () => { + it('binds operator guidance, test-first evidence, and the changelog', () => { + const operatorGuide = normalizedRepositoryDocument( + 'docs/clipboard-security.md', + ); + const doctoring = normalizedRepositoryDocument( + 'docs/doctoring/dom-capability-reflection-redaction.md', + ); + const changelog = normalizedRepositoryDocument('CHANGELOG.md'); + + expect(operatorGuide).toContain('## DOM capability failures'); + expect(operatorGuide).toContain( + 'accessors, proxies, revoked proxies, reflection failures while selecting the ambient document', + ); + expect(operatorGuide).toContain( + 'failures while invoking `createHTMLDocument()` fail closed with `dom_unavailable`', + ); + expect(operatorGuide).toMatch( + /inert-document creation failures remain `dom_unavailable`/iu, + ); + expect(operatorGuide).toContain( + 'parsing, reconstruction, DOM mutation, or serialization failures remain `invalid_html`', + ); + expect(operatorGuide).toContain( + '`docs/doctoring/dom-capability-reflection-redaction.md`', + ); + + expect(doctoring).toContain( + 'RED commit `486846ae88710b87458712cf8da80eb5677cf031`', + ); + expect(doctoring).toContain('Exact-head CI run `31087687909` failed'); + expect(doctoring).toContain( + 'GREEN commit `e4387658f3b3b936967fd9b0101203dbdc25c55a`', + ); + expect(doctoring).toContain('ECMAScript® 2026 language specification'); + expect(doctoring).toContain('WHATWG. (2026). *DOM standard*'); + expect(doctoring).toContain('OWASP Foundation. (n.d.).'); + + expect(changelog).toContain( + 'Hostile direct-API `Document` accessors, proxies, and revoked proxies now fail closed', + ); + expect(changelog).toContain( + 'hostile `createElement` and `implementation` property reads cannot leak arbitrary exception text', + ); + }); +}); diff --git a/src/extensions/SafeClipboard.ambientDom.test.ts b/src/extensions/SafeClipboard.ambientDom.test.ts new file mode 100644 index 00000000..eaee349e --- /dev/null +++ b/src/extensions/SafeClipboard.ambientDom.test.ts @@ -0,0 +1,19 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('SafeClipboard ambient DOM boundary', () => { + it('fails closed when no explicit or ambient DOM document exists', () => { + vi.stubGlobal('document', undefined); + + expect(() => sanitizeRichClipboardHtml('

x

')).toThrowError( + expect.objectContaining({ + code: 'dom_unavailable', + message: 'Rich clipboard sanitization requires a DOM-capable document.', + }), + ); + }); +}); diff --git a/src/extensions/SafeClipboard.coverageBranches.test.ts b/src/extensions/SafeClipboard.coverageBranches.test.ts new file mode 100644 index 00000000..708da8f7 --- /dev/null +++ b/src/extensions/SafeClipboard.coverageBranches.test.ts @@ -0,0 +1,104 @@ +import { Editor } from '@tiptap/core'; +import { describe, expect, it } from 'vitest'; +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; +import { safeClipboardPluginKey } from './SafeClipboardExtension.js'; +import { buildExtensions } from './kit.js'; + +/** + * Build the smallest DOM-capable test double whose source text node reports a + * null node value. Real browser text nodes normally report strings, but the + * sanitizer intentionally retains a fail-safe empty-string fallback for hostile + * or non-conforming host DOM implementations. + */ +function documentWithNullableSourceText(): Document { + const sourceTextNode = { + nodeType: 3, + nodeValue: null, + childNodes: { + length: 0, + item() { + return null; + }, + }, + } as unknown as Node; + const sourceFragment = { + childNodes: { + length: 1, + item(index: number) { + return index === 0 ? sourceTextNode : null; + }, + }, + } as unknown as DocumentFragment; + const sourceTemplate = { + content: sourceFragment, + set innerHTML(_sourceHtml: string) { + // The fixed hostile source node above is the parsed test fixture. + }, + } as unknown as HTMLTemplateElement; + const inertDocument = { + createElement(tagName: string) { + return tagName === 'template' + ? sourceTemplate + : document.createElement(tagName); + }, + createTextNode: document.createTextNode.bind(document), + } as unknown as Document; + + return { + createElement: document.createElement.bind(document), + implementation: { + createHTMLDocument() { + return inertDocument; + }, + }, + } as unknown as Document; +} + +describe('SafeClipboard residual fail-closed branches', () => { + it('keeps malformed Office-style declarations without a separator visible', () => { + expect( + sanitizeRichClipboardHtml( + '

visible malformed declaration

', + {}, + document, + ), + ).toBe('

visible malformed declaration

'); + }); + + it('converts a hostile null text-node value to bounded empty text', () => { + expect( + sanitizeRichClipboardHtml( + 'ignored by the fixed test DOM', + {}, + documentWithNullableSourceText(), + ), + ).toBe(''); + }); + + it('validates preserved nested configuration through the real ProseMirror path', () => { + const editor = new Editor({ + element: document.createElement('div'), + extensions: buildExtensions({ + clipboard: { maxHtmlBytes: 10 }, + }), + content: '', + }); + + try { + let transformed = 'x'; + editor.view.someProp('transformPastedHTML', (transform) => { + transformed = transform(transformed, editor.view); + }); + expect(transformed).toBe('x'); + + transformed = '

this is too large

'; + editor.view.someProp('transformPastedHTML', (transform) => { + transformed = transform(transformed, editor.view); + }); + expect(transformed).toBe(''); + expect(safeClipboardPluginKey.get(editor.state)).toBeTruthy(); + } finally { + editor.destroy(); + } + }); +}); diff --git a/src/extensions/SafeClipboard.coverageContract.test.ts b/src/extensions/SafeClipboard.coverageContract.test.ts new file mode 100644 index 00000000..5d3bcd0b --- /dev/null +++ b/src/extensions/SafeClipboard.coverageContract.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { + ClipboardSanitizationError, + DEFAULT_CLIPBOARD_HTML_BYTES, + DEFAULT_CLIPBOARD_MAX_DEPTH, + DEFAULT_CLIPBOARD_MAX_NODES, + SafeClipboard, + sanitizeRichClipboardHtml, +} from './SafeClipboard.js'; + +describe('SafeClipboard fail-closed coverage contract', () => { + it('redacts an unexpected inert-document operation as invalid HTML', () => { + const hostileDocument = { + createElement: document.createElement.bind(document), + implementation: { + createHTMLDocument() { + const inertDocument = document.implementation.createHTMLDocument(''); + Object.defineProperty(inertDocument, 'createElement', { + configurable: true, + value() { + throw new Error('private inert DOM implementation detail'); + }, + }); + return inertDocument; + }, + }, + } as unknown as Document; + + expect(() => + sanitizeRichClipboardHtml('

x

', {}, hostileDocument), + ).toThrowError( + expect.objectContaining({ + code: 'invalid_html', + message: 'Rich clipboard HTML could not be sanitized.', + }), + ); + }); + + it('uses an explicitly supplied nested clipboard configuration', () => { + const configured = SafeClipboard.configure({ + config: { + maxHtmlBytes: DEFAULT_CLIPBOARD_HTML_BYTES, + maxNodes: DEFAULT_CLIPBOARD_MAX_NODES, + maxDepth: DEFAULT_CLIPBOARD_MAX_DEPTH, + }, + maxHtmlBytes: DEFAULT_CLIPBOARD_HTML_BYTES, + maxNodes: DEFAULT_CLIPBOARD_MAX_NODES, + maxDepth: DEFAULT_CLIPBOARD_MAX_DEPTH, + document, + }); + const transform = configured.config.transformPastedHTML?.bind({ + options: configured.options, + } as never); + + expect(transform?.('x')).toBe('x'); + }); + + 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.', + name: 'ClipboardSanitizationError', + }); + }); +}); diff --git a/src/extensions/SafeClipboard.datalistRegression.test.ts b/src/extensions/SafeClipboard.datalistRegression.test.ts new file mode 100644 index 00000000..e6ec82d3 --- /dev/null +++ b/src/extensions/SafeClipboard.datalistRegression.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; + +describe('SafeClipboard datalist visibility regression', () => { + it('does not promote hidden datalist suggestion content into visible editor prose', () => { + const sanitized = sanitizeRichClipboardHtml( + `

visible ordinary content

+ + datalist fallback secret + + `, + {}, + document, + ); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container).toHaveTextContent('visible ordinary content'); + expect(container).not.toHaveTextContent('datalist fallback secret'); + expect(container).not.toHaveTextContent('approved option secret'); + expect(container.querySelectorAll('datalist, option')).toHaveLength(0); + }); +}); diff --git a/src/extensions/SafeClipboard.domReflection.test.ts b/src/extensions/SafeClipboard.domReflection.test.ts new file mode 100644 index 00000000..248f04a1 --- /dev/null +++ b/src/extensions/SafeClipboard.domReflection.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { + ClipboardSanitizationError, + sanitizeRichClipboardHtml, +} from './SafeClipboard.js'; + +describe('SafeClipboard DOM capability reflection boundary', () => { + it('redacts hostile document capability access before parsing', () => { + const cases: ReadonlyArray<{ + readonly document: Document; + readonly secret: string; + }> = [ + { + document: new Proxy({} as Document, { + get(_target, property) { + if (property === 'createElement') { + throw new Error('private createElement capability detail'); + } + return undefined; + }, + }), + secret: 'private createElement capability detail', + }, + { + document: new Proxy({} as Document, { + get(_target, property) { + if (property === 'createElement') { + return globalThis.document.createElement.bind(globalThis.document); + } + if (property === 'implementation') { + throw new Error('private implementation capability detail'); + } + return undefined; + }, + }), + secret: 'private implementation capability detail', + }, + ]; + + for (const testCase of cases) { + let observed: unknown; + try { + sanitizeRichClipboardHtml('

safe

', {}, testCase.document); + } catch (error) { + observed = error; + } + + expect(observed).toBeInstanceOf(ClipboardSanitizationError); + expect(observed).toEqual( + expect.objectContaining({ + code: 'dom_unavailable', + message: + 'Rich clipboard sanitization requires a DOM-capable document.', + }), + ); + expect(String(observed)).not.toContain(testCase.secret); + } + }); + + it('classifies createHTMLDocument invocation failures as unavailable DOM capability', () => { + const secret = 'private createHTMLDocument invocation detail'; + const hostileDocument = { + createElement: document.createElement.bind(document), + implementation: { + createHTMLDocument() { + throw new Error(secret); + }, + }, + } as unknown as Document; + + let observed: unknown; + try { + sanitizeRichClipboardHtml('

safe

', {}, hostileDocument); + } catch (error) { + observed = error; + } + + expect(observed).toBeInstanceOf(ClipboardSanitizationError); + expect(observed).toEqual( + expect.objectContaining({ + code: 'dom_unavailable', + message: + 'Rich clipboard sanitization requires a DOM-capable document.', + }), + ); + expect(String(observed)).not.toContain(secret); + }); +}); diff --git a/src/extensions/SafeClipboard.popoverRegression.test.ts b/src/extensions/SafeClipboard.popoverRegression.test.ts new file mode 100644 index 00000000..7664b7fb --- /dev/null +++ b/src/extensions/SafeClipboard.popoverRegression.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; + +describe('SafeClipboard popover security regression', () => { + it('drops popover subtrees whose runtime showing state is absent from clipboard HTML', () => { + const sanitized = sanitizeRichClipboardHtml( + `

visible ordinary content

+
default popover secret
+ +
manual popover secret
+
hint popover secret
+ `, + {}, + document, + ); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container).toHaveTextContent('visible ordinary content'); + expect(container).not.toHaveTextContent('default popover secret'); + expect(container).not.toHaveTextContent('auto popover secret'); + expect(container).not.toHaveTextContent('manual popover secret'); + expect(container).not.toHaveTextContent('hint popover secret'); + expect(container).not.toHaveTextContent('invalid-value popover secret'); + }); +}); diff --git a/src/extensions/SafeClipboard.securityRegression.test.ts b/src/extensions/SafeClipboard.securityRegression.test.ts new file mode 100644 index 00000000..e30d8920 --- /dev/null +++ b/src/extensions/SafeClipboard.securityRegression.test.ts @@ -0,0 +1,222 @@ +import { Editor, Extension } from '@tiptap/core'; +import { Plugin } from '@tiptap/pm/state'; +import { describe, expect, it } from 'vitest'; +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; +import { buildExtensions } from './kit.js'; + +/** Apply the complete ProseMirror HTML-paste transform chain before parsing. */ +function transformThroughRegisteredExtensions( + editor: Editor, + sourceHtml: string, +): string { + let transformed = sourceHtml; + editor.view.someProp('transformPastedHTML', (transform) => { + transformed = transform(transformed, editor.view); + }); + return transformed; +} + +describe('SafeClipboard security regressions', () => { + it('detects Office hidden declarations from raw style text without false positives', () => { + const sanitized = sanitizeRichClipboardHtml( + `

ordinary hidden secret

+

case hidden secret

+

comment hidden secret

+

unterminated comment hidden secret

+

hex escaped hidden secret

+

simple escaped hidden secret

+

six-digit escaped hidden secret

+

none remains visible

+

alligator remains visible

+

escaped alligator remains visible

+

prefixed property remains visible

+

null escape remains visible

+

surrogate escape remains visible

+

out of range escape remains visible

+

trailing escape remains visible

+

newline escape remains visible

`, + {}, + document, + ); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container).not.toHaveTextContent('ordinary hidden secret'); + expect(container).not.toHaveTextContent('case hidden secret'); + expect(container).not.toHaveTextContent('comment hidden secret'); + expect(container).not.toHaveTextContent('unterminated comment hidden secret'); + expect(container).not.toHaveTextContent('hex escaped hidden secret'); + expect(container).not.toHaveTextContent('simple escaped hidden secret'); + expect(container).not.toHaveTextContent('six-digit escaped hidden secret'); + expect(container).toHaveTextContent('none remains visible'); + expect(container).toHaveTextContent('alligator remains visible'); + expect(container).toHaveTextContent('escaped alligator remains visible'); + expect(container).toHaveTextContent('prefixed property remains visible'); + expect(container).toHaveTextContent('null escape remains visible'); + expect(container).toHaveTextContent('surrogate escape remains visible'); + expect(container).toHaveTextContent('out of range escape remains visible'); + expect(container).toHaveTextContent('trailing escape remains visible'); + expect(container).toHaveTextContent('newline escape remains visible'); + }); + + it('drops content-visibility hidden subtrees from bounded raw style declarations', () => { + const sanitized = sanitizeRichClipboardHtml( + `

ordinary containment secret

+

case containment secret

+

comment containment secret

+

escaped containment secret

+

visible value remains visible

+

auto value remains visible

+

hiddenly value remains visible

+

prefixed property remains visible

`, + {}, + document, + ); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container).not.toHaveTextContent('ordinary containment secret'); + expect(container).not.toHaveTextContent('case containment secret'); + expect(container).not.toHaveTextContent('comment containment secret'); + expect(container).not.toHaveTextContent('escaped containment secret'); + expect(container).toHaveTextContent('visible value remains visible'); + expect(container).toHaveTextContent('auto value remains visible'); + expect(container).toHaveTextContent('hiddenly value remains visible'); + expect(container).toHaveTextContent('prefixed property remains visible'); + }); + + it('drops visibility-collapse subtrees that browsers do not render', () => { + const sanitized = sanitizeRichClipboardHtml( + ` + + + + + +
collapsed row secret
collapsed cell secret
visible table content
+

collapsed ordinary secret

`, + {}, + document, + ); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container).not.toHaveTextContent('collapsed row secret'); + expect(container).not.toHaveTextContent('collapsed cell secret'); + expect(container).not.toHaveTextContent('collapsed ordinary secret'); + expect(container).toHaveTextContent('visible table content'); + }); + + it('drops metadata titles instead of surfacing document metadata as editor text', () => { + const sanitized = sanitizeRichClipboardHtml( + '

visible

metadata title secret', + {}, + document, + ); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container).toHaveTextContent('visible'); + expect(container).not.toHaveTextContent('metadata title secret'); + expect(container.querySelectorAll('title')).toHaveLength(0); + }); + + it('drops native-widget and obsolete fallback text instead of surfacing it', () => { + const sanitized = sanitizeRichClipboardHtml( + `

visible ordinary content

+ progress fallback secret + meter fallback secret + frames fallback secret + embed fallback secret`, + {}, + document, + ); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container).toHaveTextContent('visible ordinary content'); + expect(container).not.toHaveTextContent('progress fallback secret'); + expect(container).not.toHaveTextContent('meter fallback secret'); + expect(container).not.toHaveTextContent('frames fallback secret'); + expect(container).not.toHaveTextContent('embed fallback secret'); + expect( + container.querySelectorAll('progress, meter, noframes, noembed'), + ).toHaveLength(0); + }); + + it('preserves only rendered disclosure content from closed interactive elements', () => { + const sanitized = sanitizeRichClipboardHtml( + `
+ closed details summary +

closed details secret

+
+

summaryless details secret

+
+ open details summary +

open details content

+
+

closed dialog secret

+

open dialog content

`, + {}, + document, + ); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container).toHaveTextContent('closed details summary'); + expect(container).not.toHaveTextContent('closed details secret'); + expect(container).not.toHaveTextContent('summaryless details secret'); + expect(container).toHaveTextContent('open details summary'); + expect(container).toHaveTextContent('open details content'); + expect(container).not.toHaveTextContent('closed dialog secret'); + expect(container).toHaveTextContent('open dialog content'); + expect(container.querySelectorAll('details, summary, dialog')).toHaveLength(0); + }); + + it('remains the final ordinary paste transform after host extensions', () => { + const resourceReintroducer = Extension.create({ + name: 'resourceReintroducer', + priority: 100, + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + transformPastedHTML: (html) => + `${html}tracking secret`, + }, + }), + ]; + }, + }); + const editor = new Editor({ + content: '

', + extensions: buildExtensions({ + additionalExtensions: [resourceReintroducer], + }), + }); + + try { + const extensionNames = editor.extensionManager.extensions.map( + (extension) => extension.name, + ); + expect(extensionNames.indexOf('safeClipboard')).toBeGreaterThan( + extensionNames.indexOf('resourceReintroducer'), + ); + + const sanitized = transformThroughRegisteredExtensions( + editor, + '

safe content

', + ); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container).toHaveTextContent('safe content'); + expect(container).not.toHaveTextContent('tracking secret'); + expect(container).not.toHaveTextContent('script secret'); + expect(container.querySelectorAll('img, script')).toHaveLength(0); + } finally { + editor.destroy(); + } + }); +}); diff --git a/src/extensions/SafeClipboard.test.ts b/src/extensions/SafeClipboard.test.ts new file mode 100644 index 00000000..95e1c73a --- /dev/null +++ b/src/extensions/SafeClipboard.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ClipboardSanitizationError, + DEFAULT_CLIPBOARD_HTML_BYTES, + DEFAULT_CLIPBOARD_MAX_DEPTH, + DEFAULT_CLIPBOARD_MAX_NODES, + SafeClipboard, + sanitizeRichClipboardHtml, +} from './SafeClipboard.js'; + +/** Create nested markup with an exact element depth. */ +function nestedHtml(depth: number): string { + return `${'
'.repeat(depth)}x${'
'.repeat(depth)}`; +} + +describe('sanitizeRichClipboardHtml', () => { + it('preserves Word and Google Docs semantics while stripping proprietary styling', () => { + const source = ` + +

+ Word italic +

+
+ Docs +
+ Office namespace text + `; + + const sanitized = sanitizeRichClipboardHtml(source, undefined, document); + const container = document.createElement('div'); + container.innerHTML = sanitized; + + expect(container.querySelector('strong')).toHaveTextContent('Word italic'); + expect(container.querySelector('em')).toHaveTextContent('italic'); + expect(container.querySelector('u s, s u')).toHaveTextContent('Docs'); + expect(container).toHaveTextContent('Office namespace text'); + expect(sanitized).not.toMatch(/MsoNormal|data-docs-id|style=|onclick|