diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09d850f..98f6708 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,17 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build-and-test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false # pnpm version comes from the package.json "packageManager" field # (pnpm 10+), which is required to read `overrides` from # pnpm-workspace.yaml consistently with the committed lockfile. @@ -40,7 +46,7 @@ jobs: office: name: Office / Python ${{ matrix.python-version }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -51,7 +57,10 @@ jobs: env: PYTHONPATH: src steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..03e2b67 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,121 @@ +# Inkspan Architecture + +Inkspan is a modular rich-document engine that can run as a standalone React +editor or as a provider-neutral Yjs collaboration module. The repository keeps +probabilistic, transport, persistence, identity, and tenant policy outside the +editor while supplying deterministic document, safety, accessibility, and +interoperability contracts. + +```mermaid +flowchart TB + Host[Host application / naruon / CWL service] + React[Standalone CwlEditor] + Collab[CollaborativeCwlEditor] + Kit[Shared TipTap extension kit] + Clip[Safe rich clipboard boundary] + Link[SafeLink URI boundary] + Image[Base64Image binary-image boundary] + PM[TipTap / ProseMirror document] + Snap[Snapshots and versioned envelopes] + Rev[Canonical revision evidence] + Auto[Single-flight durable autosave session] + HostStore[Host transport, authorization, tenant isolation, persistence] + Office[Deterministic Office renderer] + + Host --> React + Host --> Collab + React --> Kit + Collab --> Kit + Kit --> Clip + Kit --> Link + Kit --> Image + Clip --> PM + Link --> PM + Image --> PM + PM --> Snap + Snap --> Rev + Rev --> Auto + Auto --> HostStore + Snap --> Office +``` + +## Module boundaries + +### Interactive editor graph + +- `src/components/` owns standalone React lifecycle, form integration, + accessibility attributes, editor callbacks, and imperative handles. +- `src/collaboration/` owns the provider-neutral Yjs editor and public presence + projection. Hosts own network/provider lifecycle and authorization. +- `src/extensions/` owns shared ProseMirror ingress and transaction policies. + +### Deterministic document graph + +- `documentEnvelope*` owns versioned, resource-bounded, duplicate-name-safe + structural persistence. +- `documentRevisionEvidence*` owns RFC 8785 canonical bytes and SHA-256 equality + evidence. +- `autosave/` owns bounded process-local scheduling and server-validator handoff, + not durable storage or transport. +- `office/` owns network-free JSON-to-DOCX/XLSX/PPTX rendering. + +### Trust boundaries + +```mermaid +flowchart LR + U[Untrusted clipboard / host input / remote collaboration update] + V[Bounded validation and semantic reconstruction] + D[Validated ProseMirror document] + E[Detached envelope and revision evidence] + H[Host-authorized durable transaction] + + U --> V --> D --> E --> H +``` + +Untrusted content never receives authority from its text or markup. Clipboard +HTML is parsed into an inert tree and reconstructed through a positive allowlist; +unsafe links, resource-bearing HTML images, active elements, hidden content, +and unbounded structures fail closed. Direct document writes and collaboration +updates remain protected by active-schema, SafeLink, and inline-image policies. + +## Ownership matrix + +| Concern | Inkspan | Host or integrating service | +| --- | --- | --- | +| Editor schema and deterministic serialization | Owns | Consumes | +| Clipboard, link, and inline-image ingress policy | Owns | Chooses documented limits and UX | +| Accessibility semantics and document callbacks | Owns | Supplies labels, errors, and workflow | +| Local collaboration binding | Owns | Owns Yjs document/provider lifecycle | +| Local single-flight autosave ordering | Owns | Chooses enqueue/debounce timing | +| Network, credentials, authentication | Does not own | Owns | +| Authorization and tenant isolation | Does not own | Owns | +| Durable persistence and atomic compare/commit | Does not own | Owns | +| Migration, retention, backup, residency, audit | Does not own | Owns | +| LLM/provider selection and model-use policy | Does not own | Owns | + +## Compatibility requirements + +- Standalone operation must not require naruon or any central CWL service. +- Integration surfaces must remain narrow enough for naruon compose, `ui.panel`, + contextual-orchestrator, and other repositories to provide host policy without + forking Inkspan. +- Framework-independent package subpaths must remain free of React, TipTap UI, + ProseMirror UI, Yjs, DOM, provider SDK, network, and credential dependencies + unless their documented contract explicitly requires one. +- Database objects are host-owned. New objects must use at least two descriptive + words and prefer `snake_case`. +- Default behavior changes require a minor version and a verified release-only + pull request after feature integration. + +## Quality gates + +Every production change is expected to maintain: + +- 100% production statement, branch, function, and line coverage; +- complete public module, type, class, method, function, and property docs; +- realistic security, interoperability, concurrency, and package-consumer tests; +- deterministic builds and package contents; +- exact-current-head CI, SAST, security, automated review, independent approval, + and branch protection; +- `CHANGELOG.md`, operator docs, and APA 7 doctoring where standards or research + materially inform the design. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b94ef8..b2d5e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim ## [Unreleased] +### 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 `clipboard` byte/node/depth limits and a live `onClipboardError` observer to standalone and provider-neutral Yjs editor surfaces +- Added root architecture, 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 now keeps supported structure and narrowly mapped bold/italic/underline/strike semantics while discarding arbitrary source styling and proprietary metadata +- Standalone and collaborative editors now use exactly one shared clipboard policy through `buildExtensions()` +- Nested clipboard policy objects are preserved without accessor evaluation during editor construction and use accessor-safe paste-time configuration validation at the exact rich-paste boundary +- Because default rich-HTML paste behavior changes, the integrated feature targets the next minor release, **0.6.0**, only after a separate verified release PR + +### Security +- Active, embedded, form, metadata, media, SVG/MathML, template, resource-bearing, hidden, and HTML-image subtrees are removed before insertion +- Closed `details` elements preserve only their first rendered summary, closed `dialog` subtrees are removed, and open variants are unwrapped through the ordinary sanitizer so source-only interactive content cannot become visible editor text +- Native `progress` and `meter` widget subtrees and obsolete `noframes` and `noembed` fallback subtrees are removed so stripping their wrappers cannot promote source fallback text into ordinary visible editor prose +- Hidden `datalist` suggestion and down-level fallback subtrees are removed so stripping the suggestion-source wrapper cannot promote non-rendered descendants into ordinary visible editor prose +- 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 instead of relying on browser CSSOM support for the proprietary Office property +- CSS-escaped property and keyword forms of `mso-hide: all` are decoded for exact comparison, while invalid code points, escaped newlines, prefixes, and longer look-alike values remain visible instead of producing false-positive subtree removal +- SafeClipboard uses the lowest-practical extension priority as the final ordinary TipTap paste transform, with an integration regression proving a prior host transform cannot reintroduce scripts or tracking images before parsing +- A host-installed lower-priority transform or post-parse mutation is explicitly outside the supported safety contract and requires an independently verified equivalent validation boundary +- Unsafe and credential-bearing links are unwrapped while visible text remains; SafeLink-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, and source depth are bounded; configuration accessors, symbols, unknown fields, invalid numbers, and reflection failures fail closed with static redacted error codes +- Host error observers cannot weaken the rejection result and no rejected source HTML, URL, attribute, document text, tenant identifier, or parser exception enters public errors + +### Performance +- Accepted clipboard traversal is iterative and linear in the bounded source tree; defaults are 1 MiB, 10,000 nodes, and 64 levels with documented hard ceilings +- 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, style-to-semantic conversion, tables and lists, malformed HTML, active/embedded/resource content, hidden data, remote images, unsafe links, UTF-8 byte limits, breadth/depth limits, hostile configuration, DOM-unavailable execution, callback failure, and error-redaction cases +- Added raw Office hidden-style variants including EOF-terminated CSS comments, false-positive cases, structural removed-element assertions, real TipTap transform-chain ordering, and standalone/Yjs regressions proving configuration accessors are not evaluated before paste +- Added a test-first closed/open `details` and `dialog` regression that proves hidden additional or dialog content does not enter the sanitized fragment while rendered content remains +- Added a test-first native-widget and obsolete-fallback regression proving ordinary visible text remains while `progress`, `meter`, `noframes`, and `noembed` descendants cannot surface after wrapper removal +- Added a test-first `datalist` regression proving ordinary neighboring content remains while hidden suggestion and down-level fallback descendants cannot surface after wrapper removal +- Added standalone and Yjs collaborative integration tests proving identical sanitizer behavior and latest-callback routing without editor or provider recreation +- Kept repository-wide 100% production statement, branch, function, and line coverage as the merge gate +- Recorded that current jsdom results are not cross-engine browser evidence; version-pinned Chromium, Firefox, and WebKit differential fixtures are 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 closed interactive content against the WHATWG HTML Living Standard, including the source-rendering, accessibility, host-ownership, rollback, and cross-engine uncertainty boundaries +- Documented native progress/gauge widgets and obsolete fallback elements against the WHATWG HTML Living Standard, including the fail-closed conversion decision, test-first evidence, residual risk, and rollback boundary +- Documented hidden `datalist` suggestion and down-level fallback content against the WHATWG HTML Living Standard, including the linked-control, accessibility, host-ownership, rollback, and cross-engine uncertainty boundaries +- Documented OWASP's DOMPurify recommendation, the bespoke sanitizer's vulnerability-response obligation, final-transform composition limits, and the current browser-assurance boundary +- Added Mermaid architecture and trust-boundary diagrams to `ARCHITECTURE.md` + ## [0.5.29] — 2026-08-05 ### Added diff --git a/docs/clipboard-security.md b/docs/clipboard-security.md new file mode 100644 index 0000000..6ce09da --- /dev/null +++ b/docs/clipboard-security.md @@ -0,0 +1,231 @@ +# 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. + +## 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. + +## 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, +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. + +## 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 0000000..9290d59 --- /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/css-escaped-office-hidden-content.md b/docs/doctoring/css-escaped-office-hidden-content.md new file mode 100644 index 0000000..a451b4e --- /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 0000000..2d10ab6 --- /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/native-widget-fallback-content.md b/docs/doctoring/native-widget-fallback-content.md new file mode 100644 index 0000000..dcfc0f4 --- /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/safe-rich-clipboard.md b/docs/doctoring/safe-rich-clipboard.md new file mode 100644 index 0000000..1d602d9 --- /dev/null +++ b/docs/doctoring/safe-rich-clipboard.md @@ -0,0 +1,291 @@ +# 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"`, +`display:none`, `visibility:hidden`, or Office `mso-hide:all` are removed with +descendants. 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. + +Office hidden-content detection reads the bounded raw `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 `mso-hide` property with exact value +`all`, optionally followed by terminal `!important`. This avoids relying on +engine-specific CSSOM support while rejecting malformed escapes, misleading +values such as `alligator`, and property names such as `not-mso-hide`. 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`, and +`docs/doctoring/css-escaped-office-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/visibility-collapse-hidden-content.md b/docs/doctoring/visibility-collapse-hidden-content.md new file mode 100644 index 0000000..002a7bf --- /dev/null +++ b/docs/doctoring/visibility-collapse-hidden-content.md @@ -0,0 +1,67 @@ +# 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 computed `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 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 the source style 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 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. +- 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 0000000..d5c0749 --- /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 0000000..23a29f0 --- /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 0000000..135dbe0 --- /dev/null +++ b/src/clipboardDocumentation.test.ts @@ -0,0 +1,185 @@ +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 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 0000000..a6a2f12 --- /dev/null +++ b/src/collaboration/CollaborativeCwlEditor.clipboard.test.tsx @@ -0,0 +1,107 @@ +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 installed SafeClipboard transform on a collaborative editor. */ +function transformRichClipboard(editor: Editor, html: string): string { + const extension = editor.extensionManager.extensions.find( + (candidate) => candidate.name === 'safeClipboard', + ); + const transform = extension?.config.transformPastedHTML; + if (!extension || !transform) throw new Error('SafeClipboard is not installed'); + return transform.call({ options: extension.options } as never, html); +} + +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 2c3d315..3a77ff4 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 0000000..9424953 --- /dev/null +++ b/src/components/CwlEditor.clipboard.test.tsx @@ -0,0 +1,117 @@ +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 installed SafeClipboard transform exactly as TipTap does. */ +function transformRichClipboard(editor: Editor, html: string): string { + const extension = editor.extensionManager.extensions.find( + (candidate) => candidate.name === 'safeClipboard', + ); + const transform = extension?.config.transformPastedHTML; + if (!extension || !transform) throw new Error('SafeClipboard is not installed'); + return transform.call({ options: extension.options } as never, html); +} + +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 3cee8c4..62443bb 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, @@ -69,6 +72,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); @@ -76,6 +80,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({ @@ -109,7 +119,9 @@ export const CwlEditor = forwardRef( extensions: buildExtensions({ placeholder, image, + clipboard, onImageError: reportImageError, + onClipboardError: reportClipboardError, }), content: editorValueToHtml(value ?? defaultValue ?? '', mode), editorProps: { diff --git a/src/datalistDocumentation.test.ts b/src/datalistDocumentation.test.ts new file mode 100644 index 0000000..1c9960d --- /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/extensions/SafeClipboard.ambientDom.test.ts b/src/extensions/SafeClipboard.ambientDom.test.ts new file mode 100644 index 0000000..eaee349 --- /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 0000000..5cba867 --- /dev/null +++ b/src/extensions/SafeClipboard.coverageBranches.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeRichClipboardHtml } from './SafeClipboard.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(''); + }); +}); diff --git a/src/extensions/SafeClipboard.datalistRegression.test.ts b/src/extensions/SafeClipboard.datalistRegression.test.ts new file mode 100644 index 0000000..e6ec82d --- /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.securityRegression.test.ts b/src/extensions/SafeClipboard.securityRegression.test.ts new file mode 100644 index 0000000..f2682e2 --- /dev/null +++ b/src/extensions/SafeClipboard.securityRegression.test.ts @@ -0,0 +1,194 @@ +import { Editor, Extension } from '@tiptap/core'; +import { describe, expect, it } from 'vitest'; +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; +import { buildExtensions } from './kit.js'; + +/** Apply registered TipTap HTML transforms in their actual priority order. */ +function transformThroughRegisteredExtensions( + editor: Editor, + sourceHtml: string, +): string { + return editor.extensionManager.extensions.reduce((currentHtml, extension) => { + const transform = extension.config.transformPastedHTML; + return transform === undefined + ? currentHtml + : transform.call( + { + editor, + options: extension.options, + storage: extension.storage, + } as never, + currentHtml, + ); + }, sourceHtml); +} + +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 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, + transformPastedHTML(html: string) { + return `${html}tracking secret`; + }, + }); + const editor = new Editor({ + content: '

', + extensions: buildExtensions({ + additionalExtensions: [resourceReintroducer], + }), + }); + + try { + const transforms = editor.extensionManager.extensions.filter( + (extension) => extension.config.transformPastedHTML !== undefined, + ); + expect(transforms.at(-1)?.name).toBe('safeClipboard'); + + 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 0000000..7cafc4a --- /dev/null +++ b/src/extensions/SafeClipboard.test.ts @@ -0,0 +1,392 @@ +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|