Skip to content

Commit 70f6c8c

Browse files
committed
fix: address PR review findings
- Fix XSS vulnerability in formatLogContent by escaping HTML before injecting spans - Use async fs.readFile instead of sync fs.readFileSync to avoid blocking event loop - Add path sanitization to prevent path traversal attacks in log file APIs - Add defense-in-depth path validation to ensure resolved paths stay within LOG_BASE_PATH - Add archiver error handler to properly handle archive generation errors - Fix event listener ordering: register 'end' handler before calling finalize() - Add empty zip detection: return 404 error if no log files found on disk - Fix toolProtocol not being applied for 'other' provider in new-run.tsx
1 parent dd20ee1 commit 70f6c8c

File tree

4 files changed

+91
-17
lines changed

4 files changed

+91
-17
lines changed

apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextResponse } from "next/server"
22
import type { NextRequest } from "next/server"
3-
import * as fs from "node:fs"
3+
import * as fs from "node:fs/promises"
44
import * as path from "node:path"
55

66
import { findTask, findRun } from "@roo-code/evals"
@@ -9,6 +9,12 @@ export const dynamic = "force-dynamic"
99

1010
const LOG_BASE_PATH = "/tmp/evals/runs"
1111

12+
// Sanitize path components to prevent path traversal attacks
13+
function sanitizePathComponent(component: string): string {
14+
// Remove any path separators, null bytes, and other dangerous characters
15+
return component.replace(/[/\\:\0*?"<>|]/g, "_")
16+
}
17+
1218
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string; taskId: string }> }) {
1319
const { id, taskId } = await params
1420

@@ -31,19 +37,31 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
3137
return NextResponse.json({ error: "Task does not belong to this run" }, { status: 404 })
3238
}
3339

40+
// Sanitize language and exercise to prevent path traversal
41+
const safeLanguage = sanitizePathComponent(task.language)
42+
const safeExercise = sanitizePathComponent(task.exercise)
43+
3444
// Construct the log file path
35-
const logFileName = `${task.language}-${task.exercise}.log`
45+
const logFileName = `${safeLanguage}-${safeExercise}.log`
3646
const logFilePath = path.join(LOG_BASE_PATH, String(runId), logFileName)
3747

