Files
gstack/design/src/serve.ts
T
Garry Tan 115d81d792 fix: security wave 1 — 14 fixes for audit #783 (v0.15.7.0) (#810)
* fix: DNS rebinding protection checks AAAA (IPv6) records too

Cherry-pick PR #744 by @Gonzih. Closes the IPv6-only DNS rebinding gap
by checking both A and AAAA records independently.

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

* fix: validateOutputPath symlink bypass — resolve real path before safe-dir check

Cherry-pick PR #745 by @Gonzih. Adds a second pass using fs.realpathSync()
to resolve symlinks after lexical path validation.

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

* fix: validate saved URLs before navigation in restoreState

Cherry-pick PR #751 by @Gonzih. Prevents navigation to cloud metadata
endpoints or file:// URIs embedded in user-writable state files.

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

* fix: telemetry-ingest uses anon key instead of service role key

Cherry-pick PR #750 by @Gonzih. The service role key bypasses RLS and
grants unrestricted database access — anon key + RLS is the right model
for a public telemetry endpoint.

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

* fix: killAgent() actually kills the sidebar claude subprocess

Cherry-pick PR #743 by @Gonzih. Implements cross-process kill signaling
via kill-file + polling pattern, tracks active processes per-tab.

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

* fix(design): bind server to localhost and validate reload paths

Cherry-pick PR #803 by @garagon. Adds hostname: '127.0.0.1' to Bun.serve()
and validates /api/reload paths are within cwd() or tmpdir(). Closes C1+C2
from security audit #783.

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

* fix: add auth gate to /inspector/events SSE endpoint (C3)

The /inspector/events endpoint had no authentication, unlike /activity/stream
which validates tokens. Now requires the same Bearer header or ?token= query
param check. Closes C3 from security audit #783.

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

* fix: sanitize design feedback with trust boundary markers (C4+H5)

Wrap user feedback in <user-feedback> XML markers with tag escaping to
prevent prompt injection via malicious feedback text. Cap accumulated
feedback to last 5 iterations to limit incremental poisoning.
Closes C4 and H5 from security audit #783.

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

* fix: harden file/directory permissions to owner-only (C5+H9+M9+M10)

Add mode 0o700 to all mkdirSync calls for state/session directories.
Add mode 0o600 to all writeFileSync calls for session.json, chat.jsonl,
and log files. Add umask 077 to setup script. Prevents auth tokens, chat
history, and browser logs from being world-readable on multi-user systems.
Closes C5, H9, M9, M10 from security audit #783.

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

* fix: TOCTOU race in setup symlink creation (C6)

Remove the existence check before mkdir -p (it's idempotent) and validate
the target isn't already a symlink before creating the link. Prevents a
local attacker from racing between the check and mkdir to redirect
SKILL.md writes. Closes C6 from security audit #783.

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

* fix: remove CORS wildcard, restrict to localhost (H1)

Replace Access-Control-Allow-Origin: * with http://127.0.0.1 on sidebar
tab/chat endpoints. The Chrome extension uses manifest host_permissions
to bypass CORS entirely, so this only blocks malicious websites from
making cross-origin requests. Closes H1 from security audit #783.

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

* fix: make cookie picker auth mandatory (H2)

Remove the conditional if(authToken) guard that skipped auth when
authToken was undefined. Now all cookie picker data/action routes
reject unauthenticated requests. Closes H2 from security audit #783.

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

* fix: gate /health token on chrome-extension Origin header

Only return the auth token in /health response when the request Origin
starts with chrome-extension://. The Chrome extension always sends this
origin via manifest host_permissions. Regular HTTP requests (including
tunneled ones from ngrok/SSH) won't get the token. The extension also
has a fallback path through background.js that reads the token from the
state file directly.

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

* test: update server-auth test for chrome-extension Origin gating

The test previously checked for 'localhost-only' comment. Now checks for
'chrome-extension://' since the token is gated on Origin header.

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

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

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

---------

Co-authored-by: Gonzih <gonzih@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: garagon <garagon@users.noreply.github.com>
2026-04-04 22:12:04 -07:00

252 lines
7.9 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);
}
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 }
);
}
// Validate path is within cwd or temp directory
const resolved = path.resolve(newHtmlPath);
const safeDirs = [process.cwd(), os.tmpdir()];
const isSafe = safeDirs.some(dir => resolved.startsWith(dir + path.sep) || resolved === dir);
if (!isSafe) {
return Response.json(
{ error: `Path must be within working directory or temp` },
{ status: 403 }
);
}
// Swap the HTML content
htmlContent = fs.readFileSync(newHtmlPath, "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}`);
}
}