From 587d8a0ecf0188a173c6e99b16959a1bdfc4cb36 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Sun, 5 Jul 2026 18:04:41 +0700 Subject: [PATCH] fix(markdown-editor): guard the link toolbar against non-navigable protocols (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSetLink passed window.prompt output straight to setLink with no protocol check, while the image toolbar already guards via isSafeImageSrc — so a user could toolbar-insert `javascript:alert(1)` as a link href into the persisted document. Not a live XSS today (the read-only viewer's react-markdown urlTransform and tiptap Link's isAllowedUri both sanitize on render), but defense-in-depth: keep dangerous protocols out of the document at authoring time so it never relies on every future render path sanitizing them. Add isSafeUrl (mirror of isSafeImageSrc): allow http/https/mailto/tel + relative/anchor (which resolve to the page protocol); reject javascript:/data:/vbscript:/file:/malformed. Apply it in handleSetLink. Unit-tested alongside isSafeImageSrc; 889 vitest green. Co-Authored-By: Claude Opus 4.8 --- .../markdown-editor-image-src.test.ts | 26 ++++++++++++++++++- .../markdown-editor-toolbar.tsx | 18 +++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) 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]);