mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-22 04:38:24 +08:00
chore: merge origin/main, resolve VERSION conflict (keep 0.17.0.0)
This commit is contained in:
3
.github/docker/Dockerfile.ci
vendored
3
.github/docker/Dockerfile.ci
vendored
@@ -59,5 +59,4 @@ RUN useradd -m -s /bin/bash runner \
|
|||||||
&& chmod -R a+rX /opt/node_modules_cache \
|
&& chmod -R a+rX /opt/node_modules_cache \
|
||||||
&& mkdir -p /home/runner/.gstack && chown -R runner:runner /home/runner/.gstack \
|
&& mkdir -p /home/runner/.gstack && chown -R runner:runner /home/runner/.gstack \
|
||||||
&& chmod 1777 /tmp \
|
&& chmod 1777 /tmp \
|
||||||
&& mkdir -p /home/runner/.bun && chown -R runner:runner /home/runner/.bun \
|
&& mkdir -p /home/runner/.bun && chown -R runner:runner /home/runner/.bun
|
||||||
&& chmod -R 1777 /tmp
|
|
||||||
|
|||||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.16.4.0] - 2026-04-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Cookie origin pinning.** When you import cookies for specific domains, JS execution is now blocked on pages that don't match those domains. This prevents the attack where a prompt injection navigates to an attacker's site and runs `document.cookie` to steal your imported cookies. Subdomain matching works automatically (importing `.github.com` allows `api.github.com`). When no cookies are imported, everything works as before. 3 PRs from @halbert04.
|
||||||
|
- **Command audit log.** Every browse command now gets a persistent forensic trail in `~/.gstack/.browse/browse-audit.jsonl`. Timestamp, command, args, page origin, duration, status, error, and whether cookies were imported. Append-only, never truncated, survives server restarts. Best-effort writes that never block command execution. From @halbert04.
|
||||||
|
- **Cookie domain tracking.** gstack now tracks which domains cookies were imported from. Foundation for origin pinning above. Direct imports via `--domain` track automatically. New `--all` flag makes full-browser cookie import an explicit opt-in instead of the default.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Symlink bypass in file writes.** `validateOutputPath` only checked the parent directory for symlinks, not the file itself. A symlink at `/tmp/evil.png` pointing to `/etc/crontab` passed validation because the parent `/tmp` was safe. Now checks the file with `lstatSync` before writing. From @Hybirdss.
|
||||||
|
- **Cookie-import path bypass.** Two issues: relative paths bypassed all validation (the `path.isAbsolute()` gate let `sensitive-file.json` through), and symlink resolution was missing (`path.resolve` without `realpathSync`). Now resolves to absolute, resolves symlinks, and checks against safe directories. From @urbantech.
|
||||||
|
- **Shell injection in setup scripts.** `gstack-settings-hook` interpolated file paths directly into `bun -e` JavaScript blocks. A path with quotes broke the JS string context. Now uses environment variables (`process.env`). Systematic audit confirmed only this script was vulnerable. From @garagon.
|
||||||
|
- **Form field credential leak.** Snapshot redaction only applied to `type="password"` fields. Hidden and text fields named `csrf_token`, `api_key`, `session_id` were exposed unredacted in LLM context. Now checks field name and id against sensitive patterns. From @garagon.
|
||||||
|
- **Learnings prompt injection.** Three fixes: input validation (type/key/confidence allowlists), injection pattern detection in insight field (blocks "ignore previous instructions" etc.), and cross-project trust gate (only user-stated learnings cross project boundaries). From @Ziadstr.
|
||||||
|
- **IPv6 metadata bypass.** The URL constructor normalizes `::ffff:169.254.169.254` to `::ffff:a9fe:a9fe` (hex), which wasn't in the blocklist. Added both hex-encoded forms. From @mehmoodosman.
|
||||||
|
- **Session files world-readable.** Design session files in `/tmp` were created with default permissions (0644). Now 0600 (owner-only). From @garagon.
|
||||||
|
- **Frozen lockfile in setup.** `bun install` now uses `--frozen-lockfile` to prevent supply chain attacks via floating semver ranges. From @halbert04.
|
||||||
|
- **Dockerfile chmod fix.** Removed duplicate recursive `chmod -R 1777 /tmp` (recursive sticky bit on files has no defined behavior). From @Gonzih.
|
||||||
|
- **Hardcoded /tmp in cookie import.** `cookie-import-browser` used `/tmp` directly instead of `os.tmpdir()`, breaking Windows support.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Closed 14 security issues (#665-#675, #566, #479, #467, #545) that were fixed in prior waves but still open on GitHub.
|
||||||
|
- Closed 17 community security PRs with thank-you messages and commit references.
|
||||||
|
- Security wave 3: 12 fixes, 7 contributors. Big thanks to @Hybirdss, @urbantech, @garagon, @Ziadstr, @halbert04, @mehmoodosman, @Gonzih.
|
||||||
|
|
||||||
## [0.16.3.0] - 2026-04-09
|
## [0.16.3.0] - 2026-04-09
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -12,19 +12,75 @@ mkdir -p "$GSTACK_HOME/projects/$SLUG"
|
|||||||
|
|
||||||
INPUT="$1"
|
INPUT="$1"
|
||||||
|
|
||||||
# Validate: input must be parseable JSON
|
# Validate and sanitize input
|
||||||
if ! printf '%s' "$INPUT" | bun -e "JSON.parse(await Bun.stdin.text())" 2>/dev/null; then
|
VALIDATED=$(printf '%s' "$INPUT" | bun -e "
|
||||||
echo "gstack-learnings-log: invalid JSON, skipping" >&2
|
const raw = await Bun.stdin.text();
|
||||||
|
let j;
|
||||||
|
try { j = JSON.parse(raw); } catch { process.stderr.write('gstack-learnings-log: invalid JSON, skipping\n'); process.exit(1); }
|
||||||
|
|
||||||
|
// Field validation: type must be from allowed list
|
||||||
|
const ALLOWED_TYPES = ['pattern', 'pitfall', 'preference', 'architecture', 'tool', 'operational'];
|
||||||
|
if (!j.type || !ALLOWED_TYPES.includes(j.type)) {
|
||||||
|
process.stderr.write('gstack-learnings-log: invalid type \"' + (j.type || '') + '\", must be one of: ' + ALLOWED_TYPES.join(', ') + '\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field validation: key must be alphanumeric, hyphens, underscores (no injection surface)
|
||||||
|
if (!j.key || !/^[a-zA-Z0-9_-]+$/.test(j.key)) {
|
||||||
|
process.stderr.write('gstack-learnings-log: invalid key, must be alphanumeric with hyphens/underscores only\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field validation: confidence must be 1-10
|
||||||
|
const conf = Number(j.confidence);
|
||||||
|
if (!Number.isInteger(conf) || conf < 1 || conf > 10) {
|
||||||
|
process.stderr.write('gstack-learnings-log: confidence must be integer 1-10\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
j.confidence = conf;
|
||||||
|
|
||||||
|
// Field validation: source must be from allowed list
|
||||||
|
const ALLOWED_SOURCES = ['observed', 'user-stated', 'inferred', 'cross-model'];
|
||||||
|
if (j.source && !ALLOWED_SOURCES.includes(j.source)) {
|
||||||
|
process.stderr.write('gstack-learnings-log: invalid source, must be one of: ' + ALLOWED_SOURCES.join(', ') + '\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content sanitization: strip instruction-like patterns from insight field
|
||||||
|
// These patterns could be used for prompt injection when learnings are loaded into agent context
|
||||||
|
if (j.insight) {
|
||||||
|
const INJECTION_PATTERNS = [
|
||||||
|
/ignore\s+(all\s+)?previous\s+(instructions|context|rules)/i,
|
||||||
|
/you\s+are\s+now\s+/i,
|
||||||
|
/always\s+output\s+no\s+findings/i,
|
||||||
|
/skip\s+(all\s+)?(security|review|checks)/i,
|
||||||
|
/override[:\s]/i,
|
||||||
|
/\bsystem\s*:/i,
|
||||||
|
/\bassistant\s*:/i,
|
||||||
|
/\buser\s*:/i,
|
||||||
|
/do\s+not\s+(report|flag|mention)/i,
|
||||||
|
/approve\s+(all|every|this)/i,
|
||||||
|
];
|
||||||
|
for (const pat of INJECTION_PATTERNS) {
|
||||||
|
if (pat.test(j.insight)) {
|
||||||
|
process.stderr.write('gstack-learnings-log: insight contains suspicious instruction-like content, rejected\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject timestamp if not present
|
||||||
|
if (!j.ts) j.ts = new Date().toISOString();
|
||||||
|
|
||||||
|
// Mark trust level based on source
|
||||||
|
// user-stated = user explicitly told the agent this. All others are AI-generated.
|
||||||
|
j.trusted = j.source === 'user-stated';
|
||||||
|
|
||||||
|
console.log(JSON.stringify(j));
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ $? -ne 0 ] || [ -z "$VALIDATED" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Inject timestamp if not present
|
echo "$VALIDATED" >> "$GSTACK_HOME/projects/$SLUG/learnings.jsonl"
|
||||||
if ! printf '%s' "$INPUT" | bun -e "const j=JSON.parse(await Bun.stdin.text()); if(!j.ts) process.exit(1)" 2>/dev/null; then
|
|
||||||
INPUT=$(printf '%s' "$INPUT" | bun -e "
|
|
||||||
const j = JSON.parse(await Bun.stdin.text());
|
|
||||||
j.ts = new Date().toISOString();
|
|
||||||
console.log(JSON.stringify(j));
|
|
||||||
" 2>/dev/null) || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/learnings.jsonl"
|
|
||||||
|
|||||||
@@ -68,7 +68,13 @@ for (const line of lines) {
|
|||||||
|
|
||||||
// Determine if this is from the current project or cross-project
|
// Determine if this is from the current project or cross-project
|
||||||
// Cross-project entries are tagged for display
|
// Cross-project entries are tagged for display
|
||||||
e._crossProject = !line.includes(slug) && process.env.GSTACK_SEARCH_CROSS === 'true';
|
const isCrossProject = !line.includes(slug) && process.env.GSTACK_SEARCH_CROSS === 'true';
|
||||||
|
e._crossProject = isCrossProject;
|
||||||
|
|
||||||
|
// Trust gate: cross-project learnings only loaded if trusted (user-stated)
|
||||||
|
// This prevents prompt injection from one project's AI-generated learnings
|
||||||
|
// silently influencing reviews in another project.
|
||||||
|
if (isCrossProject && e.trusted === false) continue;
|
||||||
|
|
||||||
entries.push(e);
|
entries.push(e);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ fi
|
|||||||
|
|
||||||
case "$ACTION" in
|
case "$ACTION" in
|
||||||
add)
|
add)
|
||||||
bun -e "
|
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e "
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const settingsPath = '$SETTINGS_FILE';
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||||
const hookCmd = $(printf '%s' "$HOOK_CMD" | bun -e "process.stdout.write(JSON.stringify(require('fs').readFileSync('/dev/stdin','utf8')))");
|
const hookCmd = process.env.GSTACK_HOOK_CMD;
|
||||||
|
|
||||||
let settings = {};
|
let settings = {};
|
||||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
||||||
@@ -55,9 +55,9 @@ case "$ACTION" in
|
|||||||
;;
|
;;
|
||||||
remove)
|
remove)
|
||||||
[ -f "$SETTINGS_FILE" ] || exit 0
|
[ -f "$SETTINGS_FILE" ] || exit 0
|
||||||
bun -e "
|
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e "
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const settingsPath = '$SETTINGS_FILE';
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||||
|
|
||||||
let settings = {};
|
let settings = {};
|
||||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { process.exit(0); }
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { process.exit(0); }
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ HOOK_EOF
|
|||||||
|
|
||||||
# Add hook to project-level settings.json
|
# Add hook to project-level settings.json
|
||||||
if command -v bun >/dev/null 2>&1; then
|
if command -v bun >/dev/null 2>&1; then
|
||||||
bun -e "
|
GSTACK_SETTINGS_PATH="$SETTINGS" bun -e "
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const settingsPath = '$SETTINGS';
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||||
|
|
||||||
let settings = {};
|
let settings = {};
|
||||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
||||||
|
|||||||
65
browse/src/audit.ts
Normal file
65
browse/src/audit.ts
Normal file
@@ -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 dialogAutoAccept: boolean = true;
|
||||||
private dialogPromptText: string | null = null;
|
private dialogPromptText: string | null = null;
|
||||||
|
|
||||||
|
// ─── Cookie Origin Tracking ────────────────────────────────
|
||||||
|
private cookieImportedDomains: Set<string> = new Set();
|
||||||
|
|
||||||
// ─── Handoff State ─────────────────────────────────────────
|
// ─── Handoff State ─────────────────────────────────────────
|
||||||
private isHeaded: boolean = false;
|
private isHeaded: boolean = false;
|
||||||
private consecutiveFailures: number = 0;
|
private consecutiveFailures: number = 0;
|
||||||
@@ -749,6 +752,19 @@ export class BrowserManager {
|
|||||||
return this.dialogPromptText;
|
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 ──────────────────────────────────────────────
|
// ─── Viewport ──────────────────────────────────────────────
|
||||||
async setViewport(width: number, height: number) {
|
async setViewport(width: number, height: number) {
|
||||||
await this.getPage().setViewportSize({ width, height });
|
await this.getPage().setViewportSize({ width, height });
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface BrowseConfig {
|
|||||||
consoleLog: string;
|
consoleLog: string;
|
||||||
networkLog: string;
|
networkLog: string;
|
||||||
dialogLog: string;
|
dialogLog: string;
|
||||||
|
auditLog: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +71,7 @@ export function resolveConfig(
|
|||||||
consoleLog: path.join(stateDir, 'browse-console.log'),
|
consoleLog: path.join(stateDir, 'browse-console.log'),
|
||||||
networkLog: path.join(stateDir, 'browse-network.log'),
|
networkLog: path.join(stateDir, 'browse-network.log'),
|
||||||
dialogLog: path.join(stateDir, 'browse-dialog.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 {
|
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 {
|
try {
|
||||||
fs.copyFileSync(dbPath, tmpPath);
|
fs.copyFileSync(dbPath, tmpPath);
|
||||||
// Also copy WAL and SHM if they exist (for consistent reads)
|
// 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 {
|
export function validateOutputPath(filePath: string): void {
|
||||||
const resolved = path.resolve(filePath);
|
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).
|
// The file itself may not exist yet (e.g., screenshot output).
|
||||||
// This also handles macOS /tmp → /private/tmp transparently.
|
// This also handles macOS /tmp → /private/tmp transparently.
|
||||||
let dir = path.dirname(resolved);
|
let dir = path.dirname(resolved);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TabSession } from './tab-session';
|
import type { TabSession } from './tab-session';
|
||||||
|
import type { BrowserManager } from './browser-manager';
|
||||||
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
||||||
import type { Page, Frame } from 'playwright';
|
import type { Page, Frame } from 'playwright';
|
||||||
import * as fs from 'fs';
|
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(
|
export async function handleReadCommand(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
session: TabSession
|
session: TabSession,
|
||||||
|
bm?: BrowserManager,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const page = session.getPage();
|
const page = session.getPage();
|
||||||
// Frame-aware target for content extraction
|
// Frame-aware target for content extraction
|
||||||
@@ -116,7 +150,10 @@ export async function handleReadCommand(
|
|||||||
id: input.id || undefined,
|
id: input.id || undefined,
|
||||||
placeholder: input.placeholder || undefined,
|
placeholder: input.placeholder || undefined,
|
||||||
required: input.required || 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'
|
options: el.tagName === 'SELECT'
|
||||||
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
|
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -142,6 +179,7 @@ export async function handleReadCommand(
|
|||||||
case 'js': {
|
case 'js': {
|
||||||
const expr = args[0];
|
const expr = args[0];
|
||||||
if (!expr) throw new Error('Usage: browse js <expression>');
|
if (!expr) throw new Error('Usage: browse js <expression>');
|
||||||
|
if (bm) assertJsOriginAllowed(bm, page.url());
|
||||||
const wrapped = wrapForEvaluate(expr);
|
const wrapped = wrapForEvaluate(expr);
|
||||||
const result = await target.evaluate(wrapped);
|
const result = await target.evaluate(wrapped);
|
||||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||||
@@ -150,6 +188,7 @@ export async function handleReadCommand(
|
|||||||
case 'eval': {
|
case 'eval': {
|
||||||
const filePath = args[0];
|
const filePath = args[0];
|
||||||
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
||||||
|
if (bm) assertJsOriginAllowed(bm, page.url());
|
||||||
validateReadPath(filePath);
|
validateReadPath(filePath);
|
||||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||||
const code = fs.readFileSync(filePath, 'utf-8');
|
const code = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { validateTempPath } from './path-security';
|
import { validateTempPath } from './path-security';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||||
|
import { initAuditLog, writeAuditEntry } from './audit';
|
||||||
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector';
|
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector';
|
||||||
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
||||||
// fail posix_spawn on all executables including /bin/bash)
|
// fail posix_spawn on all executables including /bin/bash)
|
||||||
@@ -47,6 +48,7 @@ import * as crypto from 'crypto';
|
|||||||
// ─── Config ─────────────────────────────────────────────────────
|
// ─── Config ─────────────────────────────────────────────────────
|
||||||
const config = resolveConfig();
|
const config = resolveConfig();
|
||||||
ensureStateDir(config);
|
ensureStateDir(config);
|
||||||
|
initAuditLog(config.auditLog);
|
||||||
|
|
||||||
// ─── Auth ───────────────────────────────────────────────────────
|
// ─── Auth ───────────────────────────────────────────────────────
|
||||||
const AUTH_TOKEN = crypto.randomUUID();
|
const AUTH_TOKEN = crypto.randomUUID();
|
||||||
@@ -1013,7 +1015,7 @@ async function handleCommandInternal(
|
|||||||
await cleanupHiddenMarkers(page);
|
await cleanupHiddenMarkers(page);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = await handleReadCommand(command, args, session);
|
result = await handleReadCommand(command, args, session, browserManager);
|
||||||
}
|
}
|
||||||
} else if (WRITE_COMMANDS.has(command)) {
|
} else if (WRITE_COMMANDS.has(command)) {
|
||||||
result = await handleWriteCommand(command, args, session, browserManager);
|
result = await handleWriteCommand(command, args, session, browserManager);
|
||||||
@@ -1088,13 +1090,14 @@ async function handleCommandInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Activity: emit command_end (skipped for chain subcommands)
|
// Activity: emit command_end (skipped for chain subcommands)
|
||||||
|
const successDuration = Date.now() - startTime;
|
||||||
if (!opts?.skipActivity) {
|
if (!opts?.skipActivity) {
|
||||||
emitActivity({
|
emitActivity({
|
||||||
type: 'command_end',
|
type: 'command_end',
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
url: browserManager.getCurrentUrl(),
|
url: browserManager.getCurrentUrl(),
|
||||||
duration: Date.now() - startTime,
|
duration: successDuration,
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
result: result,
|
result: result,
|
||||||
tabs: browserManager.getTabCount(),
|
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();
|
browserManager.resetFailures();
|
||||||
// Restore original active tab if we pinned to a specific one
|
// Restore original active tab if we pinned to a specific one
|
||||||
if (savedTabId !== null) {
|
if (savedTabId !== null) {
|
||||||
@@ -1120,13 +1134,14 @@ async function handleCommandInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Activity: emit command_end (error) — skipped for chain subcommands
|
// Activity: emit command_end (error) — skipped for chain subcommands
|
||||||
|
const errorDuration = Date.now() - startTime;
|
||||||
if (!opts?.skipActivity) {
|
if (!opts?.skipActivity) {
|
||||||
emitActivity({
|
emitActivity({
|
||||||
type: 'command_end',
|
type: 'command_end',
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
url: browserManager.getCurrentUrl(),
|
url: browserManager.getCurrentUrl(),
|
||||||
duration: Date.now() - startTime,
|
duration: errorDuration,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: err.message,
|
error: err.message,
|
||||||
tabs: browserManager.getTabCount(),
|
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();
|
browserManager.incrementFailures();
|
||||||
let errorMsg = wrapError(err);
|
let errorMsg = wrapError(err);
|
||||||
const hint = browserManager.getFailureHint();
|
const hint = browserManager.getFailureHint();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const BLOCKED_METADATA_HOSTS = new Set([
|
|||||||
'169.254.169.254', // AWS/GCP/Azure instance metadata
|
'169.254.169.254', // AWS/GCP/Azure instance metadata
|
||||||
'fe80::1', // IPv6 link-local — common metadata endpoint alias
|
'fe80::1', // IPv6 link-local — common metadata endpoint alias
|
||||||
'::ffff:169.254.169.254', // IPv4-mapped IPv6 form of the metadata IP
|
'::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.google.internal', // GCP metadata
|
||||||
'metadata.azure.internal', // Azure IMDS
|
'metadata.azure.internal', // Azure IMDS
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { validateNavigationUrl } from './url-validation';
|
|||||||
import { validateOutputPath } from './path-security';
|
import { validateOutputPath } from './path-security';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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';
|
import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -441,16 +442,17 @@ export async function handleWriteCommand(
|
|||||||
case 'cookie-import': {
|
case 'cookie-import': {
|
||||||
const filePath = args[0];
|
const filePath = args[0];
|
||||||
if (!filePath) throw new Error('Usage: browse cookie-import <json-file>');
|
if (!filePath) throw new Error('Usage: browse cookie-import <json-file>');
|
||||||
// Path validation — prevent reading arbitrary files
|
// Path validation — resolve to absolute and check against safe dirs.
|
||||||
if (path.isAbsolute(filePath)) {
|
// Fixes #707: relative paths previously bypassed the safe directory check.
|
||||||
const safeDirs = [TEMP_DIR, process.cwd()];
|
// Mirrors validateOutputPath() — resolves symlinks (e.g., macOS /tmp → /private/tmp).
|
||||||
const resolved = path.resolve(filePath);
|
const resolved = path.resolve(filePath);
|
||||||
if (!safeDirs.some(dir => isPathWithin(resolved, dir))) {
|
let resolvedReal = resolved;
|
||||||
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
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('..')) {
|
if (!SAFE_DIRECTORIES.some(dir => isPathWithin(resolvedReal, dir))) {
|
||||||
throw new Error('Path traversal sequences (..) are not allowed');
|
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||||
@@ -476,20 +478,24 @@ export async function handleWriteCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await page.context().addCookies(cookies);
|
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}`;
|
return `Loaded ${cookies.length} cookies from ${filePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'cookie-import-browser': {
|
case 'cookie-import-browser': {
|
||||||
// Two modes:
|
// Two modes:
|
||||||
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain> [--profile <profile>]
|
// 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 browserArg = args[0];
|
||||||
const domainIdx = args.indexOf('--domain');
|
const domainIdx = args.indexOf('--domain');
|
||||||
const profileIdx = args.indexOf('--profile');
|
const profileIdx = args.indexOf('--profile');
|
||||||
|
const hasAll = args.includes('--all');
|
||||||
const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default';
|
const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default';
|
||||||
|
|
||||||
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
||||||
// Direct import mode — no UI
|
// Direct import mode — scoped to specific domain
|
||||||
const domain = args[domainIdx + 1];
|
const domain = args[domainIdx + 1];
|
||||||
// Validate --domain against current page hostname to prevent cross-site cookie injection
|
// Validate --domain against current page hostname to prevent cross-site cookie injection
|
||||||
const pageHostname = new URL(page.url()).hostname;
|
const pageHostname = new URL(page.url()).hostname;
|
||||||
@@ -501,13 +507,35 @@ export async function handleWriteCommand(
|
|||||||
const result = await importCookies(browser, [domain], profile);
|
const result = await importCookies(browser, [domain], profile);
|
||||||
if (result.cookies.length > 0) {
|
if (result.cookies.length > 0) {
|
||||||
await page.context().addCookies(result.cookies);
|
await page.context().addCookies(result.cookies);
|
||||||
|
bm.trackCookieImportDomains([domain]);
|
||||||
}
|
}
|
||||||
const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`];
|
const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`];
|
||||||
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
||||||
return msg.join(' ');
|
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;
|
const port = bm.serverPort;
|
||||||
if (!port) throw new Error('Server port not available');
|
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;
|
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': {
|
case 'style': {
|
||||||
|
|||||||
@@ -1811,7 +1811,8 @@ describe('Path traversal prevention', () => {
|
|||||||
await handleWriteCommand('cookie-import', ['../../etc/shadow'], bm);
|
await handleWriteCommand('cookie-import', ['../../etc/shadow'], bm);
|
||||||
expect(true).toBe(false);
|
expect(true).toBe(false);
|
||||||
} catch (err: any) {
|
} 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/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function createSession(
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
fs.writeFileSync(sessionPath(id), JSON.stringify(session, null, 2));
|
fs.writeFileSync(sessionPath(id), JSON.stringify(session, null, 2), { mode: 0o600 });
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
setup
2
setup
@@ -208,7 +208,7 @@ if [ "$NEEDS_BUILD" -eq 1 ]; then
|
|||||||
log "Building browse binary..."
|
log "Building browse binary..."
|
||||||
(
|
(
|
||||||
cd "$SOURCE_GSTACK_DIR"
|
cd "$SOURCE_GSTACK_DIR"
|
||||||
bun install
|
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||||
bun run build
|
bun run build
|
||||||
)
|
)
|
||||||
# Safety net: write .version if build script didn't (e.g., git not available during build)
|
# Safety net: write .version if build script didn't (e.g., git not available during build)
|
||||||
|
|||||||
Reference in New Issue
Block a user