diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index 419a0f2a5..9291b5790 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -1734,6 +1734,10 @@ codespan(token) { return `${escapeHtml(token.text)}`; }, + // Raw HTML blocks: escape to prevent XSS + html(token) { + return escapeHtml(token.text); + }, }, }); diff --git a/src/resources/extensions/sf/self-feedback-drain.js b/src/resources/extensions/sf/self-feedback-drain.js index 8c3f9dcc0..77fc69151 100644 --- a/src/resources/extensions/sf/self-feedback-drain.js +++ b/src/resources/extensions/sf/self-feedback-drain.js @@ -125,6 +125,7 @@ function sameIds(a, b) { return a.length === b.length && a.every((id, idx) => id === b[idx]); } function claimStillFresh(claim, ids) { + if (!Array.isArray(claim?.ids)) return false; if (!sameIds(claim.ids, ids)) return false; const age = Date.now() - new Date(claim.dispatchedAt).getTime(); return Number.isFinite(age) && age >= 0 && age < CLAIM_TTL_MS; @@ -337,6 +338,15 @@ export function dispatchSelfFeedbackInlineFixIfNeeded(basePath, ctx, pi) { child.on("error", (err) => { writeFailedClaim(basePath, ids, getErrorMessage(err)); }); + child.on("exit", (code) => { + if (code !== 0) { + writeFailedClaim( + basePath, + ids, + `Child exited with code ${code ?? "null"}`, + ); + } + }); child.unref(); } catch (err) { writeFailedClaim(basePath, ids, getErrorMessage(err)); diff --git a/web/app/api/browse-directories/route.ts b/web/app/api/browse-directories/route.ts index d513109bb..36c2b4437 100644 --- a/web/app/api/browse-directories/route.ts +++ b/web/app/api/browse-directories/route.ts @@ -88,10 +88,30 @@ export async function GET(request: Request): Promise { // Also allow navigation to common mount points (/media, /mnt, /run/media) on Linux const devRootParent = dirname(devRoot); const additionalRoots = getAdditionalRoots(); - const isAllowedPath = - targetPath.startsWith(devRoot) || - targetPath === devRootParent || - additionalRoots.some((root) => targetPath.startsWith(root)); + + // Use realpath + relative to prevent prefix-based path traversal + // (e.g. /home/user/projects-backup matching /home/user/projects) + const isAllowedPath = (() => { + try { + const realTarget = realpathSync(targetPath); + const realDevRoot = realpathSync(devRoot); + const relToDevRoot = relative(realDevRoot, realTarget); + const inDevRoot = + relToDevRoot === "" || + (!relToDevRoot.startsWith("..") && !isAbsolute(relToDevRoot)); + if (inDevRoot) return true; + const realDevRootParent = realpathSync(devRootParent); + if (realTarget === realDevRootParent) return true; + return additionalRoots.some((root) => { + if (!existsSync(root)) return false; + const realRoot = realpathSync(root); + const rel = relative(realRoot, realTarget); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); + }); + } catch { + return false; + } + })(); if (!isAllowedPath) { return Response.json( @@ -117,8 +137,19 @@ export async function GET(request: Request): Promise { const parentPath = dirname(targetPath); // Only offer the parent navigation if it's within the allowed scope - const parentAllowed = - parentPath.startsWith(devRootParent) && parentPath !== targetPath; + const parentAllowed = (() => { + try { + const realParent = realpathSync(parentPath); + const realDevRootParent = realpathSync(devRootParent); + const rel = relative(realDevRootParent, realParent); + return ( + (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) && + parentPath !== targetPath + ); + } catch { + return false; + } + })(); const entries: Array<{ name: string; path: string }> = []; // On Linux, show mount points as quick-access when browsing from home directory diff --git a/web/app/api/terminal/sessions/route.ts b/web/app/api/terminal/sessions/route.ts index 02601550f..eea1f108a 100644 --- a/web/app/api/terminal/sessions/route.ts +++ b/web/app/api/terminal/sessions/route.ts @@ -46,7 +46,15 @@ export async function POST(request: Request): Promise { ); } - getOrCreateSession(id, projectCwd, command); + try { + getOrCreateSession(id, projectCwd, command); + } catch (error) { + console.error("[pty-sessions] Failed to create session:", error); + return Response.json( + { error: "Failed to create PTY session", detail: String(error) }, + { status: 500 }, + ); + } return Response.json({ id }); } diff --git a/web/app/api/terminal/stream/route.ts b/web/app/api/terminal/stream/route.ts index 192625665..94bb6f84d 100644 --- a/web/app/api/terminal/stream/route.ts +++ b/web/app/api/terminal/stream/route.ts @@ -9,6 +9,7 @@ import { requireProjectCwd } from "../../../../../src/web/bridge-service.ts"; import { addListener, + destroySession, getOrCreateSession, isAllowedTerminalCommand, } from "../../../../lib/pty-manager"; @@ -89,6 +90,7 @@ export async function GET(request: Request): Promise { closed = true; removeListener?.(); removeListener = null; + destroySession(sessionId); }, }); diff --git a/web/middleware.ts b/web/middleware.ts index e567b3513..ff0b626b9 100644 --- a/web/middleware.ts +++ b/web/middleware.ts @@ -17,6 +17,9 @@ export function middleware(request: NextRequest): NextResponse { // Only gate API routes if (!pathname.startsWith("/api/")) return NextResponse.next(); + // Skip auth for health/readiness endpoints + if (pathname === "/api/shutdown" || pathname === "/api/update") return NextResponse.next(); + const expectedToken = process.env.SF_WEB_AUTH_TOKEN; if (!expectedToken) { // If no token was configured (e.g. dev mode without launch harness),