From cd7d139a31e0a1191360ef73c28d043624743d7e Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:15:43 -0400 Subject: [PATCH 1/2] fix(files): resolve workspace paths and add sandboxed HTML preview Accept workspace-absolute paths in the file service and return repo-relative paths for file reads and directory listings. Serve raw content under a CSP sandbox and preview HTML files in a sandboxed iframe with a raw toggle. Resolve file-click paths from the session directory and repo local path, detect absolute and html/htm file references while ignoring URLs, accept the legacy filePath input key and filediff metadata, fix the file browser navigating into an initially selected directory, and use focus-visible rings across shared UI primitives. --- backend/src/routes/files.ts | 1 + backend/src/services/files.ts | 22 ++++--- backend/test/routes/files.test.ts | 11 ++++ backend/test/services/files.test.ts | 24 ++++++++ .../components/file-browser/FileBrowser.tsx | 31 ++++++---- .../file-browser/FileBrowserSheet.test.tsx | 37 ++++++++++++ .../file-browser/FilePreview.test.tsx | 15 +++++ .../components/file-browser/FilePreview.tsx | 60 +++++++++++++------ .../src/components/message/FileToolRender.tsx | 12 +++- .../components/message/MessagePart.test.tsx | 29 +++++++++ .../src/components/message/ToolCallPart.tsx | 4 +- .../src/components/repo/AddRepoDialog.tsx | 2 +- frontend/src/components/ui/badge.tsx | 2 +- frontend/src/components/ui/dialog.tsx | 2 +- frontend/src/components/ui/select.tsx | 2 +- frontend/src/components/ui/sidebar.tsx | 2 +- frontend/src/lib/fileReferences.test.ts | 20 +++++++ frontend/src/lib/fileReferences.ts | 2 +- frontend/src/pages/SessionDetail.tsx | 19 +++--- 19 files changed, 239 insertions(+), 58 deletions(-) create mode 100644 frontend/src/lib/fileReferences.test.ts diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 4b43f6e46..c8be10565 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -120,6 +120,7 @@ export function createFileRoutes() { headers: { 'Content-Type': result.mimeType || 'application/octet-stream', 'Content-Length': result.size.toString(), + 'Content-Security-Policy': 'sandbox allow-scripts', } }) } diff --git a/backend/src/services/files.ts b/backend/src/services/files.ts index 8979ed73a..bfa13007d 100644 --- a/backend/src/services/files.ts +++ b/backend/src/services/files.ts @@ -64,6 +64,7 @@ export async function getRawFileContent(userPath: string): Promise { export async function getFile(userPath: string): Promise { const validatedPath = validatePath(userPath) + const responsePath = path.isAbsolute(userPath.trim()) ? path.relative(SHARED_WORKSPACE_BASE, validatedPath) : userPath logger.info(`Getting file for path: ${userPath} -> ${validatedPath}`) try { @@ -84,7 +85,7 @@ export async function getFile(userPath: string): Promise { for (const entry of entries) { children.push({ name: entry.name, - path: path.join(userPath, entry.name), + path: path.join(responsePath, entry.name), isDirectory: entry.isDirectory, size: entry.size, lastModified: entry.lastModified, @@ -93,7 +94,7 @@ export async function getFile(userPath: string): Promise { return { name: path.basename(validatedPath), - path: userPath, + path: responsePath, isDirectory: true, size: 0, children: children.sort((a, b) => { @@ -127,7 +128,7 @@ export async function getFile(userPath: string): Promise { return { name: path.basename(validatedPath), - path: userPath, + path: responsePath, isDirectory: false, size: stats.size, mimeType, @@ -234,14 +235,21 @@ export async function renameOrMoveFile(userPath: string, body: { newPath: string } } +function isWithinBase(resolved: string, basePath: string): boolean { + return resolved === basePath || resolved.startsWith(`${basePath}${path.sep}`) +} + function validatePath(userPath: string): string { const trimmed = userPath.trim() const normalized = path.normalize(trimmed || '.') - const fullPath = path.join(SHARED_WORKSPACE_BASE, normalized) - const resolved = path.resolve(fullPath) - const basePath = path.resolve(WORKSPACE_BASE) - if (resolved !== basePath && !resolved.startsWith(`${basePath}${path.sep}`)) { + + if (path.isAbsolute(normalized) && isWithinBase(path.resolve(normalized), basePath)) { + return path.resolve(normalized) + } + + const resolved = path.resolve(path.join(SHARED_WORKSPACE_BASE, normalized)) + if (!isWithinBase(resolved, basePath)) { throw { message: 'Path traversal detected', statusCode: 403 } } diff --git a/backend/test/routes/files.test.ts b/backend/test/routes/files.test.ts index 23aaa5adb..fedfcfa46 100644 --- a/backend/test/routes/files.test.ts +++ b/backend/test/routes/files.test.ts @@ -166,6 +166,17 @@ describe('File Routes', () => { expect(getFile).toHaveBeenCalledWith('test-repo/test.txt') }) + it('should serve raw content inside a CSP sandbox', async () => { + getFile.mockResolvedValue({ ...mockFileInfo, name: 'report.html', mimeType: 'text/html', size: 13 }) + vi.mocked(fileService.getRawFileContent).mockResolvedValue(Buffer.from('

hi

\n\n')) + + const response = await app.request('/api/files?path=test-repo/report.html&raw=true') + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/html') + expect(response.headers.get('Content-Security-Policy')).toBe('sandbox allow-scripts') + }) + it('should return 404 when path does not exist', async () => { getFile.mockRejectedValue({ message: 'File or directory not found', statusCode: 404 }) diff --git a/backend/test/services/files.test.ts b/backend/test/services/files.test.ts index 1df66f6ad..3f118e1e2 100644 --- a/backend/test/services/files.test.ts +++ b/backend/test/services/files.test.ts @@ -386,6 +386,30 @@ describe('files service', () => { }) describe('path traversal protection', () => { + it('reads an absolute path that lies inside the workspace', async () => { + const absolutePath = await writeLines(`${relativeRoot}/report.html`, '

hi

') + + const buffer = await getRawFileContent(absolutePath) + + expect(buffer.toString('utf8')).toBe('

hi

') + }) + + it('returns repos-relative paths for directories requested by absolute path', async () => { + await writeLines(`${relativeRoot}/sub/a.txt`, 'a') + + const result = await getFile(path.join(reposPath, relativeRoot, 'sub')) + + expect(result.path).toBe(`${relativeRoot}/sub`) + expect(result.children?.map((child) => child.path)).toEqual([`${relativeRoot}/sub/a.txt`]) + }) + + it('treats an absolute path outside the workspace as repos-relative', async () => { + await expect(getRawFileContent('/etc/passwd')).rejects.toEqual({ + message: 'File not found or cannot be read', + statusCode: 404, + }) + }) + it('rejects traversal in getFile', async () => { await expect(getFile('../../etc/passwd')).rejects.toEqual({ message: 'Path traversal detected', diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index c6df1a35d..61eecefac 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -144,20 +144,11 @@ export const FileBrowser = forwardRef(funct const dropZoneRef = useRef(null) const uploadCancelledRef = useRef(false) + const loadRequestRef = useRef(0) const isMobile = useMobile() const { data: initialFileData, error: initialFileError } = useFile(initialSelectedFile) -useEffect(() => { - if (initialFileData) { - setSelectedFile(initialFileData) - if (isMobile) { - setIsPreviewModalOpen(true) - onPreviewStateChange?.(true) - } - } -}, [initialFileData, isMobile, onPreviewStateChange]) - useEffect(() => { if (initialFileError) { setError(initialFileError.message) @@ -165,6 +156,7 @@ useEffect(() => { }, [initialFileError]) const loadFiles = useCallback(async (path: string) => { + const request = ++loadRequestRef.current setLoading(true) setError(null) @@ -175,13 +167,15 @@ useEffect(() => { } const data = await response.json() + if (request !== loadRequestRef.current) return setFiles(data) setCurrentPath(path) onDirectoryLoad?.({ workspaceRoot: data.workspaceRoot, currentPath: path }) } catch (err) { + if (request !== loadRequestRef.current) return setError(err instanceof Error ? err.message : 'Failed to load files') } finally { - setLoading(false) + if (request === loadRequestRef.current) setLoading(false) } }, [onDirectoryLoad]) @@ -448,6 +442,21 @@ useEffect(() => { loadFiles(basePath) }, [basePath, loadFiles]) + useEffect(() => { + if (!initialFileData) return + setError(null) + if (initialFileData.isDirectory) { + setSelectedFile(null) + void loadFiles(initialFileData.path) + return + } + setSelectedFile(initialFileData) + if (isMobile) { + setIsPreviewModalOpen(true) + onPreviewStateChange?.(true) + } + }, [initialFileData, isMobile, onPreviewStateChange, loadFiles]) + useEffect(() => { const handleFileSaved = (event: CustomEvent<{ path: string; content?: string }>) => { if (selectedFile && selectedFile.path === event.detail.path) { diff --git a/frontend/src/components/file-browser/FileBrowserSheet.test.tsx b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx index af322b0a8..5de2d78d0 100644 --- a/frontend/src/components/file-browser/FileBrowserSheet.test.tsx +++ b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx @@ -86,6 +86,43 @@ describe('FileBrowserSheet', () => { }) }) +describe('FileBrowser initial selection', () => { + it('navigates into an initially selected directory even when the base listing resolves later', async () => { + const directory = (path: string, children: string[]) => ({ + name: path.split('/').pop(), + path, + isDirectory: true, + size: 0, + lastModified: new Date().toISOString(), + children: children.map((name) => ({ name, path: `${path}/${name}`, isDirectory: true, size: 0, lastModified: new Date().toISOString() })), + }) + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const path = new URL(String(input), 'http://localhost').searchParams.get('path') + if (path === 'repo') { + await new Promise((resolve) => setTimeout(resolve, 50)) + return new Response(JSON.stringify(directory('repo', ['frontend'])), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify(directory('repo/frontend', ['src'])), { status: 200, headers: { 'Content-Type': 'application/json' } }) + }) + vi.stubGlobal('fetch', fetchMock) + const ref = { current: null as unknown as FileBrowserHandle } + + try { + render( + , + { wrapper: createWrapper() } + ) + + expect(await screen.findByText('src')).toBeInTheDocument() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(ref.current.getCurrentPath()).toBe('repo/frontend') + expect(screen.queryByText('frontend')).not.toBeInTheDocument() + } finally { + vi.unstubAllGlobals() + } + }) +}) + describe('FileBrowser navigation', () => { it('exposes imperative handle with goBack, canGoBack, and getCurrentPath', () => { const ref = { current: null as unknown as FileBrowserHandle } diff --git a/frontend/src/components/file-browser/FilePreview.test.tsx b/frontend/src/components/file-browser/FilePreview.test.tsx index 78764b6d8..bf3ae3dd6 100644 --- a/frontend/src/components/file-browser/FilePreview.test.tsx +++ b/frontend/src/components/file-browser/FilePreview.test.tsx @@ -40,6 +40,21 @@ describe('FilePreview header buttons', () => { expect(active.className).not.toMatch(OUTLINE_DARK_OVERRIDE) }) + it('renders HTML files in a sandboxed iframe with a raw toggle', () => { + render() + + const frame = screen.getByTitle('report.html') + expect(frame.tagName).toBe('IFRAME') + expect(frame).toHaveAttribute('sandbox', 'allow-scripts') + expect(frame.getAttribute('src')).toContain('raw=true') + expect(screen.getByTitle('Open HTML in new tab')).toHaveAttribute('target', '_blank') + + fireEvent.click(screen.getByTitle('Show raw HTML')) + + expect(screen.queryByTitle('report.html')).not.toBeInTheDocument() + expect(screen.getByText('# heading')).toBeInTheDocument() + }) + it('keeps the success and destructive tints on the edit actions in dark mode', () => { render() diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index e16841221..fb91e1a50 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect, memo } from 'react' import { Button } from '@/components/ui/button' -import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code } from 'lucide-react' +import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code, ExternalLink } from 'lucide-react' import type { FileInfo } from '@/types/files' import { getFileApiUrl } from '@/api/files' import { saveFileFromUrl } from '@/lib/download' @@ -24,6 +24,9 @@ interface FilePreviewProps { export const FilePreview = memo(function FilePreview({ file, hideHeader = false, isMobileModal = false, onCloseModal, onFileSaved, initialLineNumber }: FilePreviewProps) { const isMarkdownFile = file.name.toLowerCase().endsWith('.md') || file.name.toLowerCase().endsWith('.mdx') || file.mimeType === 'text/markdown' + const isHtmlFile = /\.html?$/i.test(file.name) || file.mimeType === 'text/html' + const hasRenderedPreview = isMarkdownFile || isHtmlFile + const previewLabel = isHtmlFile ? 'HTML' : 'markdown' const [viewMode, setViewMode] = useState<'preview' | 'edit'>('preview') const [editContent, setEditContent] = useState('') @@ -31,7 +34,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, const [hasVirtualizedChanges, setHasVirtualizedChanges] = useState(false) const [highlightedLine, setHighlightedLine] = useState(initialLineNumber) const [lineWrap, setLineWrap] = useState(true) - const [markdownPreview, setMarkdownPreview] = useState(isMarkdownFile) + const [renderedPreview, setRenderedPreview] = useState(hasRenderedPreview) const [isLoadingAllContent, setIsLoadingAllContent] = useState(false) const [fullContentLoaded, setFullContentLoaded] = useState(false) const [fullContent, setFullContent] = useState(null) @@ -41,16 +44,18 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, const shouldVirtualize = file.size > VIRTUALIZATION_THRESHOLD_BYTES && !file.mimeType?.startsWith('image/') const isMarkdownTooLarge = file.size > MARKDOWN_PREVIEW_SIZE_LIMIT + const showHtmlPreview = isHtmlFile && renderedPreview && viewMode !== 'edit' + const rawFileUrl = getFileApiUrl(file.path, { params: { raw: true } }) useEffect(() => { setFullContentLoaded(false) - setMarkdownPreview(isMarkdownFile) + setRenderedPreview(hasRenderedPreview) setFullContent(null) setLocalMdContent(null) - }, [file.path, isMarkdownFile]) + }, [file.path, hasRenderedPreview]) useEffect(() => { - if (shouldVirtualize && isMarkdownFile && markdownPreview && !isMarkdownTooLarge && !fullContentLoaded) { + if (shouldVirtualize && isMarkdownFile && renderedPreview && !isMarkdownTooLarge && !fullContentLoaded) { const loadContent = async () => { if (!virtualizedRef.current) return setIsLoadingAllContent(true) @@ -66,7 +71,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, const timer = setTimeout(loadContent, 0) return () => clearTimeout(timer) } - }, [shouldVirtualize, isMarkdownFile, markdownPreview, isMarkdownTooLarge, fullContentLoaded]) + }, [shouldVirtualize, isMarkdownFile, renderedPreview, isMarkdownTooLarge, fullContentLoaded]) @@ -216,7 +221,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, return (
{file.name} @@ -224,8 +229,19 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, ) } + if (showHtmlPreview) { + return ( +