fix(markdown): open local file links
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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);
|
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 });
|
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 {
|
import {
|
||||||
BLOCK_PATH_TOKEN_RE,
|
BLOCK_PATH_TOKEN_RE,
|
||||||
isAbsoluteReferencePath,
|
isAbsoluteReferencePath,
|
||||||
|
localPathFromFileUrl,
|
||||||
normalizeReferencePath,
|
normalizeReferencePath,
|
||||||
parseFileReference,
|
parseFileReference,
|
||||||
type ParsedFileReference,
|
type ParsedFileReference,
|
||||||
@@ -300,6 +301,10 @@ const unwrapBlockCodePathTokens = (container: HTMLElement): void => {
|
|||||||
const extractPathCandidateFromElement = (element: HTMLElement): string => {
|
const extractPathCandidateFromElement = (element: HTMLElement): string => {
|
||||||
if (element.tagName.toLowerCase() === 'a') {
|
if (element.tagName.toLowerCase() === 'a') {
|
||||||
const href = element.getAttribute('href')?.trim();
|
const href = element.getAttribute('href')?.trim();
|
||||||
|
const fileUrlPath = href ? localPathFromFileUrl(href) : null;
|
||||||
|
if (fileUrlPath) {
|
||||||
|
return fileUrlPath;
|
||||||
|
}
|
||||||
if (href && isLikelyFilePath(href)) {
|
if (href && isLikelyFilePath(href)) {
|
||||||
return href;
|
return href;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,29 @@ export const normalizeReferencePath = (value: string): string => normalizeFilePa
|
|||||||
|
|
||||||
export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value);
|
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 => {
|
const trimPathCandidate = (value: string): string => {
|
||||||
let next = (value || '').trim();
|
let next = (value || '').trim();
|
||||||
if (!next) {
|
if (!next) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ mock.module('./markdown-worker', () => ({
|
|||||||
highlightCodeInWorker: async () => null,
|
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 { extractMarkdownImageCandidates, renderMarkdownSync } = await import('./markdownCore');
|
||||||
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
||||||
@@ -29,6 +29,13 @@ describe('markdown sanitization', () => {
|
|||||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script');
|
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script');
|
||||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
|
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', () => {
|
describe('Markdown images', () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import DOMPurify from 'dompurify';
|
|||||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { highlightCodeInWorker } from './markdown-worker';
|
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 =>
|
const escapeAttr = (value: string): string =>
|
||||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
@@ -400,6 +400,10 @@ const ensureSanitizeHook = (): void => {
|
|||||||
if (sanitizeHookInstalled) return;
|
if (sanitizeHookInstalled) return;
|
||||||
if (typeof window === 'undefined' || !DOMPurify.isSupported) return;
|
if (typeof window === 'undefined' || !DOMPurify.isSupported) return;
|
||||||
sanitizeHookInstalled = true;
|
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) => {
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
if (!(node instanceof HTMLAnchorElement)) return;
|
if (!(node instanceof HTMLAnchorElement)) return;
|
||||||
if (node.target !== '_blank') 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. */
|
/** Active elements forbidden again at the final DOMPurify boundary. */
|
||||||
export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const;
|
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