Files
gstack/design/src/serve.ts
T
Garry Tan 03973c2fab fix: community security wave — 8 PRs, 4 contributors (v0.15.13.0) (#847)
* fix(bin): pass search params via env vars (RCE fix) (#819)

Replace shell string interpolation with process.env in gstack-learnings-search
to prevent arbitrary code execution via crafted learnings entries. Also fixes
the CROSS_PROJECT interpolation that the original PR missed.

Adds 3 regression tests verifying no shell interpolation remains in the bun -e block.

Co-authored-by: garagon <garagon@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(browse): add path validation to upload command (#821)

Add isPathWithin() and path traversal checks to the upload command,
blocking file exfiltration via crafted upload paths. Uses existing
SAFE_DIRECTORIES constant instead of a local copy. Adds 3 regression tests.

Co-authored-by: garagon <garagon@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(browse): symlink resolution in meta-commands validateOutputPath (#820)

Add realpathSync to validateOutputPath in meta-commands.ts to catch
symlink-based directory escapes in screenshot, pdf, and responsive
commands. Resolves SAFE_DIRECTORIES through realpathSync to handle
macOS /tmp -> /private/tmp symlinks. Existing path validation tests
pass with the hardened implementation.

Co-authored-by: garagon <garagon@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add uninstall instructions to README (#812)

Community PR #812 by @0531Kim. Adds two uninstall paths: the gstack-uninstall
script (handles everything) and manual removal steps for when the repo isn't
cloned. Includes CLAUDE.md cleanup note and Playwright cache guidance.

Co-Authored-By: 0531Kim <0531Kim@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(browse): Windows launcher extraEnv + headed-mode token (#822)

Community PR #822 by @pieterklue. Three fixes:
1. Windows launcher now merges extraEnv into spawned server env (was
   only passing BROWSE_STATE_FILE, dropping all other env vars)
2. Welcome page fallback serves inline HTML instead of about:blank
   redirect (avoids ERR_UNSAFE_REDIRECT on Windows)
3. /health returns auth token in headed mode even without Origin header
   (fixes Playwright Chromium extensions that don't send it)

Also adds HOME/USERPROFILE fallback for cross-platform compatibility.

Co-Authored-By: pieterklue <pieterklue@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(browse): terminate orphan server when parent process exits (#808)

Community PR #808 by @mmporong. Passes BROWSE_PARENT_PID to the spawned
server process. The server polls every 15s with signal 0 and calls
shutdown() if the parent is gone. Prevents orphaned chrome-headless-shell
processes when Claude Code sessions exit abnormally.

Co-Authored-By: mmporong <mmporong@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): IPv6 ULA blocking, cookie redaction, per-tab cancel, targeted token (#664)

Community PR #664 by @mr-k-man (security audit round 1, new parts only).

- IPv6 ULA prefix blocking (fc00::/7) in url-validation.ts with false-positive
  guard for hostnames like fd.example.com
- Cookie value redaction for tokens, API keys, JWTs in browse cookies command
- Per-tab cancel files in killAgent() replacing broken global kill-signal
- design/serve.ts: realpathSync upgrade prevents symlink bypass in /api/reload
- extension: targeted getToken handler replaces token-in-health-broadcast
- Supabase migration 003: column-level GRANT restricts anon UPDATE scope
- Telemetry sync: upsert error logging
- 10 new tests for IPv6, cookie redaction, DNS rebinding, path traversal

Co-Authored-By: mr-k-man <mr-k-man@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): CSS injection guard, timeout clamping, session validation, tests (#806)

Community PR #806 by @mr-k-man (security audit round 2, new parts only).

- CSS value validation (DANGEROUS_CSS) in cdp-inspector, write-commands, extension inspector
- Queue file permissions (0o700/0o600) in cli, server, sidebar-agent
- escapeRegExp for frame --url ReDoS fix
- Responsive screenshot path validation with validateOutputPath
- State load cookie filtering (reject localhost/.internal/metadata cookies)
- Session ID format validation in loadSession
- /health endpoint: remove currentUrl and currentMessage fields
- QueueEntry interface + isValidQueueEntry validator for sidebar-agent
- SIGTERM->SIGKILL escalation in timeout handler
- Viewport dimension clamping (1-16384), wait timeout clamping (1s-300s)
- Cookie domain validation in cookie-import and cookie-import-browser
- DocumentFragment-based tab switching (XSS fix in sidepanel)
- pollInProgress reentrancy guard for pollChat
- toggleClass/injectCSS input validation in extension inspector
- Snapshot annotated path validation with realpathSync
- 714-line security-audit-r2.test.ts + 33-line learnings-injection.test.ts

Co-Authored-By: mr-k-man <mr-k-man@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.15.13.0)

Community security wave: 8 PRs from 4 contributors (@garagon, @mr-k-man,
@mmporong, @0531Kim, @pieterklue). IPv6 ULA blocking, cookie redaction,
per-tab cancel signaling, CSS injection guards, timeout clamping, session
validation, DocumentFragment XSS fix, parent process watchdog, uninstall
docs, Windows fixes, and 750+ lines of security regression tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garagon <garagon@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: 0531Kim <0531Kim@users.noreply.github.com>
Co-authored-by: pieterklue <pieterklue@users.noreply.github.com>
Co-authored-by: mmporong <mmporong@users.noreply.github.com>
Co-authored-by: mr-k-man <mr-k-man@users.noreply.github.com>
2026-04-06 00:47:04 -07:00

256 lines
8.2 KiB
TypeScript

/**
* HTTP server for the design comparison board feedback loop.
*
* Replaces the broken file:// + DOM polling approach. The server:
* 1. Serves the comparison board HTML over HTTP
* 2. Injects __GSTACK_SERVER_URL so the board POSTs feedback here
* 3. Prints feedback JSON to stdout (agent reads it)
* 4. Stays alive across regeneration rounds (stateful)
* 5. Auto-opens in the user's default browser
*
* State machine:
*
* SERVING ──(POST submit)──► DONE ──► exit 0
* │
* ├──(POST regenerate/remix)──► REGENERATING
* │ │
* │ (POST /api/reload)
* │ │
* │ ▼
* │ RELOADING ──► SERVING
* │
* └──(timeout)──► exit 1
*
* Feedback delivery (two channels, both always active):
* Stdout: feedback JSON (one line per event) — for foreground mode
* Disk: feedback-pending.json (regenerate/remix) or feedback.json (submit)
* written next to the HTML file — for background mode polling
*
* The agent typically backgrounds $D serve and polls for feedback-pending.json.
* When found: read it, delete it, generate new variants, POST /api/reload.
*
* Stderr: structured telemetry (SERVE_STARTED, SERVE_FEEDBACK_RECEIVED, etc.)
*/
import fs from "fs";
import os from "os";
import path from "path";
import { spawn } from "child_process";
export interface ServeOptions {
html: string;
port?: number;
hostname?: string; // default '127.0.0.1' — localhost only
timeout?: number; // seconds, default 600 (10 min)
}
type ServerState = "serving" | "regenerating" | "done";
export async function serve(options: ServeOptions): Promise<void> {
const { html, port = 0, hostname = '127.0.0.1', timeout = 600 } = options;
// Validate HTML file exists
if (!fs.existsSync(html)) {
console.error(`SERVE_ERROR: HTML file not found: ${html}`);
process.exit(1);
}
// Security: anchor all file reads to the initial HTML's directory.
// Prevents /api/reload from reading arbitrary files via path traversal.
const allowedDir = fs.realpathSync(path.dirname(path.resolve(html)));
let htmlContent = fs.readFileSync(html, "utf-8");
let state: ServerState = "serving";
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
const server = Bun.serve({
port,
hostname,
fetch(req) {
const url = new URL(req.url);
// Serve the comparison board HTML
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
// Inject the server URL so the board can POST feedback
const injected = htmlContent.replace(
"</head>",
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
);
return new Response(injected, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// Progress polling endpoint (used by board during regeneration)
if (req.method === "GET" && url.pathname === "/api/progress") {
return Response.json({ status: state });
}
// Feedback submission from the board
if (req.method === "POST" && url.pathname === "/api/feedback") {
return handleFeedback(req);
}
// Reload endpoint (used by the agent to swap in new board HTML)
if (req.method === "POST" && url.pathname === "/api/reload") {
return handleReload(req);
}
return new Response("Not found", { status: 404 });
},
});
const actualPort = server.port;
const boardUrl = `http://127.0.0.1:${actualPort}`;
console.error(`SERVE_STARTED: port=${actualPort} html=${html}`);
// Auto-open in user's default browser
openBrowser(boardUrl);
// Set timeout
timeoutTimer = setTimeout(() => {
console.error(`SERVE_TIMEOUT: after=${timeout}s`);
server.stop();
process.exit(1);
}, timeout * 1000);
async function handleFeedback(req: Request): Promise<Response> {
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
// Validate expected shape
if (typeof body !== "object" || body === null) {
return Response.json({ error: "Expected JSON object" }, { status: 400 });
}
const isSubmit = body.regenerated === false;
const isRegenerate = body.regenerated === true;
const action = isSubmit ? "submitted" : (body.regenerateAction || "regenerate");
console.error(`SERVE_FEEDBACK_RECEIVED: type=${action}`);
// Print feedback JSON to stdout (for foreground mode)
console.log(JSON.stringify(body));
// ALWAYS write feedback to disk so the agent can poll for it
// (agent typically backgrounds $D serve, can't read stdout)
const feedbackDir = path.dirname(html);
const feedbackFile = isSubmit ? "feedback.json" : "feedback-pending.json";
const feedbackPath = path.join(feedbackDir, feedbackFile);
fs.writeFileSync(feedbackPath, JSON.stringify(body, null, 2));
if (isSubmit) {
state = "done";
if (timeoutTimer) clearTimeout(timeoutTimer);
// Give the response time to send before exiting
setTimeout(() => {
server.stop();
process.exit(0);
}, 100);
return Response.json({ received: true, action: "submitted" });
}
if (isRegenerate) {
state = "regenerating";
// Reset timeout for regeneration (agent needs time to generate new variants)
if (timeoutTimer) clearTimeout(timeoutTimer);
timeoutTimer = setTimeout(() => {
console.error(`SERVE_TIMEOUT: after=${timeout}s (during regeneration)`);
server.stop();
process.exit(1);
}, timeout * 1000);
return Response.json({ received: true, action: "regenerate" });
}
return Response.json({ received: true, action: "unknown" });
}
async function handleReload(req: Request): Promise<Response> {
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const newHtmlPath = body.html;
if (!newHtmlPath || !fs.existsSync(newHtmlPath)) {
return Response.json(
{ error: `HTML file not found: ${newHtmlPath}` },
{ status: 400 }
);
}
// Security: resolve symlinks and validate the reload path is within the
// allowed directory (anchored to the initial HTML file's parent).
// Prevents path traversal via /api/reload reading arbitrary files.
const resolvedReload = fs.realpathSync(path.resolve(newHtmlPath));
if (!resolvedReload.startsWith(allowedDir + path.sep) && resolvedReload !== allowedDir) {
return Response.json(
{ error: `Path must be within: ${allowedDir}` },
{ status: 403 }
);
}
// Swap the HTML content
htmlContent = fs.readFileSync(resolvedReload, "utf-8");
state = "serving";
console.error(`SERVE_RELOADED: html=${newHtmlPath}`);
// Reset timeout
if (timeoutTimer) clearTimeout(timeoutTimer);
timeoutTimer = setTimeout(() => {
console.error(`SERVE_TIMEOUT: after=${timeout}s`);
server.stop();
process.exit(1);
}, timeout * 1000);
return Response.json({ reloaded: true });
}
// Keep the process alive
await new Promise(() => {});
}
/**
* Open a URL in the user's default browser.
* Handles macOS (open), Linux (xdg-open), and headless environments.
*/
function openBrowser(url: string): void {
const platform = process.platform;
let cmd: string;
if (platform === "darwin") {
cmd = "open";
} else if (platform === "linux") {
cmd = "xdg-open";
} else {
// Windows or unknown — just print the URL
console.error(`SERVE_BROWSER_MANUAL: url=${url}`);
console.error(`Open this URL in your browser: ${url}`);
return;
}
try {
const child = spawn(cmd, [url], {
stdio: "ignore",
detached: true,
});
child.unref();
console.error(`SERVE_BROWSER_OPENED: url=${url}`);
} catch {
// open/xdg-open not available (headless CI environment)
console.error(`SERVE_BROWSER_MANUAL: url=${url}`);
console.error(`Open this URL in your browser: ${url}`);
}
}