diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts index eb8b0e54..8d48fd8a 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { parseFileReference, type ParsedFileReference } from './fileReferenceParser'; +import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser'; const parse = (value: string): ParsedFileReference | null => parseFileReference(value); @@ -96,3 +96,17 @@ describe('parseFileReference', () => { expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 }); }); }); + +describe('localPathFromFileUrl', () => { + test('converts local file URLs to absolute paths', () => { + expect(localPathFromFileUrl('file:///private/tmp/report%20viewer.html')).toBe('/private/tmp/report viewer.html'); + expect(localPathFromFileUrl('file://localhost/private/tmp/REPORT.md')).toBe('/private/tmp/REPORT.md'); + expect(localPathFromFileUrl('file:///C:/Users/test/report.html')).toBe('C:/Users/test/report.html'); + }); + + test('rejects non-file URLs and remote file hosts', () => { + expect(localPathFromFileUrl('https://example.com/report.html')).toBeNull(); + expect(localPathFromFileUrl('file://remote-host/share/report.html')).toBeNull(); + expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index a56f165b..d38ebf57 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -38,6 +38,7 @@ import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMerma import { BLOCK_PATH_TOKEN_RE, isAbsoluteReferencePath, + localPathFromFileUrl, normalizeReferencePath, parseFileReference, type ParsedFileReference, @@ -300,6 +301,10 @@ const unwrapBlockCodePathTokens = (container: HTMLElement): void => { const extractPathCandidateFromElement = (element: HTMLElement): string => { if (element.tagName.toLowerCase() === 'a') { const href = element.getAttribute('href')?.trim(); + const fileUrlPath = href ? localPathFromFileUrl(href) : null; + if (fileUrlPath) { + return fileUrlPath; + } if (href && isLikelyFilePath(href)) { return href; } diff --git a/packages/ui/src/components/chat/fileReferenceParser.ts b/packages/ui/src/components/chat/fileReferenceParser.ts index b2c6b91e..11e230c3 100644 --- a/packages/ui/src/components/chat/fileReferenceParser.ts +++ b/packages/ui/src/components/chat/fileReferenceParser.ts @@ -24,6 +24,29 @@ export const normalizeReferencePath = (value: string): string => normalizeFilePa export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value); +export const localPathFromFileUrl = (value: string): string | null => { + let parsed: URL; + try { + parsed = new URL(value.trim()); + } catch { + return null; + } + + if (parsed.protocol !== 'file:' || (parsed.hostname && parsed.hostname !== 'localhost')) { + return null; + } + + try { + const decodedPath = decodeURIComponent(parsed.pathname); + if (/^\/[A-Za-z]:\//.test(decodedPath)) { + return decodedPath.slice(1); + } + return decodedPath.startsWith('/') ? decodedPath : null; + } catch { + return null; + } +}; + const trimPathCandidate = (value: string): string => { let next = (value || '').trim(); if (!next) { diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts index 84bd6eba..2b8b31b1 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -11,7 +11,7 @@ mock.module('./markdown-worker', () => ({ highlightCodeInWorker: async () => null, })); -import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity'; +import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity'; const { extractMarkdownImageCandidates, renderMarkdownSync } = await import('./markdownCore'); const { resolveMarkdownImageSource } = await import('./markdownImageAssets'); @@ -29,6 +29,13 @@ describe('markdown sanitization', () => { expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script'); expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style'); }); + + test('allows only local file URLs through the sanitizer policy', () => { + expect(isLocalFileUrl('file:///private/tmp/report%20viewer.html')).toBe(true); + expect(isLocalFileUrl('file://localhost/private/tmp/REPORT.md')).toBe(true); + expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false); + expect(isLocalFileUrl('javascript:alert(1)')).toBe(false); + }); }); describe('Markdown images', () => { diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index e5271820..95b18b6d 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -5,7 +5,7 @@ import DOMPurify from 'dompurify'; import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks'; import { isVSCodeRuntime } from '@/lib/desktop'; import { highlightCodeInWorker } from './markdown-worker'; -import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity'; +import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity'; const escapeAttr = (value: string): string => value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); @@ -400,6 +400,10 @@ const ensureSanitizeHook = (): void => { if (sanitizeHookInstalled) return; if (typeof window === 'undefined' || !DOMPurify.isSupported) return; sanitizeHookInstalled = true; + DOMPurify.addHook('uponSanitizeAttribute', (node, data) => { + if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return; + if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true; + }); DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (!(node instanceof HTMLAnchorElement)) return; if (node.target !== '_blank') return; diff --git a/packages/ui/src/components/chat/markdown/markdownSecurity.ts b/packages/ui/src/components/chat/markdown/markdownSecurity.ts index 158ffa0b..20ec5f13 100644 --- a/packages/ui/src/components/chat/markdown/markdownSecurity.ts +++ b/packages/ui/src/components/chat/markdown/markdownSecurity.ts @@ -4,3 +4,12 @@ export const escapeRawMarkdownHtml = (value: string): string => /** Active elements forbidden again at the final DOMPurify boundary. */ export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const; + +export const isLocalFileUrl = (value: string): boolean => { + try { + const parsed = new URL(value); + return parsed.protocol === 'file:' && (!parsed.hostname || parsed.hostname === 'localhost'); + } catch { + return false; + } +};