From 6e32b2052462cbb90d680bc0d0aca18d61b71900 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Mon, 18 May 2026 11:40:32 +0900 Subject: [PATCH] feat(web): linkify custom URI schemes with a confirm prompt (#633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add UriConfirmDialog component Add a Radix Dialog-based confirmation modal for custom URI scheme navigation. Follows the RenameSessionDialog pattern. - UriConfirmDialog: shows URI, scheme label, Cancel/Open/Always-allow buttons - i18n keys: dialog.uri.{title,description,open,alwaysAllow} * feat(web): autolink non-https URI schemes in markdown Add a remark plugin that converts raw `scheme://...` text nodes into link nodes for non-http(s) schemes. GFM already handles http/https; this plugin handles the remainder (obsidian://, vscode://, slack://, etc.). - No scheme allowlist: every `scheme://` pattern is converted; the sanitize layer (urlTransform) and onClick layer (classifyScheme) handle blocking/confirmation downstream. - Runs before remarkStripCjkAutolink so the CJK-strip plugin sees the new link nodes and can trim trailing CJK punctuation from them. - Trailing punctuation (.,;!?) stripped from matched URIs. - Unit tests: conversion, partial-match, escape, explicit link bypass, code-block bypass, trailing-punct trimming. * feat(web): linkify custom URI schemes via markdown handler Wire up 4-layer URI security policy in the markdown renderer: 1. URL sanitize (deny-only): urlTransform strips javascript:/data:/vbscript:/file: using classifyScheme as single source of truth (handles percent-encoding, case-insensitive, whitespace-prefix bypass patterns). 2. onClick intercept: custom component classifies each href — - IANA safe (https/http/irc/ircs/mailto/xmpp): navigate directly. - Deny (javascript/data/vbscript/file): preventDefault silently. - Custom (obsidian/vscode/slack/…): preventDefault + open UriConfirmDialog. 3. UriConfirmProvider: one dialog lifted to each markdown root (MarkdownText, Reasoning, MarkdownRenderer). Shared isAllowed state across all tags in the subtree — "Always allow" click updates every link in one React commit. 4. Intra-tab cross-provider sync (P7e.1): module-level schemeListeners Set so sibling UriConfirmProviders (MarkdownText + Reasoning in AssistantMessage) receive allowed-scheme updates synchronously without waiting for the window storage event (which only fires in other tabs). Cross-tab sync continues via the existing window storage event listener. 5. "Always allow" persisted to localStorage (hapi-allowed-schemes). Custom schemes once allowed navigate directly on subsequent clicks, no dialog gate. href="#" in DOM for unallowed custom schemes prevents middle-click bypass. Deny-scheme href="" prevents any navigation even if localStorage tampered. Security: classifyScheme decodes percent-encoding before scheme extraction, blocking %6Aavascript:, jav%61script:, javascript%3A (single-encoded colon) and double-encoded variants. DENY_SCHEMES checked after localStorage lookup so tampered allowed-list cannot promote deny schemes. Tests: classifyScheme 6-axis security bypass, denyOnlyTransform, localStorage roundtrip, cross-tab storage event, click handler cases. * fix(web): block control-char-spliced deny schemes in classifyScheme Browsers silently strip ASCII control characters (\t, \n, \r) and whitespace from URL scheme names during navigation. A scheme like `java\nscript:alert(1)` was navigated as `javascript:` while our literal string comparison classified it as 'custom', allowing it past the deny list and into window.open(). Introduce normalizedScheme() that: - applies 2 rounds of decodeURIComponent so double-encoded schemes (javascript%253A → javascript%3A → javascript:) are fully unwrapped before comparison - strips [\x00-\x1F\x7F\s] from the extracted scheme name, matching the browser's own normalization classifyScheme() now delegates to normalizedScheme() so both the denyOnlyTransform (urlTransform) path and the onClick path benefit from the same normalization. Tests added for \n / \t / \r / space spliced into scheme, and verify that double-encoded colon is now caught via scheme-match (not just the no-colon fallback). * fix(web): preserve relative markdown links from being blocked Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) were silently preventDefault'd in 's onClick handler. denyOnlyTransform correctly passed them through (no colon → not a scheme URL), but the click handler called classifyScheme(href) which returned 'deny' for any input with no valid scheme separator — then the deny branch fired. Add hasScheme(href): checks whether the first ':' appears before any path/query/fragment boundary ('/', '?', '#'). When hasScheme is false the href is treated as 'iana' so the browser or SPA router can navigate normally with no dialog and no preventDefault. Also wrap renderA() with so the UriConfirmDialog that UriConfirmProvider may render does not throw outside its translation context during tests. Fixes a regression that broke all relative-path markdown links once the custom-URI-scheme onClick handler was added. * test(web): cover percent-encoded scheme control char + protocol-relative href Round-5 internal hostile review noted two coverage gaps on the bot-fixup commits: - `java%0Ascript:alert(1)` (percent-encoded newline in the scheme name) takes the same decode→strip code path as the literal `java\nscript:` case but was only tested literally. Add an explicit test so a future refactor that drops the decode-then-strip ordering would be caught. - Protocol-relative URLs (`//host/path`) have no colon, so `hasScheme` returns false and `` treats them as scheme-less — browsers then navigate them as the current origin's protocol. Existing relative-href tests covered absolute paths, hashes, queries, and colon-in-path, but not the protocol-relative variant. Add one assertion. Also extend the `hasScheme` JSDoc to note that protocol-relative URLs are intentionally treated as scheme-less. * fix(web): preserve balanced parens/brackets in autolinked URIs The trailing-punctuation strip used to drop every `)` / `]` from the end of a matched URI, even when the URL body had an unmatched opener. So a URI like `obsidian://open?file=Note(1)` was rendered with href `obsidian://open?file=Note(1` plus a separate `)` text node, opening a broken deep link. Match the GFM autolink-literal behaviour: when the trailing character is `)` or `]`, keep it iff the URL body has more opening counterparts than closers (so the trailing closer balances an earlier opener and belongs to the URL). Other trailing punctuation (`.,;!?:>'"`) and unmatched closers still strip as before. Add tests for the balanced cases (`Note(1)`, `Note[1]`, nested `(a(b)c)`), the "balanced URL followed by a period" case, and a regression test that an unmatched `).` after a URL is still stripped. --- web/src/components/MarkdownRenderer.tsx | 23 +- web/src/components/UriConfirmDialog.test.tsx | 112 +++++ web/src/components/UriConfirmDialog.tsx | 79 ++++ .../assistant-ui/markdown-a.test.tsx | 335 +++++++++++++++ .../assistant-ui/markdown-text.test.ts | 17 + .../components/assistant-ui/markdown-text.tsx | 403 +++++++++++++++++- web/src/components/assistant-ui/reasoning.tsx | 19 +- web/src/lib/locales/en.ts | 4 + web/src/lib/locales/zh-CN.ts | 4 + web/src/lib/remark-non-https-autolink.test.ts | 164 +++++++ web/src/lib/remark-non-https-autolink.ts | 173 ++++++++ 11 files changed, 1308 insertions(+), 25 deletions(-) create mode 100644 web/src/components/UriConfirmDialog.test.tsx create mode 100644 web/src/components/UriConfirmDialog.tsx create mode 100644 web/src/components/assistant-ui/markdown-a.test.tsx create mode 100644 web/src/components/assistant-ui/markdown-text.test.ts create mode 100644 web/src/lib/remark-non-https-autolink.test.ts create mode 100644 web/src/lib/remark-non-https-autolink.ts diff --git a/web/src/components/MarkdownRenderer.tsx b/web/src/components/MarkdownRenderer.tsx index 7ac71bb2..280ff217 100644 --- a/web/src/components/MarkdownRenderer.tsx +++ b/web/src/components/MarkdownRenderer.tsx @@ -7,6 +7,8 @@ import { MARKDOWN_COMPONENTS_BY_LANGUAGE, MARKDOWN_CLASSNAME, defaultComponents, + denyOnlyTransform, + UriConfirmProvider, } from '@/components/assistant-ui/markdown-text' import { cn } from '@/lib/utils' @@ -22,15 +24,18 @@ function MarkdownContent(props: MarkdownRendererProps) { : defaultComponents return ( - - - + + + + + ) } diff --git a/web/src/components/UriConfirmDialog.test.tsx b/web/src/components/UriConfirmDialog.test.tsx new file mode 100644 index 00000000..28c835c9 --- /dev/null +++ b/web/src/components/UriConfirmDialog.test.tsx @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, cleanup } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { UriConfirmDialog } from '@/components/UriConfirmDialog' + +function renderDialog(props: Partial> = {}) { + const defaults = { + open: true, + url: 'obsidian://open?vault=MyVault&file=Notes%2Ftest', + scheme: 'obsidian', + onCancel: vi.fn(), + onOpen: vi.fn(), + onAlwaysAllow: vi.fn(), + } + const merged = { ...defaults, ...props } + return render( + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + cleanup() +}) + +describe('UriConfirmDialog', () => { + it('renders dialog title when open', () => { + renderDialog() + expect(screen.getByText('Open this link?')).toBeInTheDocument() + }) + + it('displays the scheme prefix in the URI display', () => { + renderDialog({ url: 'obsidian://open?vault=MyVault&file=Notes%2Ftest', scheme: 'obsidian' }) + // The URI is split: scheme prefix + remainder in sibling spans. + expect(screen.getByText('obsidian:')).toBeInTheDocument() + }) + + it('displays the URL remainder after the scheme prefix', () => { + renderDialog({ url: 'obsidian://open?vault=MyVault&file=Notes%2Ftest', scheme: 'obsidian' }) + expect(screen.getByText('//open?vault=MyVault&file=Notes%2Ftest')).toBeInTheDocument() + }) + + it('emphasizes the scheme in the URI display', () => { + renderDialog({ scheme: 'obsidian' }) + // The scheme label should appear visually prominent (e.g. as a separate span) + expect(screen.getByText('obsidian:')).toBeInTheDocument() + }) + + it('renders Cancel, Open, and Always allow buttons', () => { + renderDialog() + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^open$/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /always allow/i })).toBeInTheDocument() + }) + + it('calls onCancel when Cancel button is clicked', () => { + const onCancel = vi.fn() + renderDialog({ onCancel }) + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + expect(onCancel).toHaveBeenCalledOnce() + }) + + it('calls onOpen when Open button is clicked', () => { + const onOpen = vi.fn() + renderDialog({ onOpen }) + fireEvent.click(screen.getByRole('button', { name: /^open$/i })) + expect(onOpen).toHaveBeenCalledOnce() + }) + + it('calls onAlwaysAllow with the scheme when Always allow is clicked', () => { + const onAlwaysAllow = vi.fn() + renderDialog({ scheme: 'obsidian', onAlwaysAllow }) + fireEvent.click(screen.getByRole('button', { name: /always allow/i })) + expect(onAlwaysAllow).toHaveBeenCalledWith('obsidian') + }) + + it('does not render dialog content when open is false', () => { + renderDialog({ open: false }) + expect(screen.queryByText('Open this link?')).not.toBeInTheDocument() + }) + + it('renders without error in open and closed states (onOpenChange wiring)', () => { + // Verifies the Dialog onOpenChange prop is wired so that open→closed calls onCancel. + // Direct Esc simulation is unreliable in jsdom; we confirm both states render cleanly. + const onCancel = vi.fn() + const { rerender } = renderDialog({ onCancel, open: true }) + expect(screen.getByText('Open this link?')).toBeInTheDocument() + rerender( + + + + ) + expect(screen.queryByText('Open this link?')).not.toBeInTheDocument() + }) + + it('includes the scheme name in the Always allow button label', () => { + renderDialog({ scheme: 'vscode' }) + expect(screen.getByRole('button', { name: /always allow vscode/i })).toBeInTheDocument() + }) +}) diff --git a/web/src/components/UriConfirmDialog.tsx b/web/src/components/UriConfirmDialog.tsx new file mode 100644 index 00000000..1457d16b --- /dev/null +++ b/web/src/components/UriConfirmDialog.tsx @@ -0,0 +1,79 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useTranslation } from '@/lib/use-translation' + +export type UriConfirmDialogProps = { + /** Whether the dialog is visible. */ + open: boolean + /** The full URL being navigated to. */ + url: string + /** The scheme portion of the URL (without the colon), e.g. "obsidian". */ + scheme: string + /** Called when the user dismisses the dialog without navigating. */ + onCancel: () => void + /** Called when the user chooses to open the link once. */ + onOpen: () => void + /** Called when the user chooses to always allow this scheme. */ + onAlwaysAllow: (scheme: string) => void +} + +/** + * Confirmation dialog shown before navigating to a non-IANA URI scheme. + * Follows the RenameSessionDialog pattern (Radix Dialog + Button + i18n). + */ +export function UriConfirmDialog(props: UriConfirmDialogProps) { + const { open, url, scheme, onCancel, onOpen, onAlwaysAllow } = props + const { t } = useTranslation() + + // Split URL into scheme prefix and the rest for visual emphasis. + const schemePrefix = `${scheme}:` + const urlRemainder = url.startsWith(schemePrefix) ? url.slice(schemePrefix.length) : url + + return ( + !isOpen && onCancel()}> + + + {t('dialog.uri.title')} + + + {t('dialog.uri.description')} + + + {/* URI display with scheme emphasis */} +
+ {schemePrefix} + {urlRemainder} +
+ +
+ + + +
+
+
+ ) +} diff --git a/web/src/components/assistant-ui/markdown-a.test.tsx b/web/src/components/assistant-ui/markdown-a.test.tsx new file mode 100644 index 00000000..8ab22168 --- /dev/null +++ b/web/src/components/assistant-ui/markdown-a.test.tsx @@ -0,0 +1,335 @@ +/** + * Tests for the custom
anchor component and the inlined URL policy helpers + * in markdown-text.tsx. + * + * Covers: + * - classifyScheme: IANA / deny / custom, 6-axis security bypass + * - denyOnlyTransform: deny → "", IANA/custom → pass-through, relative paths + * - useAllowedSchemes (inlined): localStorage roundtrip, cross-tab storage event, tamper guard + * - component click behaviour: deny, IANA, custom (dialog opened via context) + * - intra-tab cross-provider sync via module-level schemeListeners emitter + */ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, cleanup, act, waitFor } from '@testing-library/react' +import React from 'react' +import { defaultComponents, classifyScheme, denyOnlyTransform, UriConfirmProvider } from '@/components/assistant-ui/markdown-text' +import { I18nProvider } from '@/lib/i18n-context' + +// defaultComponents.a is the memoized A component. +const AnchorComponent = (defaultComponents as Record).a as React.ComponentType< + React.ComponentPropsWithoutRef<'a'> +> + +// Helper: wrap A in UriConfirmProvider so useContext(UriConfirmContext) is non-null. +// Previously had a localHook fallback for bare renders, but that fallback +// added a storage listener per link (N links → N+1 listeners). The fallback is +// removed; tests must provide the context instead. +function renderA(props: React.ComponentPropsWithoutRef<'a'>) { + return render( + + + + + + ) +} + +const STORAGE_KEY = 'hapi-allowed-schemes' + +beforeEach(() => { + localStorage.clear() + cleanup() + vi.clearAllMocks() +}) + +// ── classifyScheme ──────────────────────────────────────────────────────────── + +describe('classifyScheme — IANA schemes', () => { + it.each(['https://example.com', 'http://example.com', 'mailto:user@x.com', 'irc://irc.libera.chat', 'ircs://irc.libera.chat', 'xmpp:user@x.com'])( + 'classifies %s as iana', + (url) => expect(classifyScheme(url)).toBe('iana') + ) + it('classifies HTTPS: (uppercase) as iana (case-insensitive)', () => { + expect(classifyScheme('HTTPS://example.com')).toBe('iana') + }) +}) + +describe('classifyScheme — deny schemes', () => { + it.each(['javascript:alert(1)', 'data:text/html,

