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:
Garry Tan
2026-04-06 00:47:04 -07:00
committed by GitHub
parent b3d064aabb
commit 03973c2fab
30 changed files with 4066 additions and 108 deletions

View File

@@ -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' },