mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-03 08:38:03 +02:00
chore: merge origin/main, resolve VERSION conflict (keep 0.17.0.0)
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Persistent command audit log — forensic trail for all browse server commands.
|
||||
*
|
||||
* Writes append-only JSONL to .gstack/browse-audit.jsonl. Unlike the in-memory
|
||||
* ring buffers (console, network, dialog), the audit log persists across server
|
||||
* restarts and is never truncated by the server. Each entry records:
|
||||
*
|
||||
* - timestamp, command, args (truncated), page origin
|
||||
* - duration, status (ok/error), error message if any
|
||||
* - whether cookies were imported (elevated security context)
|
||||
* - connection mode (headless/headed)
|
||||
*
|
||||
* All writes are best-effort — audit failures never cause command failures.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
export interface AuditEntry {
|
||||
ts: string;
|
||||
cmd: string;
|
||||
args: string;
|
||||
origin: string;
|
||||
durationMs: number;
|
||||
status: 'ok' | 'error';
|
||||
error?: string;
|
||||
hasCookies: boolean;
|
||||
mode: 'launched' | 'headed';
|
||||
}
|
||||
|
||||
const MAX_ARGS_LENGTH = 200;
|
||||
const MAX_ERROR_LENGTH = 300;
|
||||
|
||||
let auditPath: string | null = null;
|
||||
|
||||
export function initAuditLog(logPath: string): void {
|
||||
auditPath = logPath;
|
||||
}
|
||||
|
||||
export function writeAuditEntry(entry: AuditEntry): void {
|
||||
if (!auditPath) return;
|
||||
try {
|
||||
const truncatedArgs = entry.args.length > MAX_ARGS_LENGTH
|
||||
? entry.args.slice(0, MAX_ARGS_LENGTH) + '…'
|
||||
: entry.args;
|
||||
const truncatedError = entry.error && entry.error.length > MAX_ERROR_LENGTH
|
||||
? entry.error.slice(0, MAX_ERROR_LENGTH) + '…'
|
||||
: entry.error;
|
||||
|
||||
const record: Record<string, unknown> = {
|
||||
ts: entry.ts,
|
||||
cmd: entry.cmd,
|
||||
args: truncatedArgs,
|
||||
origin: entry.origin,
|
||||
durationMs: entry.durationMs,
|
||||
status: entry.status,
|
||||
hasCookies: entry.hasCookies,
|
||||
mode: entry.mode,
|
||||
};
|
||||
if (truncatedError) record.error = truncatedError;
|
||||
|
||||
fs.appendFileSync(auditPath, JSON.stringify(record) + '\n');
|
||||
} catch {
|
||||
// Audit write failures are silent — never block command execution
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ export class BrowserManager {
|
||||
private dialogAutoAccept: boolean = true;
|
||||
private dialogPromptText: string | null = null;
|
||||
|
||||
// ─── Cookie Origin Tracking ────────────────────────────────
|
||||
private cookieImportedDomains: Set<string> = new Set();
|
||||
|
||||
// ─── Handoff State ─────────────────────────────────────────
|
||||
private isHeaded: boolean = false;
|
||||
private consecutiveFailures: number = 0;
|
||||
@@ -749,6 +752,19 @@ export class BrowserManager {
|
||||
return this.dialogPromptText;
|
||||
}
|
||||
|
||||
// ─── Cookie Origin Tracking ────────────────────────────────
|
||||
trackCookieImportDomains(domains: string[]): void {
|
||||
for (const d of domains) this.cookieImportedDomains.add(d);
|
||||
}
|
||||
|
||||
getCookieImportedDomains(): ReadonlySet<string> {
|
||||
return this.cookieImportedDomains;
|
||||
}
|
||||
|
||||
hasCookieImports(): boolean {
|
||||
return this.cookieImportedDomains.size > 0;
|
||||
}
|
||||
|
||||
// ─── Viewport ──────────────────────────────────────────────
|
||||
async setViewport(width: number, height: number) {
|
||||
await this.getPage().setViewportSize({ width, height });
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface BrowseConfig {
|
||||
consoleLog: string;
|
||||
networkLog: string;
|
||||
dialogLog: string;
|
||||
auditLog: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +71,7 @@ export function resolveConfig(
|
||||
consoleLog: path.join(stateDir, 'browse-console.log'),
|
||||
networkLog: path.join(stateDir, 'browse-network.log'),
|
||||
dialogLog: path.join(stateDir, 'browse-dialog.log'),
|
||||
auditLog: path.join(stateDir, 'browse-audit.jsonl'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -386,7 +386,8 @@ function openDb(dbPath: string, browserName: string): Database {
|
||||
}
|
||||
|
||||
function openDbFromCopy(dbPath: string, browserName: string): Database {
|
||||
const tmpPath = `/tmp/browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`;
|
||||
// Use os.tmpdir() instead of hardcoded /tmp for cross-platform support (#708)
|
||||
const tmpPath = path.join(os.tmpdir(), `browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`);
|
||||
try {
|
||||
fs.copyFileSync(dbPath, tmpPath);
|
||||
// Also copy WAL and SHM if they exist (for consistent reads)
|
||||
|
||||
@@ -33,7 +33,26 @@ const TEMP_ONLY = [TEMP_DIR].map(d => {
|
||||
export function validateOutputPath(filePath: string): void {
|
||||
const resolved = path.resolve(filePath);
|
||||
|
||||
// Resolve real path of the parent directory to catch symlinks.
|
||||
// If the target already exists and is a symlink, resolve through it.
|
||||
// Without this, a symlink at /tmp/evil.png → /etc/crontab passes the
|
||||
// parent-directory check (parent is /tmp, which is safe) but the actual
|
||||
// write follows the symlink to /etc/crontab.
|
||||
try {
|
||||
const stat = fs.lstatSync(resolved);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const realTarget = fs.realpathSync(resolved);
|
||||
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realTarget, dir));
|
||||
if (!isSafe) {
|
||||
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
||||
}
|
||||
return; // symlink target verified, no need to check parent
|
||||
}
|
||||
} catch (e: any) {
|
||||
// ENOENT = file doesn't exist yet, fall through to parent-dir check
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
|
||||
// For new files (no existing symlink), verify the parent directory.
|
||||
// The file itself may not exist yet (e.g., screenshot output).
|
||||
// This also handles macOS /tmp → /private/tmp transparently.
|
||||
let dir = path.dirname(resolved);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { TabSession } from './tab-session';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
||||
import type { Page, Frame } from 'playwright';
|
||||
import * as fs from 'fs';
|
||||
@@ -62,10 +63,43 @@ export async function getCleanText(page: Page | Frame): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When cookies have been imported for specific domains, block JS execution
|
||||
* on pages whose origin doesn't match any imported cookie domain.
|
||||
* Prevents cross-origin cookie exfiltration via `js document.cookie` or
|
||||
* similar when the agent navigates to an untrusted page.
|
||||
*/
|
||||
function assertJsOriginAllowed(bm: BrowserManager, pageUrl: string): void {
|
||||
if (!bm.hasCookieImports()) return;
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(pageUrl).hostname;
|
||||
} catch {
|
||||
return; // about:blank, data: URIs — allow (no cookies at risk)
|
||||
}
|
||||
|
||||
const importedDomains = bm.getCookieImportedDomains();
|
||||
const allowed = [...importedDomains].some(domain => {
|
||||
// Exact match or subdomain match (e.g., ".github.com" matches "api.github.com")
|
||||
const normalized = domain.startsWith('.') ? domain : '.' + domain;
|
||||
return hostname === domain.replace(/^\./, '') || hostname.endsWith(normalized);
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
throw new Error(
|
||||
`JS execution blocked: current page (${hostname}) does not match any cookie-imported domain. ` +
|
||||
`Imported cookies for: ${[...importedDomains].join(', ')}. ` +
|
||||
`This prevents cross-origin cookie exfiltration. Navigate to an imported domain or run without imported cookies.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleReadCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
session: TabSession
|
||||
session: TabSession,
|
||||
bm?: BrowserManager,
|
||||
): Promise<string> {
|
||||
const page = session.getPage();
|
||||
// Frame-aware target for content extraction
|
||||
@@ -116,7 +150,10 @@ export async function handleReadCommand(
|
||||
id: input.id || undefined,
|
||||
placeholder: input.placeholder || undefined,
|
||||
required: input.required || undefined,
|
||||
value: input.type === 'password' ? '[redacted]' : (input.value || undefined),
|
||||
value: input.type === 'password'
|
||||
|| (input.name && /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i.test(input.name))
|
||||
|| (input.id && /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i.test(input.id))
|
||||
? '[redacted]' : (input.value || undefined),
|
||||
options: el.tagName === 'SELECT'
|
||||
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
|
||||
: undefined,
|
||||
@@ -142,6 +179,7 @@ export async function handleReadCommand(
|
||||
case 'js': {
|
||||
const expr = args[0];
|
||||
if (!expr) throw new Error('Usage: browse js <expression>');
|
||||
if (bm) assertJsOriginAllowed(bm, page.url());
|
||||
const wrapped = wrapForEvaluate(expr);
|
||||
const result = await target.evaluate(wrapped);
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||
@@ -150,6 +188,7 @@ export async function handleReadCommand(
|
||||
case 'eval': {
|
||||
const filePath = args[0];
|
||||
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
||||
if (bm) assertJsOriginAllowed(bm, page.url());
|
||||
validateReadPath(filePath);
|
||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||
const code = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
+30
-3
@@ -35,6 +35,7 @@ import {
|
||||
import { validateTempPath } from './path-security';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||
import { initAuditLog, writeAuditEntry } from './audit';
|
||||
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector';
|
||||
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
||||
// fail posix_spawn on all executables including /bin/bash)
|
||||
@@ -47,6 +48,7 @@ import * as crypto from 'crypto';
|
||||
// ─── Config ─────────────────────────────────────────────────────
|
||||
const config = resolveConfig();
|
||||
ensureStateDir(config);
|
||||
initAuditLog(config.auditLog);
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────
|
||||
const AUTH_TOKEN = crypto.randomUUID();
|
||||
@@ -1013,7 +1015,7 @@ async function handleCommandInternal(
|
||||
await cleanupHiddenMarkers(page);
|
||||
}
|
||||
} else {
|
||||
result = await handleReadCommand(command, args, session);
|
||||
result = await handleReadCommand(command, args, session, browserManager);
|
||||
}
|
||||
} else if (WRITE_COMMANDS.has(command)) {
|
||||
result = await handleWriteCommand(command, args, session, browserManager);
|
||||
@@ -1088,13 +1090,14 @@ async function handleCommandInternal(
|
||||
}
|
||||
|
||||
// Activity: emit command_end (skipped for chain subcommands)
|
||||
const successDuration = Date.now() - startTime;
|
||||
if (!opts?.skipActivity) {
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
duration: successDuration,
|
||||
status: 'ok',
|
||||
result: result,
|
||||
tabs: browserManager.getTabCount(),
|
||||
@@ -1103,6 +1106,17 @@ async function handleCommandInternal(
|
||||
});
|
||||
}
|
||||
|
||||
writeAuditEntry({
|
||||
ts: new Date().toISOString(),
|
||||
cmd: command,
|
||||
args: args.join(' '),
|
||||
origin: browserManager.getCurrentUrl(),
|
||||
durationMs: successDuration,
|
||||
status: 'ok',
|
||||
hasCookies: browserManager.hasCookieImports(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
|
||||
browserManager.resetFailures();
|
||||
// Restore original active tab if we pinned to a specific one
|
||||
if (savedTabId !== null) {
|
||||
@@ -1120,13 +1134,14 @@ async function handleCommandInternal(
|
||||
}
|
||||
|
||||
// Activity: emit command_end (error) — skipped for chain subcommands
|
||||
const errorDuration = Date.now() - startTime;
|
||||
if (!opts?.skipActivity) {
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
duration: errorDuration,
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
tabs: browserManager.getTabCount(),
|
||||
@@ -1135,6 +1150,18 @@ async function handleCommandInternal(
|
||||
});
|
||||
}
|
||||
|
||||
writeAuditEntry({
|
||||
ts: new Date().toISOString(),
|
||||
cmd: command,
|
||||
args: args.join(' '),
|
||||
origin: browserManager.getCurrentUrl(),
|
||||
durationMs: errorDuration,
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
hasCookies: browserManager.hasCookieImports(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
|
||||
browserManager.incrementFailures();
|
||||
let errorMsg = wrapError(err);
|
||||
const hint = browserManager.getFailureHint();
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
'::ffff:169.254.169.254', // IPv4-mapped IPv6 form of the metadata IP
|
||||
'::ffff:a9fe:a9fe', // Hex-encoded IPv4-mapped form (URL constructor normalizes to this)
|
||||
'::a9fe:a9fe', // Deprecated IPv4-compatible hex form
|
||||
'metadata.google.internal', // GCP metadata
|
||||
'metadata.azure.internal', // Azure IMDS
|
||||
]);
|
||||
|
||||
@@ -13,7 +13,8 @@ import { validateNavigationUrl } from './url-validation';
|
||||
import { validateOutputPath } from './path-security';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { TEMP_DIR } from './platform';
|
||||
import { TEMP_DIR, isPathWithin } from './platform';
|
||||
import { SAFE_DIRECTORIES } from './path-security';
|
||||
import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector';
|
||||
|
||||
/**
|
||||
@@ -441,16 +442,17 @@ export async function handleWriteCommand(
|
||||
case 'cookie-import': {
|
||||
const filePath = args[0];
|
||||
if (!filePath) throw new Error('Usage: browse cookie-import <json-file>');
|
||||
// Path validation — prevent reading arbitrary files
|
||||
if (path.isAbsolute(filePath)) {
|
||||
const safeDirs = [TEMP_DIR, process.cwd()];
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!safeDirs.some(dir => isPathWithin(resolved, dir))) {
|
||||
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
||||
}
|
||||
// Path validation — resolve to absolute and check against safe dirs.
|
||||
// Fixes #707: relative paths previously bypassed the safe directory check.
|
||||
// Mirrors validateOutputPath() — resolves symlinks (e.g., macOS /tmp → /private/tmp).
|
||||
const resolved = path.resolve(filePath);
|
||||
let resolvedReal = resolved;
|
||||
try { resolvedReal = fs.realpathSync(resolved); } catch {
|
||||
// File may not exist yet — resolve parent dir instead
|
||||
try { resolvedReal = path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved)); } catch {}
|
||||
}
|
||||
if (path.normalize(filePath).includes('..')) {
|
||||
throw new Error('Path traversal sequences (..) are not allowed');
|
||||
if (!SAFE_DIRECTORIES.some(dir => isPathWithin(resolvedReal, dir))) {
|
||||
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
||||
}
|
||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
@@ -476,20 +478,24 @@ export async function handleWriteCommand(
|
||||
}
|
||||
|
||||
await page.context().addCookies(cookies);
|
||||
const importedDomains = [...new Set(cookies.map((c: any) => c.domain).filter(Boolean))];
|
||||
if (importedDomains.length > 0) bm.trackCookieImportDomains(importedDomains);
|
||||
return `Loaded ${cookies.length} cookies from ${filePath}`;
|
||||
}
|
||||
|
||||
case 'cookie-import-browser': {
|
||||
// Two modes:
|
||||
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain> [--profile <profile>]
|
||||
// 2. Open picker UI: cookie-import-browser [browser]
|
||||
// Requires --domain (or --all to explicitly import everything).
|
||||
// 2. Open picker UI: cookie-import-browser [browser] (interactive domain selection)
|
||||
const browserArg = args[0];
|
||||
const domainIdx = args.indexOf('--domain');
|
||||
const profileIdx = args.indexOf('--profile');
|
||||
const hasAll = args.includes('--all');
|
||||
const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default';
|
||||
|
||||
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
||||
// Direct import mode — no UI
|
||||
// Direct import mode — scoped to specific domain
|
||||
const domain = args[domainIdx + 1];
|
||||
// Validate --domain against current page hostname to prevent cross-site cookie injection
|
||||
const pageHostname = new URL(page.url()).hostname;
|
||||
@@ -501,13 +507,35 @@ export async function handleWriteCommand(
|
||||
const result = await importCookies(browser, [domain], profile);
|
||||
if (result.cookies.length > 0) {
|
||||
await page.context().addCookies(result.cookies);
|
||||
bm.trackCookieImportDomains([domain]);
|
||||
}
|
||||
const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`];
|
||||
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
||||
return msg.join(' ');
|
||||
}
|
||||
|
||||
// Picker UI mode — open in user's browser
|
||||
if (hasAll) {
|
||||
// Explicit all-cookies import — requires --all flag as a deliberate opt-in.
|
||||
// Imports every non-expired cookie domain from the browser.
|
||||
const browser = browserArg || 'comet';
|
||||
const { listDomains } = await import('./cookie-import-browser');
|
||||
const { domains } = listDomains(browser, profile);
|
||||
const allDomainNames = domains.map((d: any) => d.domain);
|
||||
if (allDomainNames.length === 0) {
|
||||
return `No cookies found in ${browser} (profile: ${profile})`;
|
||||
}
|
||||
const result = await importCookies(browser, allDomainNames, profile);
|
||||
if (result.cookies.length > 0) {
|
||||
await page.context().addCookies(result.cookies);
|
||||
bm.trackCookieImportDomains(allDomainNames);
|
||||
}
|
||||
const msg = [`Imported ${result.count} cookies across ${Object.keys(result.domainCounts).length} domains from ${browser}`];
|
||||
msg.push('(used --all: all browser cookies imported, consider --domain for tighter scoping)');
|
||||
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
||||
return msg.join(' ');
|
||||
}
|
||||
|
||||
// Picker UI mode — open in user's browser for interactive domain selection
|
||||
const port = bm.serverPort;
|
||||
if (!port) throw new Error('Server port not available');
|
||||
|
||||
@@ -525,7 +553,7 @@ export async function handleWriteCommand(
|
||||
if (err?.code !== 'ENOENT' && !err?.message?.includes('spawn')) throw err;
|
||||
}
|
||||
|
||||
return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`;
|
||||
return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.\n\nTip: For scripted imports, use --domain <domain> to scope cookies to a single domain.`;
|
||||
}
|
||||
|
||||
case 'style': {
|
||||
|
||||
@@ -1811,7 +1811,8 @@ describe('Path traversal prevention', () => {
|
||||
await handleWriteCommand('cookie-import', ['../../etc/shadow'], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toContain('Path traversal');
|
||||
// Traversal blocked by safe-directory check (#707) or explicit .. check
|
||||
expect(err.message).toMatch(/Path must be within|Path traversal/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user