feat(web): linkify custom URI schemes with a confirm prompt (#633)

* 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 <a> 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 <A> 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 <a> 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, <A> 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 <A> 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 <A>'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 <I18nProvider> 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 `<A>` 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.
This commit is contained in:
Junmo Kim
2026-05-18 10:40:32 +08:00
committed by GitHub
parent 5512890a4c
commit 6e32b20524
11 changed files with 1308 additions and 25 deletions
+5
View File
@@ -7,6 +7,8 @@ import {
MARKDOWN_COMPONENTS_BY_LANGUAGE, MARKDOWN_COMPONENTS_BY_LANGUAGE,
MARKDOWN_CLASSNAME, MARKDOWN_CLASSNAME,
defaultComponents, defaultComponents,
denyOnlyTransform,
UriConfirmProvider,
} from '@/components/assistant-ui/markdown-text' } from '@/components/assistant-ui/markdown-text'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -22,15 +24,18 @@ function MarkdownContent(props: MarkdownRendererProps) {
: defaultComponents : defaultComponents
return ( return (
<UriConfirmProvider>
<TextMessagePartProvider text={props.content}> <TextMessagePartProvider text={props.content}>
<MarkdownTextPrimitive <MarkdownTextPrimitive
remarkPlugins={MARKDOWN_PLUGINS} remarkPlugins={MARKDOWN_PLUGINS}
rehypePlugins={MARKDOWN_REHYPE_PLUGINS} rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
components={mergedComponents} components={mergedComponents}
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE} componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
urlTransform={denyOnlyTransform}
className={cn(MARKDOWN_CLASSNAME, props.className)} className={cn(MARKDOWN_CLASSNAME, props.className)}
/> />
</TextMessagePartProvider> </TextMessagePartProvider>
</UriConfirmProvider>
) )
} }
@@ -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<React.ComponentProps<typeof UriConfirmDialog>> = {}) {
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(
<I18nProvider>
<UriConfirmDialog {...merged} />
</I18nProvider>
)
}
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(
<I18nProvider>
<UriConfirmDialog
open={false}
url="obsidian://open"
scheme="obsidian"
onCancel={onCancel}
onOpen={vi.fn()}
onAlwaysAllow={vi.fn()}
/>
</I18nProvider>
)
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()
})
})
+79
View File
@@ -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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('dialog.uri.title')}</DialogTitle>
</DialogHeader>
<DialogDescription className="mt-2">
{t('dialog.uri.description')}
</DialogDescription>
{/* URI display with scheme emphasis */}
<div className="mt-3 rounded-lg border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-3 py-2 font-mono text-sm break-all">
<span className="font-semibold text-[var(--app-link)]">{schemePrefix}</span>
<span className="text-[var(--app-fg)]">{urlRemainder}</span>
</div>
<div className="mt-4 flex gap-2 justify-end flex-wrap">
<Button
type="button"
variant="secondary"
onClick={onCancel}
>
{t('button.cancel')}
</Button>
<Button
type="button"
variant="outline"
onClick={onOpen}
>
{t('dialog.uri.open')}
</Button>
<Button
type="button"
onClick={() => onAlwaysAllow(scheme)}
>
{t('dialog.uri.alwaysAllow', { scheme })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,335 @@
/**
* Tests for the custom <A> 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
* - <A> 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<string, unknown>).a as React.ComponentType<
React.ComponentPropsWithoutRef<'a'>
>
// Helper: wrap A in UriConfirmProvider so useContext(UriConfirmContext) is non-null.
// Previously <A> 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(
<I18nProvider>
<UriConfirmProvider>
<AnchorComponent {...props} />
</UriConfirmProvider>
</I18nProvider>
)
}
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,<h1>xss</h1>', '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')
})
})
// ── <A> component — click handler ────────────────────────────────────────────
describe('markdown <A> 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 <A> onClick handler called classifyScheme(href)
// which returned 'deny' for inputs with no valid scheme → preventDefault was
// called → relative/internal links were silently blocked.
//
// Fix: <A> must detect hrefs that have no scheme and treat them as 'iana' so the
// browser/router can navigate normally.
describe('markdown <A> 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<SchemeListener>` so that
// when two sibling <UriConfirmProvider>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(
<I18nProvider>
<UriConfirmProvider>
<AnchorComponent href="obsidian://open?a=1">link1</AnchorComponent>
</UriConfirmProvider>
<UriConfirmProvider>
<AnchorComponent href="obsidian://open?a=2">link2</AnchorComponent>
</UriConfirmProvider>
</I18nProvider>
)
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()
})
})
@@ -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
})
})
@@ -1,6 +1,7 @@
import '@assistant-ui/react-markdown/styles/dot.css' import '@assistant-ui/react-markdown/styles/dot.css'
import type { ComponentPropsWithoutRef, MouseEvent } from 'react' import type { ComponentPropsWithoutRef, MouseEvent } from 'react'
import { useState, useCallback, useEffect, useMemo, createContext, useContext, type ReactNode } from 'react'
import { import {
MarkdownTextPrimitive, MarkdownTextPrimitive,
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
@@ -13,6 +14,7 @@ import rehypeKatex from 'rehype-katex'
import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code' import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink' import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink'
import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink'
import { cn, encodeBase64 } from '@/lib/utils' import { cn, encodeBase64 } from '@/lib/utils'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
@@ -20,10 +22,26 @@ import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { CopyIcon, CheckIcon } from '@/components/icons' import { CopyIcon, CheckIcon } from '@/components/icons'
import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' import { useOptionalHappyChatContext } from '@/components/AssistantChat/context'
import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links' import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links'
import { UriConfirmDialog } from '@/components/UriConfirmDialog'
import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
export const MARKDOWN_PLUGINS = [remarkGfm, remarkStripCjkAutolink, remarkMath, remarkDisableIndentedCode, remarkFilePathLinks] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']> // ── 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<MarkdownTextPrimitiveProps['remarkPlugins']>
export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable<MarkdownTextPrimitiveProps['rehypePlugins']> export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable<MarkdownTextPrimitiveProps['rehypePlugins']>
export const MARKDOWN_CLASSNAME = 'aui-md happy-chat-text min-w-0 max-w-full break-words text-[var(--app-fg)]' 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 = { export const MARKDOWN_COMPONENTS_BY_LANGUAGE = {
@@ -32,6 +50,292 @@ export const MARKDOWN_COMPONENTS_BY_LANGUAGE = {
}, },
} satisfies NonNullable<MarkdownTextPrimitiveProps['componentsByLanguage']> } satisfies NonNullable<MarkdownTextPrimitiveProps['componentsByLanguage']>
// ── 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<string> = 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<string> = 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+0000U+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+0000U+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 <A> 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
// <a href> 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
// <img src> 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 <UriConfirmProvider>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<string>) => void
const schemeListeners = new Set<SchemeListener>()
function readAllowedFromStorage(): Set<string> {
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<string>): 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<ReadonlySet<string>>(() => 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<string>) {
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 <a> tags in this tree re-render on the same state update. */
isAllowed: (scheme: string) => boolean
}
const UriConfirmContext = createContext<UriConfirmContextValue | null>(null)
/**
* Provider that mounts a single <UriConfirmDialog> for all <a> 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 <a> tag.
*
* The allowed-schemes state lives here so all child <A> 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<DialogState>(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 (
<UriConfirmContext.Provider value={contextValue}>
{children}
{dialog !== null && (
<UriConfirmDialog
open={true}
url={dialog.url}
scheme={dialog.scheme}
onCancel={closeDialog}
onOpen={handleOpen}
onAlwaysAllow={handleAlwaysAllow}
/>
)}
</UriConfirmContext.Provider>
)
}
// ── Components ───────────────────────────────────────────────────────────────
function CodeHeader(props: CodeHeaderProps) { function CodeHeader(props: CodeHeaderProps) {
const { copied, copy } = useCopyToClipboard() const { copied, copy } = useCopyToClipboard()
const language = props.language && props.language !== 'unknown' ? props.language : 'text' 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'>) { function A(props: ComponentPropsWithoutRef<'a'>) {
const chat = useOptionalHappyChatContext() 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
// <UriConfirmProvider>, 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 <A> directly to wrap with
// <UriConfirmProvider> (or supply a mock UriConfirmContext.Provider).
const ctx = useContext(UriConfirmContext)
const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null
const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
@@ -134,10 +465,61 @@ function A(props: ComponentPropsWithoutRef<'a'>) {
return <FilePathAnchor {...props} filePath={filePath} sessionId={chat.sessionId} /> return <FilePathAnchor {...props} filePath={filePath} sessionId={chat.sessionId} />
} }
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<HTMLAnchorElement>) => {
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 ( return (
<a <a
{...props} {...rest}
href={domHref}
rel={rel} rel={rel}
onClick={handleClick}
className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)} className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)}
/> />
) )
@@ -280,12 +662,15 @@ export const defaultComponents = memoizeMarkdownComponents({
export function MarkdownText() { export function MarkdownText() {
return ( return (
<UriConfirmProvider>
<MarkdownTextPrimitive <MarkdownTextPrimitive
remarkPlugins={MARKDOWN_PLUGINS} remarkPlugins={MARKDOWN_PLUGINS}
rehypePlugins={MARKDOWN_REHYPE_PLUGINS} rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
components={defaultComponents} components={defaultComponents}
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE} componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
urlTransform={denyOnlyTransform}
className={cn(MARKDOWN_CLASSNAME)} className={cn(MARKDOWN_CLASSNAME)}
/> />
</UriConfirmProvider>
) )
} }
@@ -8,6 +8,8 @@ import {
MARKDOWN_PLUGINS, MARKDOWN_PLUGINS,
MARKDOWN_REHYPE_PLUGINS, MARKDOWN_REHYPE_PLUGINS,
defaultComponents, defaultComponents,
denyOnlyTransform,
UriConfirmProvider,
} from '@/components/assistant-ui/markdown-text' } from '@/components/assistant-ui/markdown-text'
function ChevronIcon(props: { className?: string; open?: boolean }) { function ChevronIcon(props: { className?: string; open?: boolean }) {
@@ -41,13 +43,16 @@ function ShimmerDot() {
export const Reasoning: FC = () => { export const Reasoning: FC = () => {
return ( return (
<UriConfirmProvider>
<MarkdownTextPrimitive <MarkdownTextPrimitive
remarkPlugins={MARKDOWN_PLUGINS} remarkPlugins={MARKDOWN_PLUGINS}
rehypePlugins={MARKDOWN_REHYPE_PLUGINS} rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
components={defaultComponents} components={defaultComponents}
componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE} componentsByLanguage={MARKDOWN_COMPONENTS_BY_LANGUAGE}
urlTransform={denyOnlyTransform}
className={cn(MARKDOWN_CLASSNAME, 'aui-reasoning-content text-[13.5px] text-[var(--app-hint)]')} className={cn(MARKDOWN_CLASSNAME, 'aui-reasoning-content text-[13.5px] text-[var(--app-hint)]')}
/> />
</UriConfirmProvider>
) )
} }
+4
View File
@@ -83,6 +83,10 @@ export default {
'session.action.copy': 'Copy', 'session.action.copy': 'Copy',
// Dialogs // 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.title': 'Rename Session',
'dialog.rename.placeholder': 'Session name', 'dialog.rename.placeholder': 'Session name',
'dialog.rename.save': 'Save', 'dialog.rename.save': 'Save',
+4
View File
@@ -83,6 +83,10 @@ export default {
'session.action.copy': '复制', 'session.action.copy': '复制',
// Dialogs // Dialogs
'dialog.uri.title': '打开此链接?',
'dialog.uri.description': '此链接使用自定义 URI 协议。请确认您信任此链接后再打开。',
'dialog.uri.open': '打开',
'dialog.uri.alwaysAllow': '始终允许 {scheme}:',
'dialog.rename.title': '重命名会话', 'dialog.rename.title': '重命名会话',
'dialog.rename.placeholder': '会话名称', 'dialog.rename.placeholder': '会话名称',
'dialog.rename.save': '保存', 'dialog.rename.save': '保存',
@@ -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)
})
})
+173
View File
@@ -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)
}
}