From 25e75ade68a77b5723b24ecb7a319adc9b2b821d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:50:09 +0000 Subject: [PATCH] Harden link sanitization and add editor integration tests Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- src/hooks/useDocumentEditor.ts | 16 ++-- src/lib/documents/editor.integration.test.tsx | 80 +++++++++++++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 src/lib/documents/editor.integration.test.tsx diff --git a/src/hooks/useDocumentEditor.ts b/src/hooks/useDocumentEditor.ts index 6cd44c7..8fa6d78 100644 --- a/src/hooks/useDocumentEditor.ts +++ b/src/hooks/useDocumentEditor.ts @@ -45,9 +45,9 @@ export interface DocumentEditor { * extension, which is the exact seam where the Phase 2 Hocuspocus provider * attaches (`Collaboration.configure({ document, provider })`). Nothing * else about the app changes when that lands. - * - Local undo/redo stays enabled: Collaboration swaps in the Yjs undo - * manager only when a provider connects, so solo Phase 1 editing keeps the - * expected Word-like per-user history. + * - Collaboration replaces local undo/redo with the Yjs UndoManager, which + * tracks local-origin changes only — so undo behaves per-user (Word-like) + * now, and stays correct when remote peers join in Phase 2. * - Pasted/dropped HTML passes through the allowlist sanitizer before the * editor's schema parse; the schema itself drops anything else. */ @@ -125,12 +125,10 @@ export function useDocumentEditor({ link: { openOnClick: false, autolink: true, - // Only allowlisted protocols may become links. Everything else - // (javascript:, data:, vbscript:) is dropped by the extension. - isAllowedUri: (url, ctx) => { - if (ctx.defaultValidate(url)) return true; - return sanitizeUrl(url) !== undefined; - }, + // Only protocols on the shared allowlist may become links. This + // keeps `nostr:` mentions working while `javascript:`/`data:` + // hrefs are dropped. + isAllowedUri: (url) => sanitizeUrl(url) !== undefined, }, }), ...(sessionDoc ? [Collaboration.configure({ document: sessionDoc })] : []), diff --git a/src/lib/documents/editor.integration.test.tsx b/src/lib/documents/editor.integration.test.tsx new file mode 100644 index 0000000..89929e7 --- /dev/null +++ b/src/lib/documents/editor.integration.test.tsx @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import { TableKit } from '@tiptap/extension-table'; +import { sanitizeUrl } from '@/lib/nostrUtils'; +import { sanitizeHtml } from './sanitizeHtml'; +import { docToMarkdown } from './markdown'; + +/** + * Integration tests against a real headless Tiptap editor using the same + * extension configuration as `useDocumentEditor`. They prove the constrained + * schema + sanitizer + serializer chain holds on untrusted input. + */ +function createEditor(): Editor { + return new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + link: { + openOnClick: false, + autolink: true, + isAllowedUri: (url) => sanitizeUrl(url) !== undefined, + }, + }), + TableKit.configure({ table: { resizable: false } }), + ], + }); +} + +describe('editor schema + sanitizer', () => { + it('rejects javascript: link hrefs end to end', () => { + const editor = createEditor(); + editor.commands.setContent('
', { + contentType: 'html', + }); + // The unsafe href must not survive into the document. + expect(editor.getHTML()).not.toContain('javascript:'); + editor.destroy(); + }); + + it('keeps safe link hrefs', () => { + const editor = createEditor(); + editor.commands.setContent('', { + contentType: 'html', + }); + expect(editor.getHTML()).toContain('https://example.com'); + editor.destroy(); + }); + + it('drops script content pasted as HTML', () => { + const editor = createEditor(); + const sanitized = sanitizeHtml('hello
'); + editor.commands.setContent(sanitized, { contentType: 'html' }); + expect(editor.getText()).toContain('hello'); + expect(editor.getHTML()).not.toContain('