fix(workspace): improve cross-platform path handling

Normalize path separators for consistent comparison across Windows and Unix systems. Fixes path traversal validation to handle backslash separators and corrects extension detection for dotfiles like .gitignore.

- Add normalizeForComparison helper to convert backslashes to forward slashes
- Fix isTextFile to properly handle files starting with dots
- Add getParentDirPath utility for cross-platform directory navigation
- Prevent false positives in path traversal detection on Windows
This commit is contained in:
Jamie Taylor
2026-01-31 17:35:12 +00:00
parent 7e75a12614
commit 3ba5b7ee6c
2 changed files with 27 additions and 5 deletions
+13 -4
View File
@@ -30,10 +30,15 @@ export function validatePath(workspaceRoot: string, targetPath: string): string
const resolvedRoot = path.resolve(workspaceRoot)
const resolvedTarget = path.resolve(targetPath)
// Normalize paths for cross-platform comparison
// Convert backslashes to forward slashes and ensure consistent formatting
const normalizeForComparison = (p: string) => p.replace(/\\/g, '/').replace(/\/$/, '')
const normalizedRoot = normalizeForComparison(resolvedRoot) + '/'
const normalizedTarget = normalizeForComparison(resolvedTarget)
// Ensure target path is within root path by checking with trailing separator
// This prevents bypasses like /home/user/workspace-evil matching /home/user/workspace
const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep
if (!resolvedTarget.startsWith(normalizedRoot) && resolvedTarget !== resolvedRoot) {
if (!normalizedTarget.startsWith(normalizedRoot) && normalizedTarget !== normalizeForComparison(resolvedRoot)) {
throw new Error('Path traversal detected: target path is outside workspace root')
}
@@ -212,6 +217,10 @@ export function isTextFile(filename: string): boolean {
'.gitignore',
'.dockerignore',
]
const ext = path.extname(filename).toLowerCase()
return textExtensions.includes(ext) || !ext.includes('.')
// Get extension - handle files starting with dot (like .gitignore)
// path.extname returns '' for files like 'Makefile' and '.gitignore'
// We need to distinguish between extensionless files and dotfiles
const lastDotIndex = filename.lastIndexOf('.')
const ext = lastDotIndex > 0 ? path.extname(filename).toLowerCase() : ''
return textExtensions.includes(ext) || ext === ''
}
+14 -1
View File
@@ -14,6 +14,19 @@ import { FileTree, MarkdownViewer } from '~/components/workspace'
import { CrabIdleAnimation } from '~/components/ani'
import type { DirectoryEntry } from '~/lib/workspace-fs'
// Get parent directory path using path separator logic
// Works cross-platform for both / and \ separators
function getParentDirPath(filePath: string): string {
// Normalize to forward slashes for consistent processing
const normalized = filePath.replace(/\\/g, '/')
const lastSlashIndex = normalized.lastIndexOf('/')
if (lastSlashIndex <= 0) {
return filePath
}
// Return the original path up to the last separator
return filePath.substring(0, lastSlashIndex)
}
export const Route = createFileRoute('/workspace/')({
component: WorkspacePageWrapper,
})
@@ -184,7 +197,7 @@ function WorkspacePage() {
setSelectedFileContent(result.content)
setSelectedFileName(result.name)
// Get file metadata from the parent directory entry if available
const parentDir = pathCache.get(selectedPath?.substring(0, selectedPath.lastIndexOf('/')) || workspacePath)
const parentDir = pathCache.get(getParentDirPath(filePath) || workspacePath)
const fileEntry = parentDir?.find(e => e.path === filePath)
setSelectedFileSize(fileEntry?.size)
setSelectedFileModified(fileEntry?.modifiedAt)