mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-17 17:51:27 +08:00
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>
This commit is contained in:
@@ -282,6 +282,10 @@ function loadSession(): SidebarSession | null {
|
||||
try {
|
||||
const activeFile = path.join(SESSIONS_DIR, 'active.json');
|
||||
const activeData = JSON.parse(fs.readFileSync(activeFile, 'utf-8'));
|
||||
if (typeof activeData.id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(activeData.id)) {
|
||||
console.warn('[browse] Invalid session ID in active.json — ignoring');
|
||||
return null;
|
||||
}
|
||||
const sessionFile = path.join(SESSIONS_DIR, activeData.id, 'session.json');
|
||||
const session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as SidebarSession;
|
||||
// Validate worktree still exists — crash may have left stale path
|
||||
@@ -560,6 +564,7 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId
|
||||
try {
|
||||
fs.mkdirSync(gstackDir, { recursive: true, mode: 0o700 });
|
||||
fs.appendFileSync(agentQueue, entry + '\n');
|
||||
try { fs.chmodSync(agentQueue, 0o600); } catch {}
|
||||
} catch (err: any) {
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
|
||||
agentStatus = 'idle';
|
||||
@@ -572,7 +577,7 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId
|
||||
// Agent status transitions happen when we receive agent_done/agent_error events.
|
||||
}
|
||||
|
||||
function killAgent(): void {
|
||||
function killAgent(targetTabId?: number | null): void {
|
||||
if (agentProcess) {
|
||||
try { agentProcess.kill('SIGTERM'); } catch (err: any) {
|
||||
console.warn('[browse] Failed to SIGTERM agent:', err.message);
|
||||
@@ -581,17 +586,18 @@ function killAgent(): void {
|
||||
console.warn('[browse] Failed to SIGKILL agent:', err.message);
|
||||
} }, 3000);
|
||||
}
|
||||
// Signal the sidebar-agent worker to cancel via a per-tab cancel file.
|
||||
// Using per-tab files prevents race conditions where one agent's cancel
|
||||
// signal is consumed by a different tab's agent in concurrent mode.
|
||||
// When targetTabId is provided, only that tab's agent is cancelled.
|
||||
const cancelDir = path.join(process.env.HOME || '/tmp', '.gstack');
|
||||
const tabId = targetTabId ?? agentTabId ?? 0;
|
||||
const cancelFile = path.join(cancelDir, `sidebar-agent-cancel-${tabId}`);
|
||||
try { fs.writeFileSync(cancelFile, Date.now().toString()); } catch {}
|
||||
agentProcess = null;
|
||||
agentStartTime = null;
|
||||
currentMessage = null;
|
||||
agentStatus = 'idle';
|
||||
|
||||
// Signal sidebar-agent.ts to kill its active claude subprocess.
|
||||
// sidebar-agent runs in a separate non-compiled Bun process (posix_spawn
|
||||
// limitation). It polls the kill-signal file and terminates on any write.
|
||||
const agentQueue = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
|
||||
const killFile = path.join(path.dirname(agentQueue), 'sidebar-agent-kill');
|
||||
try { fs.writeFileSync(killFile, String(Date.now())); } catch {}
|
||||
}
|
||||
|
||||
// Agent health check — detect hung processes
|
||||
@@ -691,6 +697,23 @@ const idleCheckInterval = setInterval(() => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// ─── Parent-Process Watchdog ────────────────────────────────────────
|
||||
// When the spawning CLI process (e.g. a Claude Code session) exits, this
|
||||
// server can become an orphan — keeping chrome-headless-shell alive and
|
||||
// causing console-window flicker on Windows. Poll the parent PID every 15s
|
||||
// and self-terminate if it is gone.
|
||||
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
|
||||
if (BROWSE_PARENT_PID > 0) {
|
||||
setInterval(() => {
|
||||
try {
|
||||
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
||||
} catch {
|
||||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited, shutting down`);
|
||||
shutdown();
|
||||
}
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
// ─── Command Sets (from commands.ts — single source of truth) ───
|
||||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||||
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
||||
@@ -1060,12 +1083,13 @@ async function start() {
|
||||
const welcomePath = (() => {
|
||||
// Check project-local designs first, then global
|
||||
const slug = process.env.GSTACK_SLUG || 'unknown';
|
||||
const projectWelcome = `${process.env.HOME}/.gstack/projects/${slug}/designs/welcome-page-20260331/finalized.html`;
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||||
const projectWelcome = `${homeDir}/.gstack/projects/${slug}/designs/welcome-page-20260331/finalized.html`;
|
||||
try { if (require('fs').existsSync(projectWelcome)) return projectWelcome; } catch (err: any) {
|
||||
console.warn('[browse] Error checking project welcome page:', err.message);
|
||||
}
|
||||
// Fallback: built-in welcome page from gstack install
|
||||
const skillRoot = process.env.GSTACK_SKILL_ROOT || `${process.env.HOME}/.claude/skills/gstack`;
|
||||
const skillRoot = process.env.GSTACK_SKILL_ROOT || `${homeDir}/.claude/skills/gstack`;
|
||||
const builtinWelcome = `${skillRoot}/browse/src/welcome.html`;
|
||||
try { if (require('fs').existsSync(builtinWelcome)) return builtinWelcome; } catch (err: any) {
|
||||
console.warn('[browse] Error checking builtin welcome page:', err.message);
|
||||
@@ -1080,8 +1104,14 @@ async function start() {
|
||||
console.error('[browse] Failed to read welcome page:', welcomePath, err.message);
|
||||
}
|
||||
}
|
||||
// No welcome page found — redirect to about:blank
|
||||
return new Response('', { status: 302, headers: { 'Location': 'about:blank' } });
|
||||
// No welcome page found — serve a simple fallback (avoid ERR_UNSAFE_REDIRECT on Windows)
|
||||
return new Response(
|
||||
`<!DOCTYPE html><html><head><title>GStack Browser</title>
|
||||
<style>body{background:#111;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;}
|
||||
.msg{text-align:center;opacity:.7;}.gold{color:#f5a623;font-size:2em;margin-bottom:12px;}</style></head>
|
||||
<body><div class="msg"><div class="gold">◈</div><p>GStack Browser ready.</p><p style="font-size:.85em">Waiting for commands from Claude Code.</p></div></body></html>`,
|
||||
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Health check — no auth required, does NOT reset idle timer
|
||||
@@ -1092,17 +1122,18 @@ async function start() {
|
||||
mode: browserManager.getConnectionMode(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
tabs: browserManager.getTabCount(),
|
||||
currentUrl: browserManager.getCurrentUrl(),
|
||||
// Auth token for extension bootstrap. Only returned when the request
|
||||
// comes from a Chrome extension (Origin: chrome-extension://...).
|
||||
// Auth token for extension bootstrap. Safe: /health is localhost-only.
|
||||
// Previously served unconditionally, but that leaks the token if the
|
||||
// server is tunneled to the internet (ngrok, SSH tunnel).
|
||||
...(req.headers.get('origin')?.startsWith('chrome-extension://') ? { token: AUTH_TOKEN } : {}),
|
||||
// In headed mode the server is always local, so return token unconditionally
|
||||
// (fixes Playwright Chromium extensions that don't send Origin header).
|
||||
...(browserManager.getConnectionMode() === 'headed' ||
|
||||
req.headers.get('origin')?.startsWith('chrome-extension://')
|
||||
? { token: AUTH_TOKEN } : {}),
|
||||
chatEnabled: true,
|
||||
agent: {
|
||||
status: agentStatus,
|
||||
runningFor: agentStartTime ? Date.now() - agentStartTime : null,
|
||||
currentMessage,
|
||||
queueLength: messageQueue.length,
|
||||
},
|
||||
session: sidebarSession ? { id: sidebarSession.id, name: sidebarSession.name } : null,
|
||||
@@ -1223,9 +1254,10 @@ async function start() {
|
||||
}
|
||||
try {
|
||||
// Sync active tab from Chrome extension — detects manual tab switches
|
||||
const activeUrl = url.searchParams.get('activeUrl');
|
||||
if (activeUrl) {
|
||||
browserManager.syncActiveTabByUrl(activeUrl);
|
||||
const rawActiveUrl = url.searchParams.get('activeUrl');
|
||||
const sanitizedActiveUrl = sanitizeExtensionUrl(rawActiveUrl);
|
||||
if (sanitizedActiveUrl) {
|
||||
browserManager.syncActiveTabByUrl(sanitizedActiveUrl);
|
||||
}
|
||||
const tabs = await browserManager.getTabListWithTitles();
|
||||
return new Response(JSON.stringify({ tabs }), {
|
||||
@@ -1294,11 +1326,12 @@ async function start() {
|
||||
// The Chrome extension sends the active tab's URL — prefer it over
|
||||
// Playwright's page.url() which can be stale in headed mode when
|
||||
// the user navigates manually.
|
||||
const extensionUrl = body.activeTabUrl || null;
|
||||
const rawExtensionUrl = body.activeTabUrl || null;
|
||||
const sanitizedExtUrl = sanitizeExtensionUrl(rawExtensionUrl);
|
||||
// Sync active tab BEFORE reading the ID — the user may have switched
|
||||
// tabs manually and the server's activeTabId is stale.
|
||||
if (extensionUrl) {
|
||||
browserManager.syncActiveTabByUrl(extensionUrl);
|
||||
if (sanitizedExtUrl) {
|
||||
browserManager.syncActiveTabByUrl(sanitizedExtUrl);
|
||||
}
|
||||
const msgTabId = browserManager?.getActiveTabId?.() ?? 0;
|
||||
const ts = new Date().toISOString();
|
||||
@@ -1308,12 +1341,12 @@ async function start() {
|
||||
// Per-tab agent: each tab can run its own agent concurrently
|
||||
const tabState = getTabAgent(msgTabId);
|
||||
if (tabState.status === 'idle') {
|
||||
spawnClaude(msg, extensionUrl, msgTabId);
|
||||
spawnClaude(msg, sanitizedExtUrl, msgTabId);
|
||||
return new Response(JSON.stringify({ ok: true, processing: true }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} else if (tabState.queue.length < MAX_QUEUE) {
|
||||
tabState.queue.push({ message: msg, ts, extensionUrl });
|
||||
tabState.queue.push({ message: msg, ts, extensionUrl: sanitizedExtUrl });
|
||||
return new Response(JSON.stringify({ ok: true, queued: true, position: tabState.queue.length }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
@@ -1344,7 +1377,8 @@ async function start() {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
killAgent();
|
||||
const killBody = await req.json().catch(() => ({}));
|
||||
killAgent(killBody.tabId ?? null);
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Killed by user' });
|
||||
// Process next in queue
|
||||
if (messageQueue.length > 0) {
|
||||
@@ -1359,7 +1393,8 @@ async function start() {
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
killAgent();
|
||||
const stopBody = await req.json().catch(() => ({}));
|
||||
killAgent(stopBody.tabId ?? null);
|
||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Stopped by user' });
|
||||
return new Response(JSON.stringify({ ok: true, queuedMessages: messageQueue.length }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
Reference in New Issue
Block a user