diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-image-src.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-image-src.test.ts index bac79ca3..792e3e61 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-image-src.test.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-image-src.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isSafeImageSrc } from './markdown-editor-toolbar'; +import { isSafeImageSrc, isSafeUrl } from './markdown-editor-toolbar'; // The Image extension stores whatever src it is handed; isSafeImageSrc is the allowlist that blocks dangerous // PROTOCOLS (javascript:, data:text/html, vbscript:, file:) before a src is saved. A bare relative string @@ -30,3 +30,27 @@ describe('isSafeImageSrc — image-src protocol allowlist', () => { expect(isSafeImageSrc(url)).toBe(false); }); }); + +describe('isSafeUrl — link-href protocol allowlist', () => { + it.each([ + 'https://example.com/path?a=1|2', + 'http://example.com', + 'mailto:a@b.com', + 'tel:+123', + '/relative/path', + '#anchor', + './sibling', + ])('allows %s', (url) => { + expect(isSafeUrl(url)).toBe(true); + }); + + it.each([ + 'javascript:alert(1)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + 'http://', // malformed → rejected via the catch + ])('rejects %s', (url) => { + expect(isSafeUrl(url)).toBe(false); + }); +}); diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar.tsx index 47d2e613..3889d1ff 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar.tsx +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-toolbar.tsx @@ -48,6 +48,20 @@ export const isSafeImageSrc = (url: string): boolean => { } }; +// Link-toolbar counterpart of isSafeImageSrc: only navigable protocols (relative/anchor URLs resolve to the +// page's http/https). Keeps `javascript:` / `data:` out of the persisted document at authoring time so it +// never depends on every future render path sanitizing them. Load/paste bypass this (tiptap Link's +// isAllowedUri + the read-only viewer sanitize protocols on render). +const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']); + +export const isSafeUrl = (url: string): boolean => { + try { + return SAFE_LINK_PROTOCOLS.has(new URL(url, window.location.href).protocol); + } catch { + return false; + } +}; + // memo: every keystroke re-renders the RHF-controlled parent; without it all ~20 Toggle subtrees re-render // per keystroke for referentially-stable props. export const MarkdownEditorToolbar = memo(function MarkdownEditorToolbar({ @@ -68,6 +82,10 @@ export const MarkdownEditorToolbar = memo(function MarkdownEditorToolbar({ return; } + if (!isSafeUrl(url)) { + return; + } + editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); }, [editor]);