singularity-forge/web/app/api/terminal/stream/route.ts
Mikael Hugo 2d5a05a48b fix(security): resolve 7 findings from full-repo code review
- Create web/middleware.ts to authenticate all API routes via bearer token
  and origin checks (previously unauthenticated due to missing middleware file)

- Fix path traversal in browse-directories: replace startsWith with
  realpathSync + relative + isAbsolute containment checks

- Fix XSS in session HTML export: escape raw HTML blocks via marked renderer

- Fix PTY process leak: destroy session on SSE stream cancellation

- Fix unhandled exception in terminal sessions POST: wrap getOrCreateSession
  in try/catch with structured JSON error response

- Fix silent child-process failure in headless dispatch: add exit handler
  to write failed claim when sf headless triage exits non-zero

- Fix TypeError on malformed claim JSON: add Array.isArray guard before
  accessing claim.ids.length

All changes type-check cleanly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 02:18:43 +02:00

105 lines
2.4 KiB
TypeScript

/**
* SSE endpoint streaming PTY output to the browser.
*
* GET /api/terminal/stream?id=<sessionId>
*
* Creates the PTY session on first connection if it doesn't exist.
*/
import { requireProjectCwd } from "../../../../../src/web/bridge-service.ts";
import {
addListener,
destroySession,
getOrCreateSession,
isAllowedTerminalCommand,
} from "../../../../lib/pty-manager";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const encoder = new TextEncoder();
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const sessionId = url.searchParams.get("id") || "default";
const command = url.searchParams.get("command") || undefined;
const commandArgs = url.searchParams.getAll("arg");
const projectCwd = requireProjectCwd(request);
if (!isAllowedTerminalCommand(command)) {
return Response.json(
{ error: `Command not allowed: ${command}` },
{ status: 403 },
);
}
// Ensure the session exists
try {
getOrCreateSession(sessionId, projectCwd, command, commandArgs);
} catch (error) {
console.error("[pty-stream] Failed to create session:", error);
return Response.json(
{ error: "Failed to create PTY session", detail: String(error) },
{ status: 500 },
);
}
let removeListener: (() => void) | null = null;
let closed = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
// Send an initial connected event
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "connected", sessionId })}\n\n`,
),
);
removeListener = addListener(sessionId, (data: string) => {
if (closed) return;
try {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "output", data })}\n\n`,
),
);
} catch {
// Stream closed
}
});
request.signal.addEventListener(
"abort",
() => {
if (closed) return;
closed = true;
removeListener?.();
removeListener = null;
try {
controller.close();
} catch {
// Already closed
}
},
{ once: true },
);
},
cancel() {
if (closed) return;
closed = true;
removeListener?.();
removeListener = null;
destroySession(sessionId);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}