Skip to content

Commit 03973c2

Browse files
garrytangaragonclaude0531Kimpieterklue
authored
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>
1 parent b3d064a commit 03973c2

30 files changed

+4066
-108
lines changed

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
# Changelog
22

3+
## [0.15.15.0] - 2026-04-06
4+
5+
Community security wave: 8 PRs from 4 contributors, every fix credited as co-author.
6+
7+
### Added
8+
- Cookie value redaction for tokens, API keys, JWTs, and session secrets in `browse cookies` output. Your secrets no longer appear in Claude's context.
9+
- IPv6 ULA prefix blocking (fc00::/7) in URL validation. Covers the full unique-local range, not just the literal `fd00::`. Hostnames like `fcustomer.com` are not false-positived.
10+
- Per-tab cancel signaling for sidebar agents. Stopping one tab's agent no longer kills all tabs.
11+
- Parent process watchdog for the browse server. When Claude Code exits, orphaned browser processes now self-terminate within 15 seconds.
12+
- Uninstall instructions in README (script + manual removal steps).
13+
- CSS value validation blocks `url()`, `expression()`, `@import`, `javascript:`, and `data:` in style commands, preventing CSS injection attacks.
14+
- Queue entry schema validation (`isValidQueueEntry`) with path traversal checks on `stateFile` and `cwd`.
15+
- Viewport dimension clamping (1-16384) and wait timeout clamping (1s-300s) prevent OOM and runaway waits.
16+
- Cookie domain validation in `cookie-import` prevents cross-site cookie injection.
17+
- DocumentFragment-based tab switching in sidebar (replaces innerHTML round-trip XSS vector).
18+
- `pollInProgress` reentrancy guard prevents concurrent chat polls from corrupting state.
19+
- 750+ lines of new security regression tests across 4 test files.
20+
- Supabase migration 003: column-level GRANT restricts anon UPDATE to (last_seen, gstack_version, os) only.
21+
22+
### Fixed
23+
- Windows: `extraEnv` now passes through to the Windows launcher (was silently dropped).
24+
- Windows: welcome page serves inline HTML instead of `about:blank` redirect (fixes ERR_UNSAFE_REDIRECT).
25+
- Headed mode: auth token returned even without Origin header (fixes Playwright Chromium extensions).
26+
- `frame --url` now escapes user input before constructing RegExp (ReDoS fix).
27+
- Annotated screenshot path validation now resolves symlinks (was bypassable via symlink traversal).
28+
- Auth token removed from health broadcast, delivered via targeted `getToken` handler instead.
29+
- `/health` endpoint no longer exposes `currentUrl` or `currentMessage`.
30+
- Session ID validated before use in file paths (prevents path traversal via crafted active.json).
31+
- SIGTERM/SIGKILL escalation in sidebar agent timeout handler (was bare `kill()`).
32+
33+
### For contributors
34+
- Queue files created with 0o700/0o600 permissions (server, CLI, sidebar-agent).
35+
- `escapeRegExp` utility exported from meta-commands.
36+
- State load filters cookies from localhost, .internal, and metadata domains.
37+
- Telemetry sync logs upsert errors from installation tracking.
38+
339
## [0.15.14.0] - 2026-04-05
440

541
### Fixed

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,59 @@ gstack skills have voice-friendly trigger phrases. Say what you want naturally
277277
"run a security check", "test the website", "do an engineering review" — and the
278278
right skill activates. You don't need to remember slash command names or acronyms.
279279

