mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-11 16:50:20 +02: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:
co-authored by
Claude Opus 4.6
garagon
0531Kim
pieterklue
mmporong
mr-k-man
parent
b3d064aabb
commit
03973c2fab
+51
-10
@@ -15,16 +15,40 @@ import { resolveConfig } from './config';
|
||||
import type { Frame } from 'playwright';
|
||||
|
||||
// Security: Path validation to prevent path traversal attacks
|
||||
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
|
||||
// Resolve safe directories through realpathSync to handle symlinks (e.g., macOS /tmp → /private/tmp)
|
||||
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()].map(d => {
|
||||
try { return fs.realpathSync(d); } catch { return d; }
|
||||
});
|
||||
|
||||
export function validateOutputPath(filePath: string): void {
|
||||
const resolved = path.resolve(filePath);
|
||||
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
|
||||
|
||||
// Resolve real path of the parent directory to catch symlinks.
|
||||
// The file itself may not exist yet (e.g., screenshot output).
|
||||
let dir = path.dirname(resolved);
|
||||
let realDir: string;
|
||||
try {
|
||||
realDir = fs.realpathSync(dir);
|
||||
} catch {
|
||||
try {
|
||||
realDir = fs.realpathSync(path.dirname(dir));
|
||||
} catch {
|
||||
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const realResolved = path.join(realDir, path.basename(resolved));
|
||||
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realResolved, dir));
|
||||
if (!isSafe) {
|
||||
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape special regex metacharacters in a user-supplied string to prevent ReDoS. */
|
||||
export function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Tokenize a pipe segment respecting double-quoted strings. */
|
||||
function tokenizePipeSegment(segment: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
@@ -195,9 +219,10 @@ export async function handleMetaCommand(
|
||||
|
||||
for (const vp of viewports) {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
const path = `${prefix}-${vp.name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
|
||||
const screenshotPath = `${prefix}-${vp.name}.png`;
|
||||
validateOutputPath(screenshotPath);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
results.push(`${vp.name} (${vp.width}x${vp.height}): ${screenshotPath}`);
|
||||
}
|
||||
|
||||
// Restore original viewport
|
||||
@@ -238,7 +263,11 @@ export async function handleMetaCommand(
|
||||
try {
|
||||
let result: string;
|
||||
if (WRITE_COMMANDS.has(name)) {
|
||||
result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
if (bm.isWatching()) {
|
||||
result = 'BLOCKED: write commands disabled in watch mode';
|
||||
} else {
|
||||
result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
}
|
||||
lastWasWrite = true;
|
||||
} else if (READ_COMMANDS.has(name)) {
|
||||
result = await handleReadCommand(name, cmdArgs, bm);
|
||||
@@ -443,8 +472,8 @@ export async function handleMetaCommand(
|
||||
|
||||
for (const msg of messages) {
|
||||
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
|
||||
lines.push(`${ts} ${msg.url}`);
|
||||
lines.push(` "${msg.userMessage}"`);
|
||||
lines.push(`${ts} ${wrapUntrustedContent(msg.url, 'inbox-url')}`);
|
||||
lines.push(` "${wrapUntrustedContent(msg.userMessage, 'inbox-message')}"`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
@@ -495,6 +524,18 @@ export async function handleMetaCommand(
|
||||
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
|
||||
throw new Error('Invalid state file: expected cookies and pages arrays');
|
||||
}
|
||||
// Validate and filter cookies — reject malformed or internal-network cookies
|
||||
const validatedCookies = data.cookies.filter((c: any) => {
|
||||
if (typeof c !== 'object' || !c) return false;
|
||||
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
|
||||
if (typeof c.domain !== 'string' || !c.domain) return false;
|
||||
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
|
||||
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
|
||||
return true;
|
||||
});
|
||||
if (validatedCookies.length < data.cookies.length) {
|
||||
console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
|
||||
}
|
||||
// Warn on state files older than 7 days
|
||||
if (data.savedAt) {
|
||||
const ageMs = Date.now() - new Date(data.savedAt).getTime();
|
||||
@@ -507,7 +548,7 @@ export async function handleMetaCommand(
|
||||
bm.setFrame(null);
|
||||
await bm.closeAllPages();
|
||||
await bm.restoreState({
|
||||
cookies: data.cookies,
|
||||
cookies: validatedCookies,
|
||||
pages: data.pages.map((p: any) => ({ ...p, storage: null })),
|
||||
});
|
||||
return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`;
|
||||
@@ -535,7 +576,7 @@ export async function handleMetaCommand(
|
||||
frame = page.frame({ name: args[1] });
|
||||
} else if (target === '--url') {
|
||||
if (!args[1]) throw new Error('Usage: frame --url <pattern>');
|
||||
frame = page.frame({ url: new RegExp(args[1]) });
|
||||
frame = page.frame({ url: new RegExp(escapeRegExp(args[1])) });
|
||||
} else {
|
||||
// CSS selector or @ref for the iframe element
|
||||
const resolved = await bm.resolveRef(target);
|
||||
|
||||
Reference in New Issue
Block a user