mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 21:53:40 +02:00
Harden link sanitization and add editor integration tests
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
af15e6a9a7
commit
25e75ade68
@@ -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 })] : []),
|
||||
|
||||
80
src/lib/documents/editor.integration.test.tsx
Normal file
80
src/lib/documents/editor.integration.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user