280+
## Uninstall
281+
282+
### Option 1: Run the uninstall script
283+
284+
If gstack is installed on your machine:
285+
286+
```bash
287+
~/.claude/skills/gstack/bin/gstack-uninstall
288+
```
289+
290+
This handles skills, symlinks, global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics. Use `--force` to skip confirmation.
291+
292+
### Option 2: Manual removal (no local repo)
293+
294+
If you don't have the repo cloned (e.g. you installed via a Claude Code paste and later deleted the clone):
295+
296+
```bash
297+
# 1. Stop browse daemons
298+
pkill -f "gstack.*browse" 2>/dev/null || true
299+
300+
# 2. Remove per-skill symlinks pointing into gstack/
301+
find ~/.claude/skills -maxdepth 1 -type l 2>/dev/null | while read -r link; do
302+
case "$(readlink "$link" 2>/dev/null)" in gstack/*|*/gstack/*) rm -f "$link" ;; esac
303+
done
304+
305+
# 3. Remove gstack
306+
rm -rf ~/.claude/skills/gstack
307+
308+
# 4. Remove global state
309+
rm -rf ~/.gstack
310+
311+
# 5. Remove integrations (skip any you never installed)
312+
rm -rf ~/.codex/skills/gstack* 2>/dev/null
313+
rm -rf ~/.factory/skills/gstack* 2>/dev/null
314+
rm -rf ~/.kiro/skills/gstack* 2>/dev/null
315+
rm -rf ~/.openclaw/skills/gstack* 2>/dev/null
316+
317+
# 6. Remove temp files
318+
rm -f /tmp/gstack-* 2>/dev/null
319+
320+
# 7. Per-project cleanup (run from each project root)
321+
rm -rf .gstack .gstack-worktrees .claude/skills/gstack 2>/dev/null
322+
rm -rf .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null
323+
```
324+
325+
### Clean up CLAUDE.md
326+
327+
The uninstall script does not edit CLAUDE.md. In each project where gstack was added, remove the `## gstack` and `## Skill routing` sections.
328+
329+
### Playwright
330+
331+
`~/Library/Caches/ms-playwright/` (macOS) is left in place because other tools may share it. Remove it if nothing else needs it.
332+
280333
---
281334

282335
Free, MIT licensed, open source. No premium tier, no waitlist.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.15.14.0
1+
0.15.15.0