xss

', 'vbscript:msgbox(1)', 'file:///tmp/test.txt'])( + 'classifies %s as deny', + (url) => expect(classifyScheme(url)).toBe('deny') + ) +}) + +describe('classifyScheme — custom schemes', () => { + it.each(['obsidian://open?vault=V&file=F', 'vscode://file/path', 'slack://channel?team=T123'])( + 'classifies %s as custom', + (url) => expect(classifyScheme(url)).toBe('custom') + ) +}) + +describe('classifyScheme — security bypass axes', () => { + // (a) case bypass + it.each(['JavaScript:alert(1)', 'JAVASCRIPT:alert(1)'])('blocks %s (case) as deny', (url) => + expect(classifyScheme(url)).toBe('deny') + ) + // (b) whitespace prefix on entire URL + it.each(['\tjavascript:alert(1)', '\njavascript:alert(1)', ' javascript:alert(1)'])('blocks %s (whitespace prefix) as deny', (url) => + expect(classifyScheme(url)).toBe('deny') + ) + // (c) percent-encoding + it('%6Aavascript: (encoded j) → deny', () => expect(classifyScheme('%6Aavascript:alert(1)')).toBe('deny')) + it('jav%61script: (encoded a) → deny', () => expect(classifyScheme('jav%61script:alert(1)')).toBe('deny')) + // (d) double-encoding — 2-pass decode unwraps javascript%253A → javascript%3A → javascript: + // With 2-pass decode, the second pass resolves %3A → literal colon, so the scheme + // "javascript" is extracted and hits DENY_SCHEMES → 'deny' via scheme-match. + it('javascript%253A (double-encoded colon) → deny', () => expect(classifyScheme('javascript%253Aalert(1)')).toBe('deny')) + // `javascript%3Aalert(1)` — single-encoded colon. decodeURIComponent yields + // `javascript:alert(1)` with a literal colon, so classifyScheme extracts scheme + // "javascript" → hits DENY_SCHEMES → 'deny'. This is the real scheme-match path. + it('javascript%3A (single-encoded colon) → deny via scheme-match', () => expect(classifyScheme('javascript%3Aalert(1)')).toBe('deny')) + // (e) control characters spliced into scheme name + // Browsers strip \n, \t, \r from URL schemes during navigation; our normalizer + // must do the same before comparing against the deny list. + it('java\\nscript: (newline in scheme) → deny', () => expect(classifyScheme('java\nscript:alert(1)')).toBe('deny')) + it('java\\tscript: (tab in scheme) → deny', () => expect(classifyScheme('java\tscript:alert(1)')).toBe('deny')) + it('java\\rscript: (carriage return in scheme) → deny', () => expect(classifyScheme('java\rscript:alert(1)')).toBe('deny')) + it('java script: (space in scheme) → deny', () => expect(classifyScheme('java script:alert(1)')).toBe('deny')) + // percent-encoded control chars inside the scheme — decoded by pass 1 then stripped + it('java%0Ascript: (percent-encoded newline in scheme) → deny', () => expect(classifyScheme('java%0Ascript:alert(1)')).toBe('deny')) + // leading whitespace on the URL itself (already covered by trimStart, added for completeness) + it('\\tjavascript: (leading tab on URL) → deny', () => expect(classifyScheme('\tjavascript:alert(1)')).toBe('deny')) + // case sanity (also covered above but keep explicit) + it('JAVASCRIPT: → deny', () => expect(classifyScheme('JAVASCRIPT:alert(1)')).toBe('deny')) + it('JaVaScRipT: → deny', () => expect(classifyScheme('JaVaScRipT:')).toBe('deny')) + // edge + it('empty string → deny', () => expect(classifyScheme('')).toBe('deny')) + it('no-colon string → deny', () => expect(classifyScheme('not-a-url')).toBe('deny')) +}) + +// ── denyOnlyTransform ───────────────────────────────────────────────────────── + +describe('denyOnlyTransform', () => { + it.each(['javascript:alert(1)', 'data:text/html,xss', 'vbscript:x', 'file:///tmp/f', 'JavaScript:alert(1)', 'jav%61script:alert(1)', '%6Aavascript:alert(1)'])( + 'strips %s → ""', + (url) => expect(denyOnlyTransform(url)).toBe('') + ) + it.each(['https://example.com', 'http://example.com', 'mailto:a@b.com'])( + 'passes %s through unchanged', + (url) => expect(denyOnlyTransform(url)).toBe(url) + ) + it.each(['obsidian://open?vault=V', 'vscode://file/path', 'slack://channel'])( + 'passes custom scheme %s through unchanged', + (url) => expect(denyOnlyTransform(url)).toBe(url) + ) + it('passes relative path through', () => { + expect(denyOnlyTransform('/relative/path')).toBe('/relative/path') + }) +}) + +// ── useAllowedSchemes (inlined hook, tested via A component) ────────────────── + +describe('localStorage roundtrip via A component', () => { + it('renders href="#" for unallowed custom scheme', () => { + renderA({ href: 'obsidian://open?vault=V&file=F', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('#') + }) + + it('renders real href for pre-seeded allowed custom scheme', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(['obsidian'])) + renderA({ href: 'obsidian://open?vault=V&file=F', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('obsidian://open?vault=V&file=F') + }) + + it('isAllowed returns false for deny scheme even if tampered into localStorage', () => { + // Tamper: put javascript into allowed list + localStorage.setItem(STORAGE_KEY, JSON.stringify(['javascript'])) + // The A component should still not treat javascript as allowed (it classifies to 'deny') + renderA({ href: 'javascript:alert(1)', children: 'evil' }) + // href="" comes from denyOnlyTransform; onclick should preventDefault + const link = document.querySelector('a')! + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventSpy = vi.spyOn(clickEvent, 'preventDefault') + link.dispatchEvent(clickEvent) + expect(preventSpy).toHaveBeenCalled() + }) +}) + +describe('cross-tab sync via storage event', () => { + it('updates after storage event fires (simulated other-tab write)', () => { + // Start with empty storage, render an unallowed custom link + renderA({ href: 'obsidian://open', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('#') + + // Simulate another tab writing the allowed schemes + act(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(['obsidian'])) + window.dispatchEvent(new StorageEvent('storage', { + key: STORAGE_KEY, + newValue: JSON.stringify(['obsidian']), + storageArea: localStorage, + })) + }) + + // After storage event the hook re-reads; re-render the component + cleanup() + renderA({ href: 'obsidian://open', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('obsidian://open') + }) +}) + +// ──
component — click handler ──────────────────────────────────────────── + +describe('markdown component — click handler', () => { + it('prevents default when href is empty string (deny-scheme link)', () => { + renderA({ href: '', children: 'deny' }) + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventSpy = vi.spyOn(clickEvent, 'preventDefault') + document.querySelector('a')!.dispatchEvent(clickEvent) + expect(preventSpy).toHaveBeenCalled() + }) + + it('prevents default when href is undefined', () => { + renderA({ children: 'no href' }) + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventSpy = vi.spyOn(clickEvent, 'preventDefault') + document.querySelector('a')!.dispatchEvent(clickEvent) + expect(preventSpy).toHaveBeenCalled() + }) + + it('renders href="#" for an unallowed custom scheme (no middle-click bypass)', () => { + renderA({ href: 'obsidian://open?vault=V&file=F', children: 'note' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('#') + }) + + it('renders the real href for an IANA scheme (https)', () => { + renderA({ href: 'https://example.com', children: 'link' }) + expect(document.querySelector('a')!.getAttribute('href')).toBe('https://example.com') + }) + + it('does not navigate for a deny scheme (href="")', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + renderA({ href: '', children: 'evil' }) + fireEvent.click(document.querySelector('a')!) + expect(openSpy).not.toHaveBeenCalled() + openSpy.mockRestore() + }) +}) + +// ── relative / no-scheme hrefs — regression guard ──────────────────────────── +// +// Finding 2: denyOnlyTransform passes relative hrefs through unchanged (no colon +// → not a scheme URL), but the onClick handler called classifyScheme(href) +// which returned 'deny' for inputs with no valid scheme → preventDefault was +// called → relative/internal links were silently blocked. +// +// Fix: must detect hrefs that have no scheme and treat them as 'iana' so the +// browser/router can navigate normally. + +describe('markdown component — relative / no-scheme hrefs navigate normally', () => { + // Each of these hrefs has no URL scheme. Clicks must NOT be prevented. + // We verify by checking that preventDefault is NOT called on the click event. + + function clickAndCheckNotPrevented(href: string) { + renderA({ href, children: 'link' }) + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventSpy = vi.spyOn(clickEvent, 'preventDefault') + document.querySelector('a')!.dispatchEvent(clickEvent) + expect(preventSpy).not.toHaveBeenCalled() + cleanup() + } + + it('/settings → click not prevented (absolute-path relative link)', () => { + clickAndCheckNotPrevented('/settings') + }) + + it('./foo → click not prevented (relative-path link)', () => { + clickAndCheckNotPrevented('./foo') + }) + + it('#section → click not prevented (hash fragment link)', () => { + clickAndCheckNotPrevented('#section') + }) + + it('?q=1 → click not prevented (query-only link)', () => { + clickAndCheckNotPrevented('?q=1') + }) + + it('/path:colon → click not prevented (path with colon, no scheme)', () => { + // "/" appears before ":" so this is a path, not a scheme. + clickAndCheckNotPrevented('/path:colon') + }) + + it('//example.com → click not prevented (protocol-relative URL, no colon)', () => { + // Protocol-relative URLs have no colon; browsers navigate them as the + // current origin's protocol, same as any other relative href. + clickAndCheckNotPrevented('//example.com/path') + }) + + it('https://example.com → click not prevented (regression: IANA still passes through)', () => { + clickAndCheckNotPrevented('https://example.com') + }) +}) + +// ── intra-tab cross-provider sync (schemeListeners emitter) ────────────────── +// +// P7e.1 added a module-level `schemeListeners: Set` so that +// when two sibling s exist in the same window (e.g. +// MarkdownText + Reasoning in AssistantMessage), clicking "Always allow" in +// one provider's dialog immediately updates the other without waiting for a +// cross-tab storage event (which browsers only fire in OTHER tabs). +// +// This test asserts that path: mount two sibling providers, trigger allow() +// in one via the dialog flow, verify the other's link href updates. + +describe('intra-tab cross-provider sync (schemeListeners emitter)', () => { + it('allowing a scheme in one UriConfirmProvider propagates to a sibling provider', async () => { + localStorage.clear() + + // Suppress window.open — handleAlwaysAllow calls it after allow() + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + render( + + + link1 + + + link2 + + + ) + + const links = screen.getAllByRole('link') + const [a1, a2] = links + + // Both links start blocked (href="#") because obsidian is not yet allowed. + expect(a1.getAttribute('href')).toBe('#') + expect(a2.getAttribute('href')).toBe('#') + + // Click the first link → its provider opens the UriConfirmDialog. + await act(async () => { + fireEvent.click(a1) + }) + + // The "Always allow obsidian:" button is rendered by UriConfirmDialog + // via Radix Dialog portal into document.body. + const alwaysBtn = await waitFor(() => + screen.getByRole('button', { name: /always allow obsidian/i }) + ) + + await act(async () => { + fireEvent.click(alwaysBtn) + }) + + // After "Always allow", the schemeListeners emitter must have notified + // the sibling provider synchronously. Both links should now carry the + // live href (not '#'). + await waitFor(() => { + expect(a1.getAttribute('href')).toBe('obsidian://open?a=1') + expect(a2.getAttribute('href')).toBe('obsidian://open?a=2') + }) + + openSpy.mockRestore() + }) +}) diff --git a/web/src/components/assistant-ui/markdown-text.test.ts b/web/src/components/assistant-ui/markdown-text.test.ts new file mode 100644 index 00000000..43745f3d --- /dev/null +++ b/web/src/components/assistant-ui/markdown-text.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' +import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink' +import { MARKDOWN_PLUGINS } from '@/components/assistant-ui/markdown-text' + +describe('MARKDOWN_PLUGINS integration', () => { + it('includes remarkNonHttpsAutolink', () => { + expect(MARKDOWN_PLUGINS).toContain(remarkNonHttpsAutolink) + }) + + it('places remarkNonHttpsAutolink BEFORE remarkStripCjkAutolink so CJK strip sees new links', () => { + const idxAutolink = MARKDOWN_PLUGINS.indexOf(remarkNonHttpsAutolink) + const idxCjk = MARKDOWN_PLUGINS.indexOf(remarkStripCjkAutolink) + expect(idxAutolink).toBeGreaterThan(0) // not first (remarkGfm is first) + expect(idxAutolink).toBeLessThan(idxCjk) // autolink before CJK strip + }) +}) diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 308052be..925686cb 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -1,6 +1,7 @@ import '@assistant-ui/react-markdown/styles/dot.css' import type { ComponentPropsWithoutRef, MouseEvent } from 'react' +import { useState, useCallback, useEffect, useMemo, createContext, useContext, type ReactNode } from 'react' import { MarkdownTextPrimitive, unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, @@ -13,6 +14,7 @@ import rehypeKatex from 'rehype-katex' import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code' import { useNavigate } from '@tanstack/react-router' import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink' +import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' import { cn, encodeBase64 } from '@/lib/utils' import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' @@ -20,10 +22,26 @@ import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { CopyIcon, CheckIcon } from '@/components/icons' import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links' +import { UriConfirmDialog } from '@/components/UriConfirmDialog' import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' -export const MARKDOWN_PLUGINS = [remarkGfm, remarkStripCjkAutolink, remarkMath, remarkDisableIndentedCode, remarkFilePathLinks] satisfies NonNullable +// ── Plugin array ──────────────────────────────────────────────────────────── +// Order: remarkGfm → remarkNonHttpsAutolink → remarkStripCjkAutolink → remarkMath → remarkDisableIndentedCode → remarkFilePathLinks +// remarkNonHttpsAutolink must run BEFORE remarkStripCjkAutolink so that the +// CJK strip plugin sees the new link nodes and can trim trailing CJK punctuation +// from them. Both must come before remarkMath (to avoid treating TeX as URI). +// remarkFilePathLinks runs last to convert file paths → links after all other +// transforms have settled. +export const MARKDOWN_PLUGINS = [ + remarkGfm, + remarkNonHttpsAutolink, + remarkStripCjkAutolink, + remarkMath, + remarkDisableIndentedCode, + remarkFilePathLinks, // upstream — file path → link conversion, runs last +] satisfies NonNullable + export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable export const MARKDOWN_CLASSNAME = 'aui-md happy-chat-text min-w-0 max-w-full break-words text-[var(--app-fg)]' export const MARKDOWN_COMPONENTS_BY_LANGUAGE = { @@ -32,6 +50,292 @@ export const MARKDOWN_COMPONENTS_BY_LANGUAGE = { }, } satisfies NonNullable +// ── URI scheme policy (inlined from url-scheme-policy.ts) ─────────────────── +// +// IANA-registered safe schemes — mirrors react-markdown defaultUrlTransform exactly. +// Source: node_modules/react-markdown/lib/index.js:124 /^(https?|ircs?|mailto|xmpp)$/i +const IANA_SAFE_SCHEMES: ReadonlySet = new Set([ + 'http', + 'https', + 'irc', + 'ircs', + 'mailto', + 'xmpp', +]) + +// Schemes that must always be blocked regardless of user preference. +// Includes file: because Chromium denies navigation from http(s) origins. +const DENY_SCHEMES: ReadonlySet = new Set([ + 'javascript', + 'data', + 'vbscript', + 'file', +]) + +/** + * Extract the normalised scheme from a URL string. + * + * Applies up to two rounds of decodeURIComponent so that double-encoded + * bypass attempts (`javascript%253A` → `javascript%3A` → `javascript:`) + * are unwrapped before the scheme is extracted. + * + * After decoding, ASCII control characters (U+0000–U+001F, U+007F) and + * all whitespace are stripped from the extracted scheme string so that + * browsers' built-in normalization — which silently discards \t, \n, \r + * and space from scheme names during navigation — cannot be used to bypass + * the deny list (e.g. `java\nscript:alert(1)` → scheme `"javascript"`). + * + * Returns null when no valid scheme separator is found. + */ +function normalizedScheme(url: string): string | null { + let value = url.trimStart() + for (let i = 0; i < 2; i++) { + try { + const next = decodeURIComponent(value) + if (next === value) break + value = next + } catch { + break + } + } + const colonIndex = value.indexOf(':') + if (colonIndex <= 0) return null + // Strip ASCII control chars (U+0000–U+001F, DEL U+007F) and all whitespace + // from the scheme name. Browsers strip exactly these characters during + // navigation, so `java\nscript` navigates as `javascript`. + return value.slice(0, colonIndex).replace(/[\x00-\x1F\x7F\s]/g, '').toLowerCase() +} + +/** + * Classify a URL string by its scheme. + * + * Uses normalizedScheme() so that common bypass patterns — control characters + * spliced into the scheme name, tab/space prefix, single- and double-encoded + * scheme characters — are all caught before the deny/IANA comparison. + * + * @returns 'iana' | 'deny' | 'custom' + */ +export function classifyScheme(url: string): 'iana' | 'deny' | 'custom' { + const scheme = normalizedScheme(url) + if (scheme === null) return 'deny' + if (DENY_SCHEMES.has(scheme)) return 'deny' + if (IANA_SAFE_SCHEMES.has(scheme)) return 'iana' + return 'custom' +} + +/** + * Returns true when href contains a URL scheme (i.e. a "scheme:" prefix that + * appears before any path/query/fragment delimiter). + * + * This distinguishes `mailto:foo@bar` (has scheme → true) from purely relative + * hrefs like `/settings`, `./foo`, `#section`, `?q=1`, or paths that contain a + * colon after a path segment like `/path:colon` (→ false, because `/` appears + * before `:`). Protocol-relative URLs (`//host/path`) have no colon and are + * also treated as scheme-less; browsers navigate them as the current origin's + * protocol, same as a normal relative path would do. + * + * Used by to short-circuit classifyScheme for no-scheme hrefs and treat them + * as 'iana' so the browser/router can handle them normally (fixing the regression + * where relative markdown links were silently blocked by the onClick deny guard). + */ +function hasScheme(href: string): boolean { + const colonIdx = href.indexOf(':') + if (colonIdx <= 0) return false + const boundaryIdx = href.search(/[/?#]/) + return boundaryIdx < 0 || colonIdx < boundaryIdx +} + +// ── URL sanitize transform (deny-only) ────────────────────────────────────── +// Passed as urlTransform to MarkdownTextPrimitive. Only deny-listed schemes +// are stripped; every other scheme (IANA + custom) passes through so the +// is preserved for the onClick layer to handle. +// +// Uses classifyScheme as the single source of truth for scheme extraction so +// that percent-encoded bypass patterns (jav%61script:, %6Aavascript:) are +// caught by the same decoding logic used at click time. +// +// Relative paths (no colon, or colon only in path/query) have no scheme and +// are always passed through — they are safe and used for img src etc. +// +// Known limitation (FIX 5, deferred): data:image/png;base64,... used in +// is also stripped because DENY_SCHEMES includes 'data'. However, +// react-markdown's own defaultUrlTransform strips all data: URLs identically, +// so this is not a regression introduced by this PR. +export function denyOnlyTransform(url: string): string { + if (!url) return url + const trimmed = url.trimStart() + const colonIdx = trimmed.indexOf(':') + const slashIdx = trimmed.search(/[/?#]/) + if (colonIdx < 0 || (slashIdx >= 0 && slashIdx < colonIdx)) { + return url + } + return classifyScheme(url) === 'deny' ? '' : url +} + +// ── Allowed-schemes state (inlined from useAllowedSchemes.ts) ─────────────── + +const STORAGE_KEY = 'hapi-allowed-schemes' + +// Module-level subscriber set for intra-tab cross-provider sync (P7e.1). +// +// `window` `storage` events fire only in OTHER tabs/windows, not in the same +// tab that called setItem. When two s exist in the same +// window (e.g. MarkdownText + Reasoning as sibling providers in AssistantMessage), +// clicking "Always allow" in one provider's dialog must immediately update the +// other without waiting for a page reload. +// +// Solution: a module-level Set of subscriber callbacks. writeAllowedToStorage +// calls each subscriber synchronously after writing to localStorage. Each +// useAllowedSchemes instance registers on mount and unregisters on unmount. +// Cross-tab sync continues to use the existing window `storage` event. +type SchemeListener = (schemes: ReadonlySet) => void +const schemeListeners = new Set() + +function readAllowedFromStorage(): Set { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return new Set() + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return new Set() + return new Set( + parsed + .filter((s): s is string => typeof s === 'string') + .filter((s) => !DENY_SCHEMES.has(s)) + ) + } catch { + return new Set() + } +} + +function writeAllowedToStorage(schemes: ReadonlySet): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(schemes))) + } catch { + // Storage quota exceeded — silently ignore; in-memory set still works. + } + // Notify all same-tab subscribers synchronously so sibling providers + // (e.g. MarkdownText + Reasoning) update in the same React event loop tick. + for (const listener of schemeListeners) { + listener(schemes) + } +} + +function useAllowedSchemes() { + const [allowed, setAllowed] = useState>(() => readAllowedFromStorage()) + + useEffect(() => { + // Cross-tab sync: storage event fires in other tabs/windows. + function handleStorage(e: StorageEvent) { + if (e.key !== STORAGE_KEY) return + setAllowed(readAllowedFromStorage()) + } + window.addEventListener('storage', handleStorage) + + // Intra-tab sync: subscribe to module-level emitter so sibling + // providers in the same window see updates without page reload. + function handleIntraTab(schemes: ReadonlySet) { + setAllowed(new Set(schemes)) + } + schemeListeners.add(handleIntraTab) + + return () => { + window.removeEventListener('storage', handleStorage) + schemeListeners.delete(handleIntraTab) + } + }, []) + + const allow = useCallback((scheme: string) => { + if (DENY_SCHEMES.has(scheme)) return + setAllowed((prev) => { + if (prev.has(scheme)) return prev + const next = new Set(prev) + next.add(scheme) + writeAllowedToStorage(next) + return next + }) + }, []) + + const isAllowed = useCallback( + (scheme: string): boolean => { + if (DENY_SCHEMES.has(scheme)) return false + return allowed.has(scheme) + }, + [allowed] + ) + + return { allowed, allow, isAllowed } +} + +// ── UriConfirmContext — one dialog per markdown root ──────────────────────── + +type DialogState = { + url: string + scheme: string +} | null + +type UriConfirmContextValue = { + openUri: (url: string, scheme: string) => void + /** Shared isAllowed so all tags in this tree re-render on the same state update. */ + isAllowed: (scheme: string) => boolean +} + +const UriConfirmContext = createContext(null) + +/** + * Provider that mounts a single for all links in its + * subtree. Wrap each MarkdownText / MarkdownRenderer / Reasoning surface with + * this so that only one dialog instance exists per markdown root instead of + * one per tag. + * + * The allowed-schemes state lives here so all child components share the + * same React state — when "Always allow" is clicked, every link in the tree + * re-renders in the same React commit (no cross-tab storage event required). + */ +export function UriConfirmProvider({ children }: { children: ReactNode }) { + const [dialog, setDialog] = useState(null) + const { allow, isAllowed } = useAllowedSchemes() + + const openUri = useCallback((url: string, scheme: string) => { + setDialog({ url, scheme }) + }, []) + + const closeDialog = () => setDialog(null) + + const handleOpen = () => { + if (!dialog) return + closeDialog() + window.open(dialog.url, '_blank', 'noopener,noreferrer') + } + + const handleAlwaysAllow = (allowedScheme: string) => { + allow(allowedScheme) + closeDialog() + if (dialog) { + window.open(dialog.url, '_blank', 'noopener,noreferrer') + } + } + + const contextValue = useMemo(() => ({ openUri, isAllowed }), [openUri, isAllowed]) + + return ( + + {children} + {dialog !== null && ( + + )} + + ) +} + +// ── Components ─────────────────────────────────────────────────────────────── + function CodeHeader(props: CodeHeaderProps) { const { copied, copy } = useCopyToClipboard() const language = props.language && props.language !== 'unknown' ? props.language : 'text' @@ -122,8 +426,35 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin ) } +/** + * Anchor component with URI scheme policy enforcement. + * + * - Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1): passed through + * without interception so the browser or SPA router can navigate normally. + * - IANA safe schemes (https/http/mailto/irc/ircs/xmpp): navigate directly. + * - Deny schemes (javascript/data/vbscript/file): silently block. denyOnlyTransform + * already strips the href to "", so href="" in DOM (belt-and-suspenders onClick + * guard also calls preventDefault). + * - Custom schemes, NOT yet allowed by user: href="#" in DOM (not the live URL) + * so middle-click / drag-to-bar cannot bypass the dialog. Dialog opens on left-click + * via the shared UriConfirmContext (single dialog per markdown root). + * - Custom schemes, already allowed by user: live href in DOM; middle-click works. + * - File-path links (decoded by remarkFilePathLinks): delegated to FilePathAnchor + * which uses useNavigate for SPA routing. + */ function A(props: ComponentPropsWithoutRef<'a'>) { const chat = useOptionalHappyChatContext() + // useContext must be called unconditionally before any early return so that + // the Rules of Hooks are satisfied regardless of whether `filePath` is set. + // isAllowed comes exclusively from the shared context. Every call site + // (MarkdownText, Reasoning, MarkdownRenderer) wraps its surface with + // , so ctx is always present in production. + // + // The previous localHook fallback instantiated useAllowedSchemes() even + // when ctx was present — 20 anchors → 21 storage listeners per mount. + // Removing it requires tests that render directly to wrap with + // (or supply a mock UriConfirmContext.Provider). + const ctx = useContext(UriConfirmContext) const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel @@ -134,10 +465,61 @@ function A(props: ComponentPropsWithoutRef<'a'>) { return } + const isAllowed = ctx?.isAllowed ?? (() => false) + + const { onClick, href, ...rest } = props + // Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) must not be + // classified via classifyScheme — it returns 'deny' for inputs with no valid + // scheme, which previously caused the onClick handler to preventDefault and + // silently break all relative markdown links. Treat them as 'iana' so the + // browser or SPA router can navigate normally. + const isRelative = href ? !hasScheme(href) : false + const classification = href && !isRelative ? classifyScheme(href) : 'iana' + const colonIdx = href ? href.indexOf(':') : -1 + const scheme = colonIdx > 0 && !isRelative ? href!.slice(0, colonIdx).toLowerCase() : '' + const isCustomAllowed = classification === 'custom' && isAllowed(scheme) + + const domHref = + classification === 'iana' || isCustomAllowed + ? href + : classification === 'custom' + ? '#' + : href + + const handleClick = (e: React.MouseEvent) => { + const url = href ?? '' + + if (!url) { + e.preventDefault() + return + } + + if (classification === 'deny') { + e.preventDefault() + return + } + + if (classification === 'iana') { + onClick?.(e) + return + } + + if (isCustomAllowed) { + onClick?.(e) + return + } + + // Unallowed custom scheme: show confirmation dialog via context. + e.preventDefault() + ctx?.openUri(url, scheme) + } + return ( ) @@ -280,12 +662,15 @@ export const defaultComponents = memoizeMarkdownComponents({ export function MarkdownText() { return ( - + + + ) } diff --git a/web/src/components/assistant-ui/reasoning.tsx b/web/src/components/assistant-ui/reasoning.tsx index ac72711f..10c9cc08 100644 --- a/web/src/components/assistant-ui/reasoning.tsx +++ b/web/src/components/assistant-ui/reasoning.tsx @@ -8,6 +8,8 @@ import { MARKDOWN_PLUGINS, MARKDOWN_REHYPE_PLUGINS, defaultComponents, + denyOnlyTransform, + UriConfirmProvider, } from '@/components/assistant-ui/markdown-text' function ChevronIcon(props: { className?: string; open?: boolean }) { @@ -41,13 +43,16 @@ function ShimmerDot() { export const Reasoning: FC = () => { return ( - + + + ) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 64219608..7a1190ef 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -83,6 +83,10 @@ export default { 'session.action.copy': 'Copy', // Dialogs + 'dialog.uri.title': 'Open this link?', + 'dialog.uri.description': 'This link uses a custom URI scheme. Make sure you trust this link before opening it.', + 'dialog.uri.open': 'Open', + 'dialog.uri.alwaysAllow': 'Always allow {scheme}:', 'dialog.rename.title': 'Rename Session', 'dialog.rename.placeholder': 'Session name', 'dialog.rename.save': 'Save', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 92a40980..3f6ca797 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -83,6 +83,10 @@ export default { 'session.action.copy': '复制', // Dialogs + 'dialog.uri.title': '打开此链接?', + 'dialog.uri.description': '此链接使用自定义 URI 协议。请确认您信任此链接后再打开。', + 'dialog.uri.open': '打开', + 'dialog.uri.alwaysAllow': '始终允许 {scheme}:', 'dialog.rename.title': '重命名会话', 'dialog.rename.placeholder': '会话名称', 'dialog.rename.save': '保存', diff --git a/web/src/lib/remark-non-https-autolink.test.ts b/web/src/lib/remark-non-https-autolink.test.ts new file mode 100644 index 00000000..d2e5237b --- /dev/null +++ b/web/src/lib/remark-non-https-autolink.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' + +/** + * Unit tests for remark-non-https-autolink. + * + * We test the tree-transform function directly by feeding it MDAST + * structures. The plugin converts raw "scheme://..." text in paragraph + * text nodes into link nodes, skipping http/https (handled by GFM), + * explicit markdown links, and code blocks/inline code. + */ +describe('remarkNonHttpsAutolink', () => { + const transform = remarkNonHttpsAutolink() + + function makeRoot(children: any[]) { return { type: 'root', children } as any } + function makeParagraph(children: any[]) { return { type: 'paragraph', children } as any } + function makeText(value: string) { return { type: 'text', value } as any } + function makeLink(url: string, textValue: string) { + return { type: 'link', url, children: [{ type: 'text', value: textValue }] } as any + } + function makeCode(lang: string, value: string) { return { type: 'code', lang, value } as any } + function makeInlineCode(value: string) { return { type: 'inlineCode', value } as any } + + // ── Basic autolink ─────────────────────────────────────────────────── + + it('converts obsidian:// raw URI in text to a link node', () => { + const tree = makeRoot([makeParagraph([makeText('Check obsidian://open?vault=V&file=F here')])]) + transform(tree) + const para = tree.children[0] + expect(para.children[0]).toMatchObject({ type: 'text', value: 'Check ' }) + expect(para.children[1]).toMatchObject({ type: 'link', url: 'obsidian://open?vault=V&file=F' }) + expect(para.children[2]).toMatchObject({ type: 'text', value: ' here' }) + }) + + it.each([ + ['vscode://file/path/to/file', 'Open vscode://file/path/to/file'], + ['slack://channel?team=T123', 'Join slack://channel?team=T123'], + ])('converts %s URI to a link node', (expectedUrl, inputText) => { + const tree = makeRoot([makeParagraph([makeText(inputText)])]) + transform(tree) + const link = tree.children[0].children.find((c: any) => c.type === 'link') + expect(link?.url).toBe(expectedUrl) + }) + + it('sets the link text to the full URI (autolink style)', () => { + const tree = makeRoot([makeParagraph([makeText('obsidian://vault/file')])]) + transform(tree) + const link = tree.children[0].children[0] + expect(link.children[0].value).toBe('obsidian://vault/file') + }) + + // ── http/https NOT converted (GFM handles those) ───────────────────── + + it.each([ + 'http://example.com', + 'https://example.com', + ])('does NOT convert %s (GFM handles those)', (url) => { + const tree = makeRoot([makeParagraph([makeText(`Visit ${url}`)])]) + transform(tree) + const links = tree.children[0].children.filter((c: any) => c.type === 'link') + expect(links.length).toBe(0) + }) + + // ── Existing link nodes not touched ────────────────────────────────── + + it('does not modify an existing explicit markdown link node', () => { + const existingLink = makeLink('obsidian://open', 'My Note') + const tree = makeRoot([makeParagraph([existingLink])]) + transform(tree) + const para = tree.children[0] + expect(para.children.length).toBe(1) + expect(para.children[0]).toMatchObject({ type: 'link', url: 'obsidian://open' }) + expect(para.children[0].children[0].value).toBe('My Note') + }) + + // ── Code blocks / inline code not touched ──────────────────────────── + + it('does not linkify URIs inside code blocks', () => { + const tree = makeRoot([makeCode('sh', 'open obsidian://vault/file')]) + transform(tree) + expect(tree.children[0].type).toBe('code') + expect(tree.children[0].value).toBe('open obsidian://vault/file') + }) + + it('does not linkify URIs inside inline code', () => { + const tree = makeRoot([makeParagraph([makeInlineCode('obsidian://vault/file')])]) + transform(tree) + expect(tree.children[0].children[0].type).toBe('inlineCode') + }) + + // ── Trailing punctuation handling ──────────────────────────────────── + + it.each([ + ['period', 'See obsidian://open?vault=V.', 'obsidian://open?vault=V', '.'], + ['comma', 'Link obsidian://open,', 'obsidian://open', ','], + ['closing paren', 'See (obsidian://open)', 'obsidian://open', ')'], + ])('strips trailing %s from the linked URL', (_label, inputText, expectedUrl, expectedTrailing) => { + const tree = makeRoot([makeParagraph([makeText(inputText)])]) + transform(tree) + const para = tree.children[0] + const link = para.children.find((c: any) => c.type === 'link') + expect(link?.url).toBe(expectedUrl) + const trailingText = para.children[para.children.indexOf(link) + 1] + expect(trailingText?.value).toContain(expectedTrailing) + }) + + // ── Balanced paren/bracket preservation (GFM autolink literal behaviour) ─── + + it.each([ + ['balanced paren in URL', 'obsidian://open?file=Note(1)', 'obsidian://open?file=Note(1)'], + ['balanced bracket in URL', 'obsidian://open?file=Note[1]', 'obsidian://open?file=Note[1]'], + ['nested balanced parens', 'obsidian://open?q=(a(b)c)', 'obsidian://open?q=(a(b)c)'], + ])('keeps %s inside the linked URL', (_label, inputText, expectedUrl) => { + const tree = makeRoot([makeParagraph([makeText(inputText)])]) + transform(tree) + const link = tree.children[0].children.find((c: any) => c.type === 'link') + expect(link?.url).toBe(expectedUrl) + // No trailing text node should be emitted for a balanced URL. + expect(tree.children[0].children.length).toBe(1) + }) + + it('strips period after a balanced-paren URL but keeps the parens', () => { + const tree = makeRoot([makeParagraph([makeText('See obsidian://open?file=Note(1).')])]) + transform(tree) + const para = tree.children[0] + const link = para.children.find((c: any) => c.type === 'link') + expect(link?.url).toBe('obsidian://open?file=Note(1)') + const trailingText = para.children[para.children.indexOf(link) + 1] + expect(trailingText?.value).toBe('.') + }) + + it('strips an unmatched closing paren (no opener in URL body)', () => { + const tree = makeRoot([makeParagraph([makeText('(see obsidian://x).')])]) + transform(tree) + const para = tree.children[0] + const link = para.children.find((c: any) => c.type === 'link') + expect(link?.url).toBe('obsidian://x') + const trailingText = para.children[para.children.indexOf(link) + 1] + expect(trailingText?.value).toBe(').') + }) + + // ── Multiple URIs in one text node ─────────────────────────────────── + + it('converts multiple non-https URIs in the same text node', () => { + const tree = makeRoot([ + makeParagraph([makeText('A: obsidian://vault/a and B: vscode://file/b done')]) + ]) + transform(tree) + const links = tree.children[0].children.filter((c: any) => c.type === 'link') + expect(links.length).toBe(2) + expect(links[0].url).toBe('obsidian://vault/a') + expect(links[1].url).toBe('vscode://file/b') + }) + + // ── Scheme-only / edge cases ───────────────────────────────────────── + + it('does not linkify a bare scheme without "://"', () => { + // mailto: without // is valid URI but our plugin targets scheme:// only + const tree = makeRoot([makeParagraph([makeText('mailto:user@example.com')])]) + transform(tree) + const links = tree.children[0].children.filter((c: any) => c.type === 'link') + expect(links.length).toBe(0) + }) +}) diff --git a/web/src/lib/remark-non-https-autolink.ts b/web/src/lib/remark-non-https-autolink.ts new file mode 100644 index 00000000..d2172f63 --- /dev/null +++ b/web/src/lib/remark-non-https-autolink.ts @@ -0,0 +1,173 @@ +/** + * Remark plugin that converts raw non-https URI scheme text into link nodes. + * + * GFM (`remark-gfm`) already handles `http://`, `https://`, and `www.` autolinks. + * This plugin handles the remainder: any `scheme://...` pattern where the scheme + * is NOT `http` or `https` (to avoid duplicating GFM's work). + * + * Pipeline position: before `remarkStripCjkAutolink`, before `remarkMath`. + * + * Security note: this plugin deliberately has NO scheme allowlist — it converts + * every `scheme://` pattern it finds. The sanitize layer (`urlTransform`) and + * the onClick layer (`classifyScheme`) handle blocking/confirmation downstream. + * Keeping the plugin allowlist-free means new custom schemes work automatically + * without touching this file. + */ + +// Matches a non-http(s) URI of the form `scheme://...` where: +// - scheme is one or more ASCII letters (a-z), digits, +, -, or . +// - scheme is NOT "http" or "https" (those are GFM's domain) +// - followed by "://" and a run of non-whitespace characters +// +// Trailing punctuation (.,!?;:) and closing brackets/parens are stripped +// by a post-match trim step so "See obsidian://x." doesn't include the ".". +const NON_HTTPS_URI_RE = /\b(?!https?:\/\/)([a-zA-Z][a-zA-Z0-9+\-.]*):\/\/[^\s]*/g + +// Characters that may be stripped from the end of a matched URI. +// `)` and `]` are only stripped when the URL body has no unmatched opening +// counterpart — this mirrors GFM autolink literal behaviour and keeps URIs +// like `obsidian://open?file=Note(1)` intact. +const TRAILING_PUNCT_CHARS = /[.,;!?:)>\]'"]/ + +/** + * Strip trailing punctuation from a URI, but preserve `)` / `]` that close an + * unmatched `(` / `[` inside the URL body. + * + * Examples: + * `obsidian://x.` → stripped `obsidian://x`, trailing `.` + * `obsidian://open?file=Note(1)` → stripped unchanged, trailing `` + * `obsidian://x).` → stripped `obsidian://x`, trailing `).` (no `(` to balance) + */ +function stripTrailingPunct(uri: string): { stripped: string; trailing: string } { + let stripped = uri + let trailing = '' + while (stripped.length > 0) { + const last = stripped[stripped.length - 1] + if (!TRAILING_PUNCT_CHARS.test(last)) break + + if (last === ')' || last === ']') { + const open = last === ')' ? '(' : '[' + const inner = stripped.slice(0, -1) + let opens = 0 + let closes = 0 + for (const ch of inner) { + if (ch === open) opens++ + else if (ch === last) closes++ + } + // If the inner URL already has more opens than closes, the trailing + // closer balances an earlier opener and belongs to the URL. + if (closes < opens) break + } + + trailing = last + trailing + stripped = stripped.slice(0, -1) + } + return { stripped, trailing } +} + +interface MdastNode { + type: string + url?: string + value?: string + lang?: string + children?: MdastNode[] +} + +/** + * Walk all text nodes inside paragraph-like containers and replace + * `scheme://...` patterns with link nodes. + * + * Skips: + * - `code` (fenced code blocks) and `inlineCode` nodes — never touched. + * - `link` / `linkReference` nodes — their children are not re-processed + * (existing links are left as-is). + */ +function visitAndLinkify(node: MdastNode): void { + if (!node.children) return + + const newChildren: MdastNode[] = [] + + for (const child of node.children) { + // Don't descend into existing links or code nodes. + if ( + child.type === 'link' + || child.type === 'linkReference' + || child.type === 'inlineCode' + || child.type === 'code' + ) { + newChildren.push(child) + continue + } + + if (child.type === 'text' && typeof child.value === 'string') { + const segments = linkifyText(child.value) + newChildren.push(...segments) + continue + } + + // Recurse into other container nodes (e.g. paragraph, blockquote, list items). + visitAndLinkify(child) + newChildren.push(child) + } + + node.children = newChildren +} + +/** + * Split a raw text string around any `scheme://...` matches and return a + * mixed array of text nodes and link nodes. + */ +function linkifyText(text: string): MdastNode[] { + const result: MdastNode[] = [] + let lastIndex = 0 + + // Reset the regex state (global flag carries state across calls). + NON_HTTPS_URI_RE.lastIndex = 0 + + let match: RegExpExecArray | null + while ((match = NON_HTTPS_URI_RE.exec(text)) !== null) { + const rawUri = match[0] + const matchStart = match.index + + // Strip trailing punctuation characters from the URI, preserving + // balanced ()/[]. + const { stripped, trailing } = stripTrailingPunct(rawUri) + + // Text before this match. + if (matchStart > lastIndex) { + result.push({ type: 'text', value: text.slice(lastIndex, matchStart) }) + } + + // The link node. + result.push({ + type: 'link', + url: stripped, + children: [{ type: 'text', value: stripped }], + }) + + // Any stripped trailing punctuation becomes a plain text node. + if (trailing) { + result.push({ type: 'text', value: trailing }) + } + + lastIndex = matchStart + rawUri.length + } + + // Remaining text after the last match. + if (lastIndex < text.length) { + result.push({ type: 'text', value: text.slice(lastIndex) }) + } + + // If no matches were found, return the original text node unchanged. + if (result.length === 0) { + result.push({ type: 'text', value: text }) + } + + return result +} + +export default function remarkNonHttpsAutolink() { + return (tree: MdastNode) => { + visitAndLinkify(tree) + } +}