fix(markdown-editor): guard the link toolbar against non-navigable protocols (M3)

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 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-05 18:04:41 +07:00
co-authored by Claude Opus 4.8
parent 5eb4ea22ad
commit 587d8a0ecf
2 changed files with 43 additions and 1 deletions
@@ -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,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'http://', // malformed → rejected via the catch
])('rejects %s', (url) => {
expect(isSafeUrl(url)).toBe(false);
});
});
@@ -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]);