bin/gstack-learnings-search

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@ if [ ${#FILES[@]} -eq 0 ]; then
4343
fi
4444

4545
# Process all files through bun for JSON parsing, decay, dedup, filtering
46-
cat "${FILES[@]}" 2>/dev/null | bun -e "
46+
GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" \
47+
cat "${FILES[@]}" 2>/dev/null | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun -e "
4748
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
4849
const now = Date.now();
49-
const type = '${TYPE}';
50-
const query = '${QUERY}'.toLowerCase();
51-
const limit = ${LIMIT};
52-
const slug = '${SLUG}';
50+
const type = process.env.GSTACK_SEARCH_TYPE || '';
51+
const query = (process.env.GSTACK_SEARCH_QUERY || '').toLowerCase();
52+
const limit = parseInt(process.env.GSTACK_SEARCH_LIMIT || '10', 10);
53+
const slug = process.env.GSTACK_SEARCH_SLUG || '';
5354
5455
const entries = [];
5556
for (const line of lines) {
@@ -67,7 +68,7 @@ for (const line of lines) {
6768
6869
// Determine if this is from the current project or cross-project
6970
// Cross-project entries are tagged for display
70-
e._crossProject = !line.includes(slug) && '${CROSS_PROJECT}' === 'true';
71+
e._crossProject = !line.includes(slug) && process.env.GSTACK_SEARCH_CROSS === 'true';
7172
7273
entries.push(e);
7374
} catch {}

bin/gstack-telemetry-sync

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ case "$HTTP_CODE" in
122122
# Advance by SENT count (not inserted count) because we can't map inserted back to
123123
# source lines. If inserted==0, something is systemically wrong — don't advance.
124124
INSERTED="$(grep -o '"inserted":[0-9]*' "$RESP_FILE" 2>/dev/null | grep -o '[0-9]*' || echo "0")"
125+
# Check for upsert errors (installation tracking failures) — log but don't block cursor advance
126+
UPSERT_ERRORS="$(grep -o '"upsertErrors"' "$RESP_FILE" 2>/dev/null || true)"
127+
if [ -n "$UPSERT_ERRORS" ]; then
128+
echo "[gstack-telemetry-sync] Warning: installation upsert errors in response" >&2
129+
fi
125130
if [ "${INSERTED:-0}" -gt 0 ] 2>/dev/null; then
126131
NEW_CURSOR=$(( CURSOR + COUNT ))
127132
echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true

browse/src/browser-manager.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -826,11 +826,11 @@ export class BrowserManager {
826826
// a tampered URL could navigate to cloud metadata endpoints or file:// URIs.
827827
try {
828828
await validateNavigationUrl(saved.url);
829-
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
830-
} catch {
831-
// Invalid URL in saved state — skip navigation, leave blank page
832-
console.log(`[browse] restoreState: skipping unsafe URL: ${saved.url}`);
829+
} catch (err: any) {
830+
console.warn(`[browse] Skipping invalid URL in state file: ${saved.url}${err.message}`);
831+
continue;
833832
}
833+
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
834834
}
835835

836836
if (saved.storage) {

browse/src/cdp-inspector.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,12 @@ export async function modifyStyle(
472472
throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`);
473473
}
474474

475+
// Validate CSS value — block data exfiltration patterns
476+
const DANGEROUS_CSS = /url\s*\(|expression\s*\(|@import|javascript:|data:/i;
477+
if (DANGEROUS_CSS.test(value)) {
478+
throw new Error('CSS value rejected: contains potentially dangerous pattern.');
479+
}
480+
475481
let oldValue = '';
476482
let source = 'inline';
477483
let sourceLine = 0;

browse/src/cli.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,17 +232,18 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
232232
// when the CLI exits, the server dies with it. Use Node's child_process.spawn
233233
// with { detached: true } instead, which is the gold standard for Windows
234234
// process independence. Credit: PR #191 by @fqueiro.
235+
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...(extraEnv || {}) });
235236
const launcherCode =
236237
`const{spawn}=require('child_process');` +
237238
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
238239
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
239-
`{BROWSE_STATE_FILE:${JSON.stringify(config.stateFile)}})}).unref()`;
240+
`${extraEnvStr})}).unref()`;
240241
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
241242
} else {
242243
// macOS/Linux: Bun.spawn + unref works correctly
243244
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
244245
stdio: ['ignore', 'pipe', 'pipe'],
245-
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv },
246+
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...extraEnv },
246247
});
247248
proc.unref();
248249
}
@@ -587,7 +588,10 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
587588
}
588589
// Clear old agent queue
589590
const agentQueue = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
590-
try { fs.writeFileSync(agentQueue, ''); } catch {}
591+
try {
592+
fs.mkdirSync(path.dirname(agentQueue), { recursive: true, mode: 0o700 });
593+
fs.writeFileSync(agentQueue, '', { mode: 0o600 });
594+
} catch {}
591595

592596
// Resolve browse binary path the same way — execPath-relative
593597
let browseBin = path.resolve(__dirname, '..', 'dist', 'browse');

browse/src/meta-commands.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,40 @@ import { resolveConfig } from './config';
1515
import type { Frame } from 'playwright';
1616

1717
// Security: Path validation to prevent path traversal attacks
18-
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
18+
// Resolve safe directories through realpathSync to handle symlinks (e.g., macOS /tmp → /private/tmp)
19+
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()].map(d => {
20+
try { return fs.realpathSync(d); } catch { return d; }
21+
});
1922

2023
export function validateOutputPath(filePath: string): void {
2124
const resolved = path.resolve(filePath);
22-
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
25+
26+
// Resolve real path of the parent directory to catch symlinks.
27+
// The file itself may not exist yet (e.g., screenshot output).
28+
let dir = path.dirname(resolved);
29+
let realDir: string;
30+
try {
31+
realDir = fs.realpathSync(dir);
32+
} catch {
33+
try {
34+
realDir = fs.realpathSync(path.dirname(dir));
35+
} catch {
36+
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
37+
}
38+
}
39+
40+
const realResolved = path.join(realDir, path.basename(resolved));
41+
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realResolved, dir));
2342
if (!isSafe) {
2443
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
2544
}
2645
}
2746

47+
/** Escape special regex metacharacters in a user-supplied string to prevent ReDoS. */
48+
export function escapeRegExp(s: string): string {
49+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50+
}
51+
2852
/** Tokenize a pipe segment respecting double-quoted strings. */
2953
function tokenizePipeSegment(segment: string): string[] {
3054
const tokens: string[] = [];
@@ -195,9 +219,10 @@ export async function handleMetaCommand(
195219

196220
for (const vp of viewports) {
197221
await page.setViewportSize({ width: vp.width, height: vp.height });
198-
const path = `${prefix}-${vp.name}.png`;
199-
await page.screenshot({ path, fullPage: true });
200-
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
222+
const screenshotPath = `${prefix}-${vp.name}.png`;
223+
validateOutputPath(screenshotPath);
224+
await page.screenshot({ path: screenshotPath, fullPage: true });
225+
results.push(`${vp.name} (${vp.width}x${vp.height}): ${screenshotPath}`);
201226
}
202227

203228
// Restore original viewport
@@ -238,7 +263,11 @@ export async function handleMetaCommand(
238263
try {
239264
let result: string;
240265
if (WRITE_COMMANDS.has(name)) {
241-
result = await handleWriteCommand(name, cmdArgs, bm);
266+
if (bm.isWatching()) {
267+
result = 'BLOCKED: write commands disabled in watch mode';
268+
} else {
269+
result = await handleWriteCommand(name, cmdArgs, bm);
270+
}
242271
lastWasWrite = true;
243272
} else if (READ_COMMANDS.has(name)) {
244273
result = await handleReadCommand(name, cmdArgs, bm);
@@ -443,8 +472,8 @@ export async function handleMetaCommand(
443472

444473
for (const msg of messages) {
445474
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
446-
lines.push(`${ts} ${msg.url}`);
447-
lines.push(` "${msg.userMessage}"`);
475+
lines.push(`${ts} ${wrapUntrustedContent(msg.url, 'inbox-url')}`);
476+
lines.push(` "${wrapUntrustedContent(msg.userMessage, 'inbox-message')}"`);
448477
lines.push('');
449478
}
450479

@@ -495,6 +524,18 @@ export async function handleMetaCommand(
495524
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
496525
throw new Error('Invalid state file: expected cookies and pages arrays');
497526
}
527+
// Validate and filter cookies — reject malformed or internal-network cookies
528+
const validatedCookies = data.cookies.filter((c: any) => {
529+
if (typeof c !== 'object' || !c) return false;
530+
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
531+
if (typeof c.domain !== 'string' || !c.domain) return false;
532+
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
533+
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
534+
return true;
535+
});
536+
if (validatedCookies.length < data.cookies.length) {
537+
console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
538+
}
498539
// Warn on state files older than 7 days
499540
if (data.savedAt) {
500541
const ageMs = Date.now() - new Date(data.savedAt).getTime();
@@ -507,7 +548,7 @@ export async function handleMetaCommand(
507548
bm.setFrame(null);
508549
await bm.closeAllPages();
509550
await bm.restoreState({
510-
cookies: data.cookies,
551+
cookies: validatedCookies,
511552
pages: data.pages.map((p: any) => ({ ...p, storage: null })),
512553
});
513554
return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`;
@@ -535,7 +576,7 @@ export async function handleMetaCommand(
535576
frame = page.frame({ name: args[1] });
536577
} else if (target === '--url') {
537578
if (!args[1]) throw new Error('Usage: frame --url <pattern>');
538-
frame = page.frame({ url: new RegExp(args[1]) });
579+
frame = page.frame({ url: new RegExp(escapeRegExp(args[1])) });
539580
} else {
540581
// CSS selector or @ref for the iframe element
541582
const resolved = await bm.resolveRef(target);

browse/src/read-commands.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import * as path from 'path';
1313
import { TEMP_DIR, isPathWithin } from './platform';
1414
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
1515

16+
// Redaction patterns for sensitive cookie/storage values — exported for test coverage
17+
export const SENSITIVE_COOKIE_NAME = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i;
18+
export const SENSITIVE_COOKIE_VALUE = /^(eyJ|sk-|sk_live_|sk_test_|pk_live_|pk_test_|rk_live_|sk-ant-|ghp_|gho_|github_pat_|xox[bpsa]-|AKIA[A-Z0-9]{16}|AIza|SG\.|Bearer\s|sbp_)/;
19+
1620
/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
1721
function hasAwait(code: string): boolean {
1822
const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
@@ -300,7 +304,14 @@ export async function handleReadCommand(
300304

301305
case 'cookies': {
302306
const cookies = await page.context().cookies();
303-
return JSON.stringify(cookies, null, 2);
307+
// Redact cookie values that look like secrets (consistent with storage redaction)
308+
const redacted = cookies.map(c => {
309+
if (SENSITIVE_COOKIE_NAME.test(c.name) || SENSITIVE_COOKIE_VALUE.test(c.value)) {
310+
return { ...c, value: `[REDACTED — ${c.value.length} chars]` };
311+
}
312+
return c;
313+
});
314+
return JSON.stringify(redacted, null, 2);
304315
}
305316

306317
case 'storage': {

0 commit comments

Comments
 (0)