From 055dda167292238892d34ac3d012aefbc72479a9 Mon Sep 17 00:00:00 2001 From: jennyf19 <19942418+jennyf19@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:28:43 -0700 Subject: [PATCH] signals-dashboard: harden desk launch (round 2 review) Synced from the-workshop@8106dd4. Addresses the second GHCP review pass on #2363: - isOnPath now requires a runnable file (X_OK / PATHEXT), not just existsSync. - launchDeskConsole adds a realpath-containment check (symlink escape) and takes workshopDir. - Dashboard copies the path only on the non-launch fallback. - Mutating /api/* routes now require the canonical loopback Host plus a per-server capability token (DNS-rebinding defense), mirroring connector-namespaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26faf13e-639c-4a21-ac05-c0dc2bff7c62 --- extensions/signals-dashboard/extension.mjs | 130 ++++++++++++++++----- 1 file changed, 99 insertions(+), 31 deletions(-) diff --git a/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extension.mjs index 0fed4a7f..962b6d6b 100644 --- a/extensions/signals-dashboard/extension.mjs +++ b/extensions/signals-dashboard/extension.mjs @@ -4,10 +4,11 @@ // Supports stashing desks (48hr hold) and restoring them. import { createServer } from "node:http"; -import { existsSync } from "node:fs"; +import { existsSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs"; import { readdir, readFile, writeFile, stat } from "node:fs/promises"; -import { join, delimiter } from "node:path"; +import { join, delimiter, sep } from "node:path"; import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; const servers = new Map(); @@ -74,6 +75,18 @@ function trySpawn(cmd, args, opts = {}) { // Resolve an executable on PATH (honoring PATHEXT on Windows), mirroring // WorkshopRoom's AgentClis.IsOnPath. Used to prefer Agency when the machine has // it installed, falling back to vanilla Copilot. +// A PATH hit only counts if it resolves to a real, runnable file. existsSync +// alone would treat a directory or a non-executable file named `agency` as a +// match, so auto-detection would pick the wrapper and the terminal would then +// fail to run it with no fallback. +function isExecutableFile(p) { + try { + if (!statSync(p).isFile()) return false; + if (process.platform !== "win32") accessSync(p, fsConstants.X_OK); + return true; + } catch { return false; } +} + function isOnPath(command) { try { const dirs = (process.env.PATH || "").split(delimiter); @@ -82,10 +95,13 @@ function isOnPath(command) { : []; for (const dir of dirs) { if (!dir) continue; - try { - if (existsSync(join(dir, command))) return true; - for (const ext of exts) if (existsSync(join(dir, command + ext))) return true; - } catch {} + // On Windows only a PATHEXT match is runnable; on POSIX check the bare + // name, and isExecutableFile confirms the execute bit either way. + if (exts.length) { + for (const ext of exts) if (isExecutableFile(join(dir, command + ext))) return true; + } else if (isExecutableFile(join(dir, command))) { + return true; + } } } catch {} return false; @@ -129,12 +145,28 @@ function osaStringLiteral(s) { return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; } -async function launchDeskConsole(deskPath, deskName) { +// Resolve symlinks on both sides and confirm the target is the workshop root +// itself or lives beneath it. The callers locate a desk with stat(), which +// follows symlinks, so a committed desks/foo -> /outside symlink would otherwise +// launch the agent with an external working directory, breaking the inside-repo +// guarantee. +function isInsideRoot(root, target) { + try { + const r = realpathSync(root); + const t = realpathSync(target); + return t === r || t.startsWith(r + sep); + } catch { return false; } +} + +async function launchDeskConsole(deskPath, deskName, workshopDir) { // deskPath can't contain a double quote (a real Windows path never does and // it would break out of a quoted argument). deskName must be a plain slug so - // it is safe on every command line and shell below. + // it is safe on every command line and shell below, and the resolved desk + // must still live inside the workshop root (which defeats a symlinked desk + // that escapes the repo). if (!deskPath || deskPath.includes('"')) return false; if (!isSafeDeskNameForLaunch(deskName)) return false; + if (!isInsideRoot(workshopDir, deskPath)) return false; const run = [...deskAgentArgv(deskName), "-i", deskOrientPrompt(deskName)]; if (process.platform === "win32") { // Windows Terminal is a GUI app, so it always surfaces its own visible @@ -219,6 +251,24 @@ function isCrossSiteRequest(req) { return site === "cross-site" || site === "same-site"; } +// Pin the Host header to the exact loopback authority we bound. A DNS-rebinding +// page reaches us under its own hostname (Host: attacker.example:), so an +// exact match against 127.0.0.1: refuses those requests before any state +// change — Origin/Host equality alone doesn't, since the attacker controls both. +function isCanonicalHost(req, canonicalHost) { + return String(req.headers.host || "").toLowerCase() === String(canonicalHost || "").toLowerCase(); +} + +// Capability check for the per-server token minted at startup and embedded in +// the page we serve. Only the loopback document we rendered knows it, so a blind +// cross-origin/rebinding caller can't forge a mutating request even if it +// reached the socket. +function hasCapabilityToken(req, token) { + const header = req.headers["x-workshop-token"]; + const provided = Array.isArray(header) ? header[0] : header; + return typeof provided === "string" && provided.length > 0 && provided === token; +} + // --- Stash management --- async function readStash(workshopDir) { @@ -675,7 +725,7 @@ function renderStashedCard(entry) { `; } -function renderDashboard(signals, stashed) { +function renderDashboard(signals, stashed, capabilityToken) { const activeSignals = sortSignals(signals.filter(s => !stashed.some(e => e.name === s.deskName))); const cards = activeSignals.length > 0 @@ -747,12 +797,17 @@ function renderDashboard(signals, stashed) {