fix(markdown): open local file links
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, '<').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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user