fix: block unsafe editor links

This commit is contained in:
lucaronin
2026-04-26 02:16:20 +02:00
parent 36e8a62284
commit 39234dcf52
4 changed files with 135 additions and 32 deletions

View File

@@ -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', () => {

View File

@@ -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<HTMLElement>('.wikilink[data-target]')?.dataset.target ?? null
}
function resolveUrlTarget(target: HTMLElement) {
const href = target.closest<HTMLAnchorElement>('a[href]')?.getAttribute('href')?.trim()
if (!href || !isUrlValue(href)) return null
return normalizeUrl(href)
function resolveAnchorHref(target: HTMLElement) {
return target.closest<HTMLAnchorElement>('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<HTMLDivElement | null>,
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)

29
src/utils/url.test.ts Normal file
View File

@@ -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()
})
})

View File

@@ -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<void> {
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')
}
}