38-
// Check if the log file exists
39-
if (!fs.existsSync(logFilePath)) {
40-
return NextResponse.json({ error: "Log file not found", logContent: null }, { status: 200 })
48+
// Verify the resolved path is within the expected directory (defense in depth)
49+
const resolvedPath = path.resolve(logFilePath)
50+
const expectedBase = path.resolve(LOG_BASE_PATH)
51+
if (!resolvedPath.startsWith(expectedBase)) {
52+
return NextResponse.json({ error: "Invalid log path" }, { status: 400 })
4153
}
4254

43-
// Read the log file
44-
const logContent = fs.readFileSync(logFilePath, "utf-8")
45-
46-
return NextResponse.json({ logContent })
55+
// Check if the log file exists and read it (async)
56+
try {
57+
const logContent = await fs.readFile(logFilePath, "utf-8")
58+
return NextResponse.json({ logContent })
59+
} catch (err) {
60+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
61+
return NextResponse.json({ error: "Log file not found", logContent: null }, { status: 200 })
62+
}
63+
throw err
64+
}
4765
} catch (error) {
4866
console.error("Error reading task log:", error)
4967

apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export const dynamic = "force-dynamic"
1010

1111
const LOG_BASE_PATH = "/tmp/evals/runs"
1212

13+
// Sanitize path components to prevent path traversal attacks
14+
function sanitizePathComponent(component: string): string {
15+
// Remove any path separators, null bytes, and other dangerous characters
16+
return component.replace(/[/\\:\0*?"<>|]/g, "_")
17+
}
18+
1319
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
1420
const { id } = await params
1521

@@ -43,25 +49,61 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
4349
chunks.push(chunk)
4450
})
4551

52+
// Track archive errors
53+
let archiveError: Error | null = null
54+
archive.on("error", (err: Error) => {
55+
archiveError = err
56+
})
57+
58+
// Set up the end promise before finalizing (proper event listener ordering)
59+
const archiveEndPromise = new Promise<void>((resolve, reject) => {
60+
archive.on("end", resolve)
61+
archive.on("error", reject)
62+
})
63+
4664
// Add each failed task's log file to the archive
4765
const logDir = path.join(LOG_BASE_PATH, String(runId))
66+
let filesAdded = 0
4867

4968
for (const task of failedTasks) {
50-
const logFileName = `${task.language}-${task.exercise}.log`
69+
// Sanitize language and exercise to prevent path traversal
70+
const safeLanguage = sanitizePathComponent(task.language)
71+
const safeExercise = sanitizePathComponent(task.exercise)
72+
const logFileName = `${safeLanguage}-${safeExercise}.log`
5173
const logFilePath = path.join(logDir, logFileName)
5274

75+
// Verify the resolved path is within the expected directory (defense in depth)
76+
const resolvedPath = path.resolve(logFilePath)
77+
const expectedBase = path.resolve(LOG_BASE_PATH)
78+
if (!resolvedPath.startsWith(expectedBase)) {
79+
continue // Skip files with suspicious paths
80+
}
81+
5382
if (fs.existsSync(logFilePath)) {
5483
archive.file(logFilePath, { name: logFileName })
84+
filesAdded++
5585
}
5686
}
5787

88+
// Check if any files were actually added
89+
if (filesAdded === 0) {
90+
archive.abort()
91+
return NextResponse.json(
92+
{ error: "No log files found - they may have been cleared from disk" },
93+
{ status: 404 },
94+
)
95+
}
96+
5897
// Finalize the archive
5998
await archive.finalize()
6099

61100
// Wait for all data to be collected
62-
await new Promise<void>((resolve) => {
63-
archive.on("end", resolve)
64-
})
101+
await archiveEndPromise
102+
103+
// Check for archive errors
104+
if (archiveError) {
105+
throw archiveError
106+
}
65107

66108
// Combine all chunks into a single buffer
67109
const zipBuffer = Buffer.concat(chunks)

apps/web-evals/src/app/runs/[id]/run.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,25 @@ function getToolAbbreviation(toolName: string): string {
4242
.join("")
4343
}
4444

45-
// Format log content with basic highlighting
45+
// Escape HTML to prevent XSS
46+
function escapeHtml(text: string): string {
47+
return text
48+
.replace(/&/g, "&amp;")
49+
.replace(/</g, "&lt;")
50+
.replace(/>/g, "&gt;")
51+
.replace(/"/g, "&quot;")
52+
.replace(/'/g, "&#039;")
53+
}
54+
55+
// Format log content with basic highlighting (XSS-safe)
4656
function formatLogContent(log: string): React.ReactNode[] {
4757
const lines = log.split("\n")
4858
return lines.map((line, index) => {
59+
// First escape the entire line to prevent XSS
60+
let formattedLine = escapeHtml(line)
61+
4962
// Highlight timestamps [YYYY-MM-DDTHH:MM:SS.sssZ]
50-
let formattedLine = line.replace(
63+
formattedLine = formattedLine.replace(
5164
/\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\]/g,
5265
'<span class="text-blue-400">[$1]</span>',
5366
)
@@ -67,7 +80,7 @@ function formatLogContent(log: string): React.ReactNode[] {
6780
'<span class="text-purple-400">$1</span>',
6881
)
6982

70-
// Highlight message arrows
83+
// Highlight message arrows (escaped as &rarr; after escapeHtml)
7184
formattedLine = formattedLine.replace(//g, '<span class="text-cyan-400">→</span>')
7285

7386
return (

apps/web-evals/src/app/runs/new/new-run.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,10 @@ export function NewRun() {
248248
: {}),
249249
}
250250
} else if (provider === "other" && values.settings) {
251-
// For imported settings, merge in experiments
251+
// For imported settings, merge in experiments and tool protocol
252252
values.settings = {
253253
...values.settings,
254+
toolProtocol: useNativeToolProtocol ? "native" : "xml",
254255
...experimentsSettings,
255256
}
256257
}

0 commit comments

Comments
 (0)