fix: block unsafe editor links
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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
29
src/utils/url.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user