From 3ed2cbd933cc8db642c74cf8d08faecd62b03714 Mon Sep 17 00:00:00 2001 From: Shahbaz !! Date: Tue, 18 Aug 2026 15:01:28 +0000 Subject: [PATCH] fix: prevent profiler file symlink escapes --- src/api/profiler.file.ts | 74 +++++++++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/src/api/profiler.file.ts b/src/api/profiler.file.ts index e235951f..9c4175a9 100644 --- a/src/api/profiler.file.ts +++ b/src/api/profiler.file.ts @@ -193,23 +193,60 @@ function getSuppliedCode(req: ApiRequest, parsedUrl: URL): string | null { * @returns Normalized absolute path inside the project root, or `null` when * the path escapes the workspace. */ -function resolveWorkspacePath(rawPath: string): string | null { - const cwd = process.cwd() - const parsedPath = rawPath.startsWith('file://') - ? fileURLToPath(rawPath) - : rawPath +export async function resolveWorkspacePath( + rawPath: string, + cwd = process.cwd() +): Promise { + let parsedPath: string + try { + parsedPath = rawPath.startsWith('file://') + ? fileURLToPath(rawPath) + : rawPath + } catch { + return null + } + const absolutePath = path.resolve(cwd, parsedPath) - const normalizedCwd = `${cwd}${path.sep}` + const lexicalRelativePath = path.relative(cwd, absolutePath) + if ( + lexicalRelativePath === '..' || + lexicalRelativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(lexicalRelativePath) + ) { + return null + } + let realCwd: string + try { + realCwd = await fsPromises.realpath(cwd) + } catch { + return null + } + + let realPath: string + try { + realPath = await fsPromises.realpath(absolutePath) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT' || code === 'ENOTDIR') { + // Keep the lexical path so missing files continue to return the route's + // existing 404 response instead of being treated as path escapes. + return absolutePath + } + + return null + } + + const realRelativePath = path.relative(realCwd, realPath) if ( - absolutePath !== cwd && - !absolutePath.startsWith(normalizedCwd) && - !parsedPath.startsWith(normalizedCwd) + realRelativePath === '..' || + realRelativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(realRelativePath) ) { return null } - return absolutePath + return realPath } /** @@ -366,7 +403,7 @@ async function handler( return } - const absolutePath = resolveWorkspacePath(rawPath) + const absolutePath = await resolveWorkspacePath(rawPath) if (absolutePath === null) { sendErrorResponse( req, @@ -389,10 +426,23 @@ async function handler( const resolvedPath = await resolveReadablePath( getPathCandidates(absolutePath) ) + const safePath = await resolveWorkspacePath(resolvedPath) + if (safePath === null) { + sendErrorResponse( + req, + res, + 403, + 'Forbidden', + 'Path is outside the project root.', + parsedUrl.pathname + ) + return + } + sendResponse( req, res, - await buildSnippetResponse(resolvedPath, line, context), + await buildSnippetResponse(safePath, line, context), 200 ) } catch (error) {