Harden link sanitization and add editor integration tests

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-06 21:50:09 +00:00
committed by GitHub
parent af15e6a9a7
commit 25e75ade68
2 changed files with 87 additions and 9 deletions

View File

@@ -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 })] : []),

View File

@@ -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('<p><a href="javascript:alert(1)">click</a></p>', {
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('<p><a href="https://example.com">click</a></p>', {
contentType: 'html',
});
expect(editor.getHTML()).toContain('https://example.com');
editor.destroy();
});
it('drops script content pasted as HTML', () => {
const editor = createEditor();
const sanitized = sanitizeHtml('<p>hello</p><script>alert(1)</script>');
editor.commands.setContent(sanitized, { contentType: 'html' });
expect(editor.getText()).toContain('hello');
expect(editor.getHTML()).not.toContain('<script');
editor.destroy();
});
it('serializes typed content to portable Markdown', () => {
const editor = createEditor();
editor.commands.setContent('<h1>Title</h1><p>Some <strong>bold</strong> text</p>', {
contentType: 'html',
});
expect(docToMarkdown(editor.getJSON())).toBe('# Title\n\nSome **bold** text');
editor.destroy();
});
it('serializes a table to GFM', () => {
const editor = createEditor();
editor
.chain()
.focus()
.insertTable({ rows: 2, cols: 2, withHeaderRow: true })
.run();
const markdown = docToMarkdown(editor.getJSON());
expect(markdown).toContain('| --- | --- |');
editor.destroy();
});
});