From 39234dcf5246cb84cb2a360fa0d0da3c050d1070 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 26 Apr 2026 02:16:20 +0200 Subject: [PATCH] fix: block unsafe editor links --- .../useEditorLinkActivation.test.tsx | 31 ++++++-- src/components/useEditorLinkActivation.ts | 73 +++++++++++++------ src/utils/url.test.ts | 29 ++++++++ src/utils/url.ts | 34 ++++++++- 4 files changed, 135 insertions(+), 32 deletions(-) create mode 100644 src/utils/url.test.ts diff --git a/src/components/useEditorLinkActivation.test.tsx b/src/components/useEditorLinkActivation.test.tsx index 54df6814..47ae26a5 100644 --- a/src/components/useEditorLinkActivation.test.tsx +++ b/src/components/useEditorLinkActivation.test.tsx @@ -46,13 +46,20 @@ function appendUrl(container: HTMLElement, href: string) { const link = document.createElement('a') link.setAttribute('href', href) link.textContent = href - link.addEventListener('click', (event) => { - if (!(event as MouseEvent).metaKey) event.preventDefault() - }) container.appendChild(link) return link } +function dispatchClick(target: HTMLElement, options: MouseEventInit = {}) { + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + ...options, + }) + target.dispatchEvent(event) + return event +} + describe('useEditorLinkActivation', () => { beforeEach(() => { mockOpenExternalUrl.mockClear() @@ -86,11 +93,25 @@ describe('useEditorLinkActivation', () => { const { container } = renderHarness() const link = appendUrl(container, 'https://example.com') - fireEvent.click(link) + const plainClick = dispatchClick(link) expect(mockOpenExternalUrl).not.toHaveBeenCalled() + expect(plainClick.defaultPrevented).toBe(true) - fireEvent.click(link, { metaKey: true }) + const modifiedClick = dispatchClick(link, { metaKey: true }) expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://example.com') + expect(modifiedClick.defaultPrevented).toBe(true) + }) + + it('blocks malformed URL anchors instead of opening or falling through', () => { + const { container } = renderHarness() + const link = appendUrl(container, 'https://exa mple.com') + + const plainClick = dispatchClick(link) + const modifiedClick = dispatchClick(link, { metaKey: true }) + + expect(plainClick.defaultPrevented).toBe(true) + expect(modifiedClick.defaultPrevented).toBe(true) + expect(mockOpenExternalUrl).not.toHaveBeenCalled() }) it('ignores malformed URLs and links inside code blocks', () => { diff --git a/src/components/useEditorLinkActivation.ts b/src/components/useEditorLinkActivation.ts index f8c7e4da..ea84e576 100644 --- a/src/components/useEditorLinkActivation.ts +++ b/src/components/useEditorLinkActivation.ts @@ -1,5 +1,5 @@ import { useEffect, type RefObject } from 'react' -import { isUrlValue, normalizeUrl, openExternalUrl } from '../utils/url' +import { normalizeExternalUrl, openExternalUrl } from '../utils/url' const CODE_CONTEXT_SELECTOR = '[data-content-type="codeBlock"], pre, code' @@ -15,10 +15,8 @@ function resolveWikilinkTarget(target: HTMLElement) { return target.closest('.wikilink[data-target]')?.dataset.target ?? null } -function resolveUrlTarget(target: HTMLElement) { - const href = target.closest('a[href]')?.getAttribute('href')?.trim() - if (!href || !isUrlValue(href)) return null - return normalizeUrl(href) +function resolveAnchorHref(target: HTMLElement) { + return target.closest('a[href]')?.getAttribute('href')?.trim() ?? null } function blurActiveEditable(container: HTMLElement) { @@ -33,6 +31,52 @@ function setFollowLinksActive(container: HTMLElement, active: boolean) { else container.removeAttribute('data-follow-links') } +function consumeEditorLinkClick(event: MouseEvent) { + event.preventDefault() + event.stopPropagation() +} + +function activateWikilink( + event: MouseEvent, + container: HTMLElement, + target: string, + onNavigateWikilink: (target: string) => void, +) { + if (!hasFollowModifier(event)) return + + consumeEditorLinkClick(event) + blurActiveEditable(container) + onNavigateWikilink(target) +} + +function activateExternalUrl(event: MouseEvent, href: string) { + consumeEditorLinkClick(event) + + if (!hasFollowModifier(event)) return + + const urlTarget = normalizeExternalUrl(href) + if (!urlTarget) return + + openExternalUrl(urlTarget).catch((err) => console.warn('[link] Failed to open URL:', err)) +} + +function handleEditorLinkClick( + event: MouseEvent, + container: HTMLElement, + onNavigateWikilink: (target: string) => void, +) { + if (!(event.target instanceof HTMLElement) || isInsideCodeContext(event.target)) return + + const wikilinkTarget = resolveWikilinkTarget(event.target) + if (wikilinkTarget) { + activateWikilink(event, container, wikilinkTarget, onNavigateWikilink) + return + } + + const href = resolveAnchorHref(event.target) + if (href) activateExternalUrl(event, href) +} + export function useEditorLinkActivation( containerRef: RefObject, onNavigateWikilink: (target: string) => void, @@ -49,24 +93,7 @@ export function useEditorLinkActivation( if (document.visibilityState !== 'visible') resetModifierState() } const handleClick = (event: MouseEvent) => { - if (!hasFollowModifier(event)) return - if (!(event.target instanceof HTMLElement) || isInsideCodeContext(event.target)) return - - const wikilinkTarget = resolveWikilinkTarget(event.target) - if (wikilinkTarget) { - event.preventDefault() - event.stopPropagation() - blurActiveEditable(container) - onNavigateWikilink(wikilinkTarget) - return - } - - const urlTarget = resolveUrlTarget(event.target) - if (!urlTarget) return - - event.preventDefault() - event.stopPropagation() - openExternalUrl(urlTarget).catch((err) => console.warn('[link] Failed to open URL:', err)) + handleEditorLinkClick(event, container, onNavigateWikilink) } container.addEventListener('click', handleClick, true) diff --git a/src/utils/url.test.ts b/src/utils/url.test.ts new file mode 100644 index 00000000..53d5a1fb --- /dev/null +++ b/src/utils/url.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { normalizeExternalUrl, openExternalUrl } from './url' + +describe('normalizeExternalUrl', () => { + it('keeps valid http URLs and normalizes bare domains', () => { + expect(normalizeExternalUrl('https://example.com/docs')).toBe('https://example.com/docs') + expect(normalizeExternalUrl('example.com/docs')).toBe('https://example.com/docs') + }) + + it('rejects malformed or unsupported URLs', () => { + expect(normalizeExternalUrl('https://exa mple.com')).toBeNull() + expect(normalizeExternalUrl('javascript:alert(1)')).toBeNull() + expect(normalizeExternalUrl('not a url')).toBeNull() + }) +}) + +describe('openExternalUrl', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not ask the browser to open malformed URLs', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + + await openExternalUrl('https://exa mple.com') + + expect(open).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/url.ts b/src/utils/url.ts index 821a8b5f..8b361a25 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -2,24 +2,50 @@ import { isTauri } from '../mock-tauri' const URL_PATTERN = /^https?:\/\//i const BARE_DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+([/?#]|$)/i +const UNSAFE_URL_WHITESPACE_PATTERN = /\s/ + +export function normalizeExternalUrl(value: string): string | null { + const trimmed = value.trim() + if (!trimmed || UNSAFE_URL_WHITESPACE_PATTERN.test(trimmed)) return null + + const candidate = URL_PATTERN.test(trimmed) + ? trimmed + : BARE_DOMAIN_PATTERN.test(trimmed) + ? `https://${trimmed}` + : null + + if (!candidate) return null + + try { + const parsed = new URL(candidate) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null + return candidate + } catch { + return null + } +} export function isUrlValue(value: string): boolean { - if (!value) return false - return URL_PATTERN.test(value) || BARE_DOMAIN_PATTERN.test(value) + return normalizeExternalUrl(value) !== null } export function normalizeUrl(url: string): string { + const normalized = normalizeExternalUrl(url) + if (normalized) return normalized if (URL_PATTERN.test(url)) return url return `https://${url}` } /** Open a URL in the system browser. Uses Tauri opener plugin in native mode, window.open in browser. */ export async function openExternalUrl(url: string): Promise { + const normalized = normalizeExternalUrl(url) + if (!normalized) return + if (isTauri()) { const { openUrl } = await import('@tauri-apps/plugin-opener') - await openUrl(url) + await openUrl(normalized) } else { - window.open(url, '_blank') + window.open(normalized, '_blank') } }