mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 22:35:36 +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:
@@ -826,11 +826,11 @@ export class BrowserManager {
|
||||
// a tampered URL could navigate to cloud metadata endpoints or file:// URIs.
|
||||
try {
|
||||
await validateNavigationUrl(saved.url);
|
||||
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
||||
} catch {
|
||||
// Invalid URL in saved state — skip navigation, leave blank page
|
||||
console.log(`[browse] restoreState: skipping unsafe URL: ${saved.url}`);
|
||||
} catch (err: any) {
|
||||
console.warn(`[browse] Skipping invalid URL in state file: ${saved.url} — ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
||||
}
|
||||
|
||||
if (saved.storage) {
|
||||
|
||||
@@ -472,6 +472,12 @@ export async function modifyStyle(
|
||||
throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`);
|
||||
}
|
||||
|
||||
// Validate CSS value — block data exfiltration patterns
|
||||
const DANGEROUS_CSS = /url\s*\(|expression\s*\(|@import|javascript:|data:/i;
|
||||
if (DANGEROUS_CSS.test(value)) {
|
||||
throw new Error('CSS value rejected: contains potentially dangerous pattern.');
|
||||
}
|
||||
|
||||
let oldValue = '';
|
||||
let source = 'inline';
|
||||
let sourceLine = 0;
|
||||
|
||||
+7
-3
@@ -232,17 +232,18 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
// when the CLI exits, the server dies with it. Use Node's child_process.spawn
|
||||
// with { detached: true } instead, which is the gold standard for Windows
|
||||
// process independence. Credit: PR #191 by @fqueiro.
|
||||
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...(extraEnv || {}) });
|
||||
const launcherCode =
|
||||
`const{spawn}=require('child_process');` +
|
||||
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
||||
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
|
||||
`{BROWSE_STATE_FILE:${JSON.stringify(config.stateFile)}})}).unref()`;
|
||||
`${extraEnvStr})}).unref()`;
|
||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
} else {
|
||||
// macOS/Linux: Bun.spawn + unref works correctly
|
||||
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, ...extraEnv },
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...extraEnv },
|
||||
});
|
||||
proc.unref();
|
||||
}
|
||||
@@ -587,7 +588,10 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
}
|
||||
// Clear old agent queue
|
||||
const agentQueue = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
|
||||
try { fs.writeFileSync(agentQueue, ''); } catch {}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(agentQueue), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(agentQueue, '', { mode: 0o600 });
|
||||
} catch {}
|
||||
|
||||
// Resolve browse binary path the same way — execPath-relative
|
||||
let browseBin = path.resolve(__dirname, '..', 'dist', 'browse');
|
||||
|
||||
+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);
|
||||
|
||||
@@ -13,6 +13,10 @@ import * as path from 'path';
|
||||
import { TEMP_DIR, isPathWithin } from './platform';
|
||||
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
|
||||
|
||||
// Redaction patterns for sensitive cookie/storage values — exported for test coverage
|
||||
export const SENSITIVE_COOKIE_NAME = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i;
|
||||
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_)/;
|
||||
|
||||
/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
|
||||
function hasAwait(code: string): boolean {
|
||||
const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
@@ -300,7 +304,14 @@ export async function handleReadCommand(
|
||||
|
||||
case 'cookies': {
|
||||
const cookies = await page.context().cookies();
|
||||
return JSON.stringify(cookies, null, 2);
|
||||
// Redact cookie values that look like secrets (consistent with storage redaction)
|
||||
const redacted = cookies.map(c => {
|
||||
if (SENSITIVE_COOKIE_NAME.test(c.name) || SENSITIVE_COOKIE_VALUE.test(c.value)) {
|
||||
return { ...c, value: `[REDACTED — ${c.value.length} chars]` };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
return JSON.stringify(redacted, null, 2);
|
||||
}
|
||||
|
||||
case 'storage': {
|
||||
|
||||
+62
-27
@@ -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' },
|
||||
|
||||
@@ -20,12 +20,50 @@ const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`;
|
||||
const POLL_MS = 200; // 200ms poll — keeps time-to-first-token low
|
||||
const B = process.env.BROWSE_BIN || path.resolve(__dirname, '../../.claude/skills/gstack/browse/dist/browse');
|
||||
|
||||
const CANCEL_DIR = path.join(process.env.HOME || '/tmp', '.gstack');
|
||||
function cancelFileForTab(tabId: number): string {
|
||||
return path.join(CANCEL_DIR, `sidebar-agent-cancel-${tabId}`);
|
||||
}
|
||||
|
||||
interface QueueEntry {
|
||||
prompt: string;
|
||||
args?: string[];
|
||||
stateFile?: string;
|
||||
cwd?: string;
|
||||
tabId?: number | null;
|
||||
message?: string | null;
|
||||
pageUrl?: string | null;
|
||||
sessionId?: string | null;
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
function isValidQueueEntry(e: unknown): e is QueueEntry {
|
||||
if (typeof e !== 'object' || e === null) return false;
|
||||
const obj = e as Record<string, unknown>;
|
||||
if (typeof obj.prompt !== 'string' || obj.prompt.length === 0) return false;
|
||||
if (obj.args !== undefined && (!Array.isArray(obj.args) || !obj.args.every(a => typeof a === 'string'))) return false;
|
||||
if (obj.stateFile !== undefined) {
|
||||
if (typeof obj.stateFile !== 'string') return false;
|
||||
if (obj.stateFile.includes('..')) return false;
|
||||
}
|
||||
if (obj.cwd !== undefined) {
|
||||
if (typeof obj.cwd !== 'string') return false;
|
||||
if (obj.cwd.includes('..')) return false;
|
||||
}
|
||||
if (obj.tabId !== undefined && obj.tabId !== null && typeof obj.tabId !== 'number') return false;
|
||||
if (obj.message !== undefined && obj.message !== null && typeof obj.message !== 'string') return false;
|
||||
if (obj.pageUrl !== undefined && obj.pageUrl !== null && typeof obj.pageUrl !== 'string') return false;
|
||||
if (obj.sessionId !== undefined && obj.sessionId !== null && typeof obj.sessionId !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
let lastLine = 0;
|
||||
let authToken: string | null = null;
|
||||
// Per-tab processing — each tab can run its own agent concurrently
|
||||
const processingTabs = new Set<number>();
|
||||
// Active claude subprocesses — keyed by tabId for targeted kill
|
||||
const activeProcs = new Map<number, ReturnType<typeof spawn>>();
|
||||
let activeProc: ReturnType<typeof spawn> | null = null;
|
||||
// Kill-file timestamp last seen — avoids double-kill on same write
|
||||
let lastKillTs = 0;
|
||||
|
||||
@@ -228,7 +266,7 @@ async function handleStreamEvent(event: any, tabId?: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function askClaude(queueEntry: any): Promise<void> {
|
||||
async function askClaude(queueEntry: QueueEntry): Promise<void> {
|
||||
const { prompt, args, stateFile, cwd, tabId } = queueEntry;
|
||||
const tid = tabId ?? 0;
|
||||
|
||||
@@ -250,6 +288,10 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
effectiveCwd = process.cwd();
|
||||
}
|
||||
|
||||
// Clear any stale cancel signal for this tab before starting
|
||||
const cancelFile = cancelFileForTab(tid);
|
||||
try { fs.unlinkSync(cancelFile); } catch {}
|
||||
|
||||
const proc = spawn('claude', claudeArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: effectiveCwd,
|
||||
@@ -270,9 +312,23 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
|
||||
// Track active procs so kill-file polling can terminate them
|
||||
activeProcs.set(tid, proc);
|
||||
activeProc = proc;
|
||||
|
||||
proc.stdin.end();
|
||||
|
||||
// Poll for per-tab cancel signal from server's killAgent()
|
||||
const cancelCheck = setInterval(() => {
|
||||
try {
|
||||
if (fs.existsSync(cancelFile)) {
|
||||
console.log(`[sidebar-agent] Cancel signal received for tab ${tid} — killing claude subprocess`);
|
||||
try { proc.kill('SIGTERM'); } catch {}
|
||||
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000);
|
||||
fs.unlinkSync(cancelFile);
|
||||
clearInterval(cancelCheck);
|
||||
}
|
||||
} catch {}
|
||||
}, 500);
|
||||
|
||||
let buffer = '';
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
@@ -293,6 +349,8 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
clearInterval(cancelCheck);
|
||||
activeProc = null;
|
||||
activeProcs.delete(tid);
|
||||
if (buffer.trim()) {
|
||||
try { handleStreamEvent(JSON.parse(buffer), tid); } catch (err: any) {
|
||||
@@ -310,6 +368,8 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
clearInterval(cancelCheck);
|
||||
activeProc = null;
|
||||
const errorMsg = stderrBuffer.trim()
|
||||
? `${err.message}\nstderr: ${stderrBuffer.trim().slice(-500)}`
|
||||
: err.message;
|
||||
@@ -322,9 +382,10 @@ async function askClaude(queueEntry: any): Promise<void> {
|
||||
// Timeout (default 300s / 5 min — multi-page tasks need time)
|
||||
const timeoutMs = parseInt(process.env.SIDEBAR_AGENT_TIMEOUT || '300000', 10);
|
||||
setTimeout(() => {
|
||||
try { proc.kill(); } catch (killErr: any) {
|
||||
try { proc.kill('SIGTERM'); } catch (killErr: any) {
|
||||
console.warn(`[sidebar-agent] Tab ${tid}: Failed to kill timed-out process:`, killErr.message);
|
||||
}
|
||||
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000);
|
||||
const timeoutMsg = stderrBuffer.trim()
|
||||
? `Timed out after ${timeoutMs / 1000}s\nstderr: ${stderrBuffer.trim().slice(-500)}`
|
||||
: `Timed out after ${timeoutMs / 1000}s`;
|
||||
@@ -366,12 +427,16 @@ async function poll() {
|
||||
const line = readLine(lastLine);
|
||||
if (!line) continue;
|
||||
|
||||
let entry: any;
|
||||
try { entry = JSON.parse(line); } catch (err: any) {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(line); } catch (err: any) {
|
||||
console.warn(`[sidebar-agent] Skipping malformed queue entry at line ${lastLine}:`, line.slice(0, 80), err.message);
|
||||
continue;
|
||||
}
|
||||
if (!entry.message && !entry.prompt) continue;
|
||||
if (!isValidQueueEntry(parsed)) {
|
||||
console.warn(`[sidebar-agent] Skipping invalid queue entry at line ${lastLine}: failed schema validation`);
|
||||
continue;
|
||||
}
|
||||
const entry = parsed;
|
||||
|
||||
const tid = entry.tabId ?? 0;
|
||||
// Skip if this tab already has an agent running — server queues per-tab
|
||||
@@ -415,6 +480,7 @@ async function main() {
|
||||
const dir = path.dirname(QUEUE);
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '', { mode: 0o600 });
|
||||
try { fs.chmodSync(QUEUE, 0o600); } catch {}
|
||||
|
||||
lastLine = countLines();
|
||||
await refreshToken();
|
||||
|
||||
+26
-5
@@ -348,11 +348,32 @@ export async function handleSnapshot(
|
||||
// ─── Annotated screenshot (-a) ────────────────────────────
|
||||
if (opts.annotate) {
|
||||
const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`;
|
||||
// Validate output path (consistent with screenshot/pdf/responsive)
|
||||
const resolvedPath = require('path').resolve(screenshotPath);
|
||||
const safeDirs = [TEMP_DIR, process.cwd()];
|
||||
if (!safeDirs.some((dir: string) => isPathWithin(resolvedPath, dir))) {
|
||||
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
||||
// Validate output path — resolve symlinks to prevent symlink traversal attacks
|
||||
{
|
||||
const nodePath = require('path') as typeof import('path');
|
||||
const nodeFs = require('fs') as typeof import('fs');
|
||||
const absolute = nodePath.resolve(screenshotPath);
|
||||
const safeDirs = [TEMP_DIR, process.cwd()].map((d: string) => {
|
||||
try { return nodeFs.realpathSync(d); } catch { return d; }
|
||||
});
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = nodeFs.realpathSync(absolute);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
try {
|
||||
const dir = nodeFs.realpathSync(nodePath.dirname(absolute));
|
||||
realPath = nodePath.join(dir, nodePath.basename(absolute));
|
||||
} catch {
|
||||
realPath = absolute;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Cannot resolve real path: ${screenshotPath} (${err.code})`);
|
||||
}
|
||||
}
|
||||
if (!safeDirs.some((dir: string) => isPathWithin(realPath, dir))) {
|
||||
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Inject overlay divs at each ref's bounding box
|
||||
|
||||
@@ -3,15 +3,34 @@
|
||||
* Localhost and private IPs are allowed (primary use case: QA testing local dev servers).
|
||||
*/
|
||||
|
||||
const BLOCKED_METADATA_HOSTS = new Set([
|
||||
'169.254.169.254', // AWS/GCP/Azure instance metadata (IPv4 link-local)
|
||||
export const BLOCKED_METADATA_HOSTS = new Set([
|
||||
'169.254.169.254', // AWS/GCP/Azure instance metadata
|
||||
'fe80::1', // IPv6 link-local — common metadata endpoint alias
|
||||
'fd00::', // IPv6 unique local (metadata in some cloud setups)
|
||||
'::ffff:169.254.169.254', // IPv4-mapped IPv6 form of the metadata IP
|
||||
'metadata.google.internal', // GCP metadata
|
||||
'metadata.azure.internal', // Azure IMDS
|
||||
]);
|
||||
|
||||
/**
|
||||
* IPv6 prefixes to block (CIDR-style). Any address starting with these
|
||||
* hex prefixes is rejected. Covers the full ULA range (fc00::/7 = fc00:: and fd00::).
|
||||
*/
|
||||
const BLOCKED_IPV6_PREFIXES = ['fc', 'fd'];
|
||||
|
||||
/**
|
||||
* Check if an IPv6 address falls within a blocked prefix range.
|
||||
* Handles the full ULA range (fc00::/7), not just the exact literal fd00::.
|
||||
* Only matches actual IPv6 addresses (must contain ':'), not hostnames
|
||||
* like fd.example.com or fcustomer.com.
|
||||
*/
|
||||
function isBlockedIpv6(addr: string): boolean {
|
||||
const normalized = addr.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
// Must contain a colon to be an IPv6 address — avoids false positives on
|
||||
// hostnames like fd.example.com or fcustomer.com
|
||||
if (!normalized.includes(':')) return false;
|
||||
return BLOCKED_IPV6_PREFIXES.some(prefix => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize hostname for blocklist comparison:
|
||||
* - Strip trailing dot (DNS fully-qualified notation)
|
||||
@@ -37,7 +56,7 @@ function isMetadataIp(hostname: string): boolean {
|
||||
try {
|
||||
const probe = new URL(`http://${hostname}`);
|
||||
const normalized = probe.hostname;
|
||||
if (BLOCKED_METADATA_HOSTS.has(normalized)) return true;
|
||||
if (BLOCKED_METADATA_HOSTS.has(normalized) || isBlockedIpv6(normalized)) return true;
|
||||
// Also check after stripping trailing dot
|
||||
if (normalized.endsWith('.') && BLOCKED_METADATA_HOSTS.has(normalized.slice(0, -1))) return true;
|
||||
} catch {
|
||||
@@ -69,7 +88,7 @@ async function resolvesToBlockedIp(hostname: string): Promise<boolean> {
|
||||
const v6Check = resolve6(hostname).then(
|
||||
(addresses) => addresses.some(addr => {
|
||||
const normalized = addr.toLowerCase();
|
||||
return BLOCKED_METADATA_HOSTS.has(normalized) ||
|
||||
return BLOCKED_METADATA_HOSTS.has(normalized) || isBlockedIpv6(normalized) ||
|
||||
// fe80::/10 is link-local — always block (covers all fe80:: addresses)
|
||||
normalized.startsWith('fe80:');
|
||||
}),
|
||||
@@ -100,7 +119,7 @@ export async function validateNavigationUrl(url: string): Promise<void> {
|
||||
|
||||
const hostname = normalizeHostname(parsed.hostname.toLowerCase());
|
||||
|
||||
if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname)) {
|
||||
if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname) || isBlockedIpv6(hostname)) {
|
||||
throw new Error(
|
||||
`Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.`
|
||||
);
|
||||
|
||||
@@ -14,7 +14,10 @@ import { TEMP_DIR, isPathWithin } from './platform';
|
||||
import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector';
|
||||
|
||||
// Security: Path validation for screenshot output
|
||||
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; }
|
||||
});
|
||||
|
||||
function validateOutputPath(filePath: string): void {
|
||||
const resolved = path.resolve(filePath);
|
||||
@@ -326,7 +329,9 @@ export async function handleWriteCommand(
|
||||
const selector = args[0];
|
||||
if (!selector) throw new Error('Usage: browse wait <selector|--networkidle|--load|--domcontentloaded>');
|
||||
if (selector === '--networkidle') {
|
||||
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
||||
const MAX_WAIT_MS = 300_000;
|
||||
const MIN_WAIT_MS = 1_000;
|
||||
const timeout = Math.min(Math.max(args[1] ? parseInt(args[1], 10) || MIN_WAIT_MS : 15000, MIN_WAIT_MS), MAX_WAIT_MS);
|
||||
await page.waitForLoadState('networkidle', { timeout });
|
||||
return 'Network idle';
|
||||
}
|
||||
@@ -338,7 +343,9 @@ export async function handleWriteCommand(
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
return 'DOM content loaded';
|
||||
}
|
||||
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
||||
const MAX_WAIT_MS = 300_000;
|
||||
const MIN_WAIT_MS = 1_000;
|
||||
const timeout = Math.min(Math.max(args[1] ? parseInt(args[1], 10) || MIN_WAIT_MS : 15000, MIN_WAIT_MS), MAX_WAIT_MS);
|
||||
const resolved = await bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.waitFor({ state: 'visible', timeout });
|
||||
@@ -351,7 +358,9 @@ export async function handleWriteCommand(
|
||||
case 'viewport': {
|
||||
const size = args[0];
|
||||
if (!size || !size.includes('x')) throw new Error('Usage: browse viewport <WxH> (e.g., 375x812)');
|
||||
const [w, h] = size.split('x').map(Number);
|
||||
const [rawW, rawH] = size.split('x').map(Number);
|
||||
const w = Math.min(Math.max(Math.round(rawW) || 1280, 1), 16384);
|
||||
const h = Math.min(Math.max(Math.round(rawH) || 720, 1), 16384);
|
||||
await bm.setViewport(w, h);
|
||||
return `Viewport set to ${w}x${h}`;
|
||||
}
|
||||
@@ -399,9 +408,19 @@ export async function handleWriteCommand(
|
||||
const [selector, ...filePaths] = args;
|
||||
if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2...]');
|
||||
|
||||
// Validate all files exist before upload
|
||||
// Validate paths are within safe directories (same check as cookie-import)
|
||||
for (const fp of filePaths) {
|
||||
if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
|
||||
if (path.isAbsolute(fp)) {
|
||||
let resolvedFp: string;
|
||||
try { resolvedFp = fs.realpathSync(path.resolve(fp)); } catch { resolvedFp = path.resolve(fp); }
|
||||
if (!SAFE_DIRECTORIES.some(dir => isPathWithin(resolvedFp, dir))) {
|
||||
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
if (path.normalize(fp).includes('..')) {
|
||||
throw new Error('Path traversal sequences (..) are not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = await bm.resolveRef(selector);
|
||||
@@ -459,7 +478,14 @@ export async function handleWriteCommand(
|
||||
|
||||
for (const c of cookies) {
|
||||
if (!c.name || c.value === undefined) throw new Error('Each cookie must have "name" and "value" fields');
|
||||
if (!c.domain) c.domain = defaultDomain;
|
||||
if (!c.domain) {
|
||||
c.domain = defaultDomain;
|
||||
} else {
|
||||
const cookieDomain = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
|
||||
if (cookieDomain !== defaultDomain && !defaultDomain.endsWith('.' + cookieDomain)) {
|
||||
throw new Error(`Cookie domain "${c.domain}" does not match current page domain "${defaultDomain}". Use the target site first.`);
|
||||
}
|
||||
}
|
||||
if (!c.path) c.path = '/';
|
||||
}
|
||||
|
||||
@@ -479,6 +505,12 @@ export async function handleWriteCommand(
|
||||
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
||||
// Direct import mode — no UI
|
||||
const domain = args[domainIdx + 1];
|
||||
// Validate --domain against current page hostname to prevent cross-site cookie injection
|
||||
const pageHostname = new URL(page.url()).hostname;
|
||||
const normalizedDomain = domain.startsWith('.') ? domain.slice(1) : domain;
|
||||
if (normalizedDomain !== pageHostname && !pageHostname.endsWith('.' + normalizedDomain)) {
|
||||
throw new Error(`--domain "${domain}" does not match current page domain "${pageHostname}". Navigate to the target site first.`);
|
||||
}
|
||||
const browser = browserArg || 'comet';
|
||||
const result = await importCookies(browser, [domain], profile);
|
||||
if (result.cookies.length > 0) {
|
||||
@@ -528,6 +560,12 @@ export async function handleWriteCommand(
|
||||
throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`);
|
||||
}
|
||||
|
||||
// Validate CSS value — block data exfiltration patterns
|
||||
const DANGEROUS_CSS = /url\s*\(|expression\s*\(|@import|javascript:|data:/i;
|
||||
if (DANGEROUS_CSS.test(value)) {
|
||||
throw new Error('CSS value rejected: contains potentially dangerous pattern.');
|
||||
}
|
||||
|
||||
const mod = await modifyStyle(page, selector, property, value);
|
||||
return `Style modified: ${selector} { ${property}: ${mod.oldValue || '(none)'} → ${value} } (${mod.method})`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user