mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-21 20:28:24 +08: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:
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,5 +1,41 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.15.15.0] - 2026-04-06
|
||||||
|
|
||||||
|
Community security wave: 8 PRs from 4 contributors, every fix credited as co-author.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Cookie value redaction for tokens, API keys, JWTs, and session secrets in `browse cookies` output. Your secrets no longer appear in Claude's context.
|
||||||
|
- IPv6 ULA prefix blocking (fc00::/7) in URL validation. Covers the full unique-local range, not just the literal `fd00::`. Hostnames like `fcustomer.com` are not false-positived.
|
||||||
|
- Per-tab cancel signaling for sidebar agents. Stopping one tab's agent no longer kills all tabs.
|
||||||
|
- Parent process watchdog for the browse server. When Claude Code exits, orphaned browser processes now self-terminate within 15 seconds.
|
||||||
|
- Uninstall instructions in README (script + manual removal steps).
|
||||||
|
- CSS value validation blocks `url()`, `expression()`, `@import`, `javascript:`, and `data:` in style commands, preventing CSS injection attacks.
|
||||||
|
- Queue entry schema validation (`isValidQueueEntry`) with path traversal checks on `stateFile` and `cwd`.
|
||||||
|
- Viewport dimension clamping (1-16384) and wait timeout clamping (1s-300s) prevent OOM and runaway waits.
|
||||||
|
- Cookie domain validation in `cookie-import` prevents cross-site cookie injection.
|
||||||
|
- DocumentFragment-based tab switching in sidebar (replaces innerHTML round-trip XSS vector).
|
||||||
|
- `pollInProgress` reentrancy guard prevents concurrent chat polls from corrupting state.
|
||||||
|
- 750+ lines of new security regression tests across 4 test files.
|
||||||
|
- Supabase migration 003: column-level GRANT restricts anon UPDATE to (last_seen, gstack_version, os) only.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Windows: `extraEnv` now passes through to the Windows launcher (was silently dropped).
|
||||||
|
- Windows: welcome page serves inline HTML instead of `about:blank` redirect (fixes ERR_UNSAFE_REDIRECT).
|
||||||
|
- Headed mode: auth token returned even without Origin header (fixes Playwright Chromium extensions).
|
||||||
|
- `frame --url` now escapes user input before constructing RegExp (ReDoS fix).
|
||||||
|
- Annotated screenshot path validation now resolves symlinks (was bypassable via symlink traversal).
|
||||||
|
- Auth token removed from health broadcast, delivered via targeted `getToken` handler instead.
|
||||||
|
- `/health` endpoint no longer exposes `currentUrl` or `currentMessage`.
|
||||||
|
- Session ID validated before use in file paths (prevents path traversal via crafted active.json).
|
||||||
|
- SIGTERM/SIGKILL escalation in sidebar agent timeout handler (was bare `kill()`).
|
||||||
|
|
||||||
|
### For contributors
|
||||||
|
- Queue files created with 0o700/0o600 permissions (server, CLI, sidebar-agent).
|
||||||
|
- `escapeRegExp` utility exported from meta-commands.
|
||||||
|
- State load filters cookies from localhost, .internal, and metadata domains.
|
||||||
|
- Telemetry sync logs upsert errors from installation tracking.
|
||||||
|
|
||||||
## [0.15.14.0] - 2026-04-05
|
## [0.15.14.0] - 2026-04-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -277,6 +277,59 @@ gstack skills have voice-friendly trigger phrases. Say what you want naturally
|
|||||||
"run a security check", "test the website", "do an engineering review" — and the
|
"run a security check", "test the website", "do an engineering review" — and the
|
||||||
right skill activates. You don't need to remember slash command names or acronyms.
|
right skill activates. You don't need to remember slash command names or acronyms.
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
### Option 1: Run the uninstall script
|
||||||
|
|
||||||
|
If gstack is installed on your machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/gstack/bin/gstack-uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
This handles skills, symlinks, global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics. Use `--force` to skip confirmation.
|
||||||
|
|
||||||
|
### Option 2: Manual removal (no local repo)
|
||||||
|
|
||||||
|
If you don't have the repo cloned (e.g. you installed via a Claude Code paste and later deleted the clone):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop browse daemons
|
||||||
|
pkill -f "gstack.*browse" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 2. Remove per-skill symlinks pointing into gstack/
|
||||||
|
find ~/.claude/skills -maxdepth 1 -type l 2>/dev/null | while read -r link; do
|
||||||
|
case "$(readlink "$link" 2>/dev/null)" in gstack/*|*/gstack/*) rm -f "$link" ;; esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Remove gstack
|
||||||
|
rm -rf ~/.claude/skills/gstack
|
||||||
|
|
||||||
|
# 4. Remove global state
|
||||||
|
rm -rf ~/.gstack
|
||||||
|
|
||||||
|
# 5. Remove integrations (skip any you never installed)
|
||||||
|
rm -rf ~/.codex/skills/gstack* 2>/dev/null
|
||||||
|
rm -rf ~/.factory/skills/gstack* 2>/dev/null
|
||||||
|
rm -rf ~/.kiro/skills/gstack* 2>/dev/null
|
||||||
|
rm -rf ~/.openclaw/skills/gstack* 2>/dev/null
|
||||||
|
|
||||||
|
# 6. Remove temp files
|
||||||
|
rm -f /tmp/gstack-* 2>/dev/null
|
||||||
|
|
||||||
|
# 7. Per-project cleanup (run from each project root)
|
||||||
|
rm -rf .gstack .gstack-worktrees .claude/skills/gstack 2>/dev/null
|
||||||
|
rm -rf .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean up CLAUDE.md
|
||||||
|
|
||||||
|
The uninstall script does not edit CLAUDE.md. In each project where gstack was added, remove the `## gstack` and `## Skill routing` sections.
|
||||||
|
|
||||||
|
### Playwright
|
||||||
|
|
||||||
|
`~/Library/Caches/ms-playwright/` (macOS) is left in place because other tools may share it. Remove it if nothing else needs it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Free, MIT licensed, open source. No premium tier, no waitlist.
|
Free, MIT licensed, open source. No premium tier, no waitlist.
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ if [ ${#FILES[@]} -eq 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Process all files through bun for JSON parsing, decay, dedup, filtering
|
# Process all files through bun for JSON parsing, decay, dedup, filtering
|
||||||
cat "${FILES[@]}" 2>/dev/null | bun -e "
|
GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" \
|
||||||
|
cat "${FILES[@]}" 2>/dev/null | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun -e "
|
||||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const type = '${TYPE}';
|
const type = process.env.GSTACK_SEARCH_TYPE || '';
|
||||||
const query = '${QUERY}'.toLowerCase();
|
const query = (process.env.GSTACK_SEARCH_QUERY || '').toLowerCase();
|
||||||
const limit = ${LIMIT};
|
const limit = parseInt(process.env.GSTACK_SEARCH_LIMIT || '10', 10);
|
||||||
const slug = '${SLUG}';
|
const slug = process.env.GSTACK_SEARCH_SLUG || '';
|
||||||
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -67,7 +68,7 @@ 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) && '${CROSS_PROJECT}' === 'true';
|
e._crossProject = !line.includes(slug) && process.env.GSTACK_SEARCH_CROSS === 'true';
|
||||||
|
|
||||||
entries.push(e);
|
entries.push(e);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ case "$HTTP_CODE" in
|
|||||||
# Advance by SENT count (not inserted count) because we can't map inserted back to
|
# Advance by SENT count (not inserted count) because we can't map inserted back to
|
||||||
# source lines. If inserted==0, something is systemically wrong — don't advance.
|
# source lines. If inserted==0, something is systemically wrong — don't advance.
|
||||||
INSERTED="$(grep -o '"inserted":[0-9]*' "$RESP_FILE" 2>/dev/null | grep -o '[0-9]*' || echo "0")"
|
INSERTED="$(grep -o '"inserted":[0-9]*' "$RESP_FILE" 2>/dev/null | grep -o '[0-9]*' || echo "0")"
|
||||||
|
# Check for upsert errors (installation tracking failures) — log but don't block cursor advance
|
||||||
|
UPSERT_ERRORS="$(grep -o '"upsertErrors"' "$RESP_FILE" 2>/dev/null || true)"
|
||||||
|
if [ -n "$UPSERT_ERRORS" ]; then
|
||||||
|
echo "[gstack-telemetry-sync] Warning: installation upsert errors in response" >&2
|
||||||
|
fi
|
||||||
if [ "${INSERTED:-0}" -gt 0 ] 2>/dev/null; then
|
if [ "${INSERTED:-0}" -gt 0 ] 2>/dev/null; then
|
||||||
NEW_CURSOR=$(( CURSOR + COUNT ))
|
NEW_CURSOR=$(( CURSOR + COUNT ))
|
||||||
echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true
|
echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true
|
||||||
|
|||||||
@@ -826,11 +826,11 @@ export class BrowserManager {
|
|||||||
// a tampered URL could navigate to cloud metadata endpoints or file:// URIs.
|
// a tampered URL could navigate to cloud metadata endpoints or file:// URIs.
|
||||||
try {
|
try {
|
||||||
await validateNavigationUrl(saved.url);
|
await validateNavigationUrl(saved.url);
|
||||||
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
} catch (err: any) {
|
||||||
} catch {
|
console.warn(`[browse] Skipping invalid URL in state file: ${saved.url} — ${err.message}`);
|
||||||
// Invalid URL in saved state — skip navigation, leave blank page
|
continue;
|
||||||
console.log(`[browse] restoreState: skipping unsafe URL: ${saved.url}`);
|
|
||||||
}
|
}
|
||||||
|
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (saved.storage) {
|
if (saved.storage) {
|
||||||
|
|||||||
@@ -472,6 +472,12 @@ export async function modifyStyle(
|
|||||||
throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`);
|
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 oldValue = '';
|
||||||
let source = 'inline';
|
let source = 'inline';
|
||||||
let sourceLine = 0;
|
let sourceLine = 0;
|
||||||
|
|||||||
@@ -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
|
// 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
|
// with { detached: true } instead, which is the gold standard for Windows
|
||||||
// process independence. Credit: PR #191 by @fqueiro.
|
// 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 launcherCode =
|
||||||
`const{spawn}=require('child_process');` +
|
`const{spawn}=require('child_process');` +
|
||||||
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
||||||
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
|
`{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'] });
|
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||||
} else {
|
} else {
|
||||||
// macOS/Linux: Bun.spawn + unref works correctly
|
// macOS/Linux: Bun.spawn + unref works correctly
|
||||||
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
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();
|
proc.unref();
|
||||||
}
|
}
|
||||||
@@ -587,7 +588,10 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
}
|
}
|
||||||
// Clear old agent queue
|
// Clear old agent queue
|
||||||
const agentQueue = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
|
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
|
// Resolve browse binary path the same way — execPath-relative
|
||||||
let browseBin = path.resolve(__dirname, '..', 'dist', 'browse');
|
let browseBin = path.resolve(__dirname, '..', 'dist', 'browse');
|
||||||
|
|||||||
@@ -15,16 +15,40 @@ import { resolveConfig } from './config';
|
|||||||
import type { Frame } from 'playwright';
|
import type { Frame } from 'playwright';
|
||||||
|
|
||||||
// Security: Path validation to prevent path traversal attacks
|
// 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 {
|
export function validateOutputPath(filePath: string): void {
|
||||||
const resolved = path.resolve(filePath);
|
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) {
|
if (!isSafe) {
|
||||||
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
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. */
|
/** Tokenize a pipe segment respecting double-quoted strings. */
|
||||||
function tokenizePipeSegment(segment: string): string[] {
|
function tokenizePipeSegment(segment: string): string[] {
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
@@ -195,9 +219,10 @@ export async function handleMetaCommand(
|
|||||||
|
|
||||||
for (const vp of viewports) {
|
for (const vp of viewports) {
|
||||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||||
const path = `${prefix}-${vp.name}.png`;
|
const screenshotPath = `${prefix}-${vp.name}.png`;
|
||||||
await page.screenshot({ path, fullPage: true });
|
validateOutputPath(screenshotPath);
|
||||||
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||||
|
results.push(`${vp.name} (${vp.width}x${vp.height}): ${screenshotPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore original viewport
|
// Restore original viewport
|
||||||
@@ -238,7 +263,11 @@ export async function handleMetaCommand(
|
|||||||
try {
|
try {
|
||||||
let result: string;
|
let result: string;
|
||||||
if (WRITE_COMMANDS.has(name)) {
|
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;
|
lastWasWrite = true;
|
||||||
} else if (READ_COMMANDS.has(name)) {
|
} else if (READ_COMMANDS.has(name)) {
|
||||||
result = await handleReadCommand(name, cmdArgs, bm);
|
result = await handleReadCommand(name, cmdArgs, bm);
|
||||||
@@ -443,8 +472,8 @@ export async function handleMetaCommand(
|
|||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
|
const ts = msg.timestamp ? `[${msg.timestamp}]` : '[unknown]';
|
||||||
lines.push(`${ts} ${msg.url}`);
|
lines.push(`${ts} ${wrapUntrustedContent(msg.url, 'inbox-url')}`);
|
||||||
lines.push(` "${msg.userMessage}"`);
|
lines.push(` "${wrapUntrustedContent(msg.userMessage, 'inbox-message')}"`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +524,18 @@ export async function handleMetaCommand(
|
|||||||
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
|
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
|
||||||
throw new Error('Invalid state file: expected cookies and pages arrays');
|
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
|
// Warn on state files older than 7 days
|
||||||
if (data.savedAt) {
|
if (data.savedAt) {
|
||||||
const ageMs = Date.now() - new Date(data.savedAt).getTime();
|
const ageMs = Date.now() - new Date(data.savedAt).getTime();
|
||||||
@@ -507,7 +548,7 @@ export async function handleMetaCommand(
|
|||||||
bm.setFrame(null);
|
bm.setFrame(null);
|
||||||
await bm.closeAllPages();
|
await bm.closeAllPages();
|
||||||
await bm.restoreState({
|
await bm.restoreState({
|
||||||
cookies: data.cookies,
|
cookies: validatedCookies,
|
||||||
pages: data.pages.map((p: any) => ({ ...p, storage: null })),
|
pages: data.pages.map((p: any) => ({ ...p, storage: null })),
|
||||||
});
|
});
|
||||||
return `State loaded: ${data.cookies.length} cookies, ${data.pages.length} pages`;
|
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] });
|
frame = page.frame({ name: args[1] });
|
||||||
} else if (target === '--url') {
|
} else if (target === '--url') {
|
||||||
if (!args[1]) throw new Error('Usage: frame --url <pattern>');
|
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 {
|
} else {
|
||||||
// CSS selector or @ref for the iframe element
|
// CSS selector or @ref for the iframe element
|
||||||
const resolved = await bm.resolveRef(target);
|
const resolved = await bm.resolveRef(target);
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import * as path from 'path';
|
|||||||
import { TEMP_DIR, isPathWithin } from './platform';
|
import { TEMP_DIR, isPathWithin } from './platform';
|
||||||
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
|
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). */
|
/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
|
||||||
function hasAwait(code: string): boolean {
|
function hasAwait(code: string): boolean {
|
||||||
const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
@@ -300,7 +304,14 @@ export async function handleReadCommand(
|
|||||||
|
|
||||||
case 'cookies': {
|
case 'cookies': {
|
||||||
const cookies = await page.context().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': {
|
case 'storage': {
|
||||||
|
|||||||
@@ -282,6 +282,10 @@ function loadSession(): SidebarSession | null {
|
|||||||
try {
|
try {
|
||||||
const activeFile = path.join(SESSIONS_DIR, 'active.json');
|
const activeFile = path.join(SESSIONS_DIR, 'active.json');
|
||||||
const activeData = JSON.parse(fs.readFileSync(activeFile, 'utf-8'));
|
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 sessionFile = path.join(SESSIONS_DIR, activeData.id, 'session.json');
|
||||||
const session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as SidebarSession;
|
const session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as SidebarSession;
|
||||||
// Validate worktree still exists — crash may have left stale path
|
// Validate worktree still exists — crash may have left stale path
|
||||||
@@ -560,6 +564,7 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId
|
|||||||
try {
|
try {
|
||||||
fs.mkdirSync(gstackDir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(gstackDir, { recursive: true, mode: 0o700 });
|
||||||
fs.appendFileSync(agentQueue, entry + '\n');
|
fs.appendFileSync(agentQueue, entry + '\n');
|
||||||
|
try { fs.chmodSync(agentQueue, 0o600); } catch {}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
|
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
|
||||||
agentStatus = 'idle';
|
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.
|
// Agent status transitions happen when we receive agent_done/agent_error events.
|
||||||
}
|
}
|
||||||
|
|
||||||
function killAgent(): void {
|
function killAgent(targetTabId?: number | null): void {
|
||||||
if (agentProcess) {
|
if (agentProcess) {
|
||||||
try { agentProcess.kill('SIGTERM'); } catch (err: any) {
|
try { agentProcess.kill('SIGTERM'); } catch (err: any) {
|
||||||
console.warn('[browse] Failed to SIGTERM agent:', err.message);
|
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);
|
console.warn('[browse] Failed to SIGKILL agent:', err.message);
|
||||||
} }, 3000);
|
} }, 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;
|
agentProcess = null;
|
||||||
agentStartTime = null;
|
agentStartTime = null;
|
||||||
currentMessage = null;
|
currentMessage = null;
|
||||||
agentStatus = 'idle';
|
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
|
// Agent health check — detect hung processes
|
||||||
@@ -691,6 +697,23 @@ const idleCheckInterval = setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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) ───
|
// ─── Command Sets (from commands.ts — single source of truth) ───
|
||||||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||||||
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
||||||
@@ -1060,12 +1083,13 @@ async function start() {
|
|||||||
const welcomePath = (() => {
|
const welcomePath = (() => {
|
||||||
// Check project-local designs first, then global
|
// Check project-local designs first, then global
|
||||||
const slug = process.env.GSTACK_SLUG || 'unknown';
|
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) {
|
try { if (require('fs').existsSync(projectWelcome)) return projectWelcome; } catch (err: any) {
|
||||||
console.warn('[browse] Error checking project welcome page:', err.message);
|
console.warn('[browse] Error checking project welcome page:', err.message);
|
||||||
}
|
}
|
||||||
// Fallback: built-in welcome page from gstack install
|
// 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`;
|
const builtinWelcome = `${skillRoot}/browse/src/welcome.html`;
|
||||||
try { if (require('fs').existsSync(builtinWelcome)) return builtinWelcome; } catch (err: any) {
|
try { if (require('fs').existsSync(builtinWelcome)) return builtinWelcome; } catch (err: any) {
|
||||||
console.warn('[browse] Error checking builtin welcome page:', err.message);
|
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);
|
console.error('[browse] Failed to read welcome page:', welcomePath, err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No welcome page found — redirect to about:blank
|
// No welcome page found — serve a simple fallback (avoid ERR_UNSAFE_REDIRECT on Windows)
|
||||||
return new Response('', { status: 302, headers: { 'Location': 'about:blank' } });
|
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
|
// Health check — no auth required, does NOT reset idle timer
|
||||||
@@ -1092,17 +1122,18 @@ async function start() {
|
|||||||
mode: browserManager.getConnectionMode(),
|
mode: browserManager.getConnectionMode(),
|
||||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||||
tabs: browserManager.getTabCount(),
|
tabs: browserManager.getTabCount(),
|
||||||
currentUrl: browserManager.getCurrentUrl(),
|
// Auth token for extension bootstrap. Safe: /health is localhost-only.
|
||||||
// Auth token for extension bootstrap. Only returned when the request
|
|
||||||
// comes from a Chrome extension (Origin: chrome-extension://...).
|
|
||||||
// Previously served unconditionally, but that leaks the token if the
|
// Previously served unconditionally, but that leaks the token if the
|
||||||
// server is tunneled to the internet (ngrok, SSH tunnel).
|
// 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,
|
chatEnabled: true,
|
||||||
agent: {
|
agent: {
|
||||||
status: agentStatus,
|
status: agentStatus,
|
||||||
runningFor: agentStartTime ? Date.now() - agentStartTime : null,
|
runningFor: agentStartTime ? Date.now() - agentStartTime : null,
|
||||||
currentMessage,
|
|
||||||
queueLength: messageQueue.length,
|
queueLength: messageQueue.length,
|
||||||
},
|
},
|
||||||
session: sidebarSession ? { id: sidebarSession.id, name: sidebarSession.name } : null,
|
session: sidebarSession ? { id: sidebarSession.id, name: sidebarSession.name } : null,
|
||||||
@@ -1223,9 +1254,10 @@ async function start() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Sync active tab from Chrome extension — detects manual tab switches
|
// Sync active tab from Chrome extension — detects manual tab switches
|
||||||
const activeUrl = url.searchParams.get('activeUrl');
|
const rawActiveUrl = url.searchParams.get('activeUrl');
|
||||||
if (activeUrl) {
|
const sanitizedActiveUrl = sanitizeExtensionUrl(rawActiveUrl);
|
||||||
browserManager.syncActiveTabByUrl(activeUrl);
|
if (sanitizedActiveUrl) {
|
||||||
|
browserManager.syncActiveTabByUrl(sanitizedActiveUrl);
|
||||||
}
|
}
|
||||||
const tabs = await browserManager.getTabListWithTitles();
|
const tabs = await browserManager.getTabListWithTitles();
|
||||||
return new Response(JSON.stringify({ tabs }), {
|
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
|
// The Chrome extension sends the active tab's URL — prefer it over
|
||||||
// Playwright's page.url() which can be stale in headed mode when
|
// Playwright's page.url() which can be stale in headed mode when
|
||||||
// the user navigates manually.
|
// 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
|
// Sync active tab BEFORE reading the ID — the user may have switched
|
||||||
// tabs manually and the server's activeTabId is stale.
|
// tabs manually and the server's activeTabId is stale.
|
||||||
if (extensionUrl) {
|
if (sanitizedExtUrl) {
|
||||||
browserManager.syncActiveTabByUrl(extensionUrl);
|
browserManager.syncActiveTabByUrl(sanitizedExtUrl);
|
||||||
}
|
}
|
||||||
const msgTabId = browserManager?.getActiveTabId?.() ?? 0;
|
const msgTabId = browserManager?.getActiveTabId?.() ?? 0;
|
||||||
const ts = new Date().toISOString();
|
const ts = new Date().toISOString();
|
||||||
@@ -1308,12 +1341,12 @@ async function start() {
|
|||||||
// Per-tab agent: each tab can run its own agent concurrently
|
// Per-tab agent: each tab can run its own agent concurrently
|
||||||
const tabState = getTabAgent(msgTabId);
|
const tabState = getTabAgent(msgTabId);
|
||||||
if (tabState.status === 'idle') {
|
if (tabState.status === 'idle') {
|
||||||
spawnClaude(msg, extensionUrl, msgTabId);
|
spawnClaude(msg, sanitizedExtUrl, msgTabId);
|
||||||
return new Response(JSON.stringify({ ok: true, processing: true }), {
|
return new Response(JSON.stringify({ ok: true, processing: true }), {
|
||||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
} else if (tabState.queue.length < MAX_QUEUE) {
|
} 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 }), {
|
return new Response(JSON.stringify({ ok: true, queued: true, position: tabState.queue.length }), {
|
||||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
@@ -1344,7 +1377,8 @@ async function start() {
|
|||||||
if (!validateAuth(req)) {
|
if (!validateAuth(req)) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
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' });
|
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Killed by user' });
|
||||||
// Process next in queue
|
// Process next in queue
|
||||||
if (messageQueue.length > 0) {
|
if (messageQueue.length > 0) {
|
||||||
@@ -1359,7 +1393,8 @@ async function start() {
|
|||||||
if (!validateAuth(req)) {
|
if (!validateAuth(req)) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
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' });
|
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Stopped by user' });
|
||||||
return new Response(JSON.stringify({ ok: true, queuedMessages: messageQueue.length }), {
|
return new Response(JSON.stringify({ ok: true, queuedMessages: messageQueue.length }), {
|
||||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
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 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 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 lastLine = 0;
|
||||||
let authToken: string | null = null;
|
let authToken: string | null = null;
|
||||||
// Per-tab processing — each tab can run its own agent concurrently
|
// Per-tab processing — each tab can run its own agent concurrently
|
||||||
const processingTabs = new Set<number>();
|
const processingTabs = new Set<number>();
|
||||||
// Active claude subprocesses — keyed by tabId for targeted kill
|
// Active claude subprocesses — keyed by tabId for targeted kill
|
||||||
const activeProcs = new Map<number, ReturnType<typeof spawn>>();
|
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
|
// Kill-file timestamp last seen — avoids double-kill on same write
|
||||||
let lastKillTs = 0;
|
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 { prompt, args, stateFile, cwd, tabId } = queueEntry;
|
||||||
const tid = tabId ?? 0;
|
const tid = tabId ?? 0;
|
||||||
|
|
||||||
@@ -250,6 +288,10 @@ async function askClaude(queueEntry: any): Promise<void> {
|
|||||||
effectiveCwd = process.cwd();
|
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, {
|
const proc = spawn('claude', claudeArgs, {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
@@ -270,9 +312,23 @@ async function askClaude(queueEntry: any): Promise<void> {
|
|||||||
|
|
||||||
// Track active procs so kill-file polling can terminate them
|
// Track active procs so kill-file polling can terminate them
|
||||||
activeProcs.set(tid, proc);
|
activeProcs.set(tid, proc);
|
||||||
|
activeProc = proc;
|
||||||
|
|
||||||
proc.stdin.end();
|
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 = '';
|
let buffer = '';
|
||||||
|
|
||||||
proc.stdout.on('data', (data: Buffer) => {
|
proc.stdout.on('data', (data: Buffer) => {
|
||||||
@@ -293,6 +349,8 @@ async function askClaude(queueEntry: any): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on('close', (code) => {
|
||||||
|
clearInterval(cancelCheck);
|
||||||
|
activeProc = null;
|
||||||
activeProcs.delete(tid);
|
activeProcs.delete(tid);
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
try { handleStreamEvent(JSON.parse(buffer), tid); } catch (err: any) {
|
try { handleStreamEvent(JSON.parse(buffer), tid); } catch (err: any) {
|
||||||
@@ -310,6 +368,8 @@ async function askClaude(queueEntry: any): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (err) => {
|
proc.on('error', (err) => {
|
||||||
|
clearInterval(cancelCheck);
|
||||||
|
activeProc = null;
|
||||||
const errorMsg = stderrBuffer.trim()
|
const errorMsg = stderrBuffer.trim()
|
||||||
? `${err.message}\nstderr: ${stderrBuffer.trim().slice(-500)}`
|
? `${err.message}\nstderr: ${stderrBuffer.trim().slice(-500)}`
|
||||||
: err.message;
|
: err.message;
|
||||||
@@ -322,9 +382,10 @@ async function askClaude(queueEntry: any): Promise<void> {
|
|||||||
// Timeout (default 300s / 5 min — multi-page tasks need time)
|
// Timeout (default 300s / 5 min — multi-page tasks need time)
|
||||||
const timeoutMs = parseInt(process.env.SIDEBAR_AGENT_TIMEOUT || '300000', 10);
|
const timeoutMs = parseInt(process.env.SIDEBAR_AGENT_TIMEOUT || '300000', 10);
|
||||||
setTimeout(() => {
|
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);
|
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()
|
const timeoutMsg = stderrBuffer.trim()
|
||||||
? `Timed out after ${timeoutMs / 1000}s\nstderr: ${stderrBuffer.trim().slice(-500)}`
|
? `Timed out after ${timeoutMs / 1000}s\nstderr: ${stderrBuffer.trim().slice(-500)}`
|
||||||
: `Timed out after ${timeoutMs / 1000}s`;
|
: `Timed out after ${timeoutMs / 1000}s`;
|
||||||
@@ -366,12 +427,16 @@ async function poll() {
|
|||||||
const line = readLine(lastLine);
|
const line = readLine(lastLine);
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
|
|
||||||
let entry: any;
|
let parsed: unknown;
|
||||||
try { entry = JSON.parse(line); } catch (err: any) {
|
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);
|
console.warn(`[sidebar-agent] Skipping malformed queue entry at line ${lastLine}:`, line.slice(0, 80), err.message);
|
||||||
continue;
|
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;
|
const tid = entry.tabId ?? 0;
|
||||||
// Skip if this tab already has an agent running — server queues per-tab
|
// 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);
|
const dir = path.dirname(QUEUE);
|
||||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '', { mode: 0o600 });
|
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '', { mode: 0o600 });
|
||||||
|
try { fs.chmodSync(QUEUE, 0o600); } catch {}
|
||||||
|
|
||||||
lastLine = countLines();
|
lastLine = countLines();
|
||||||
await refreshToken();
|
await refreshToken();
|
||||||
|
|||||||
@@ -348,11 +348,32 @@ export async function handleSnapshot(
|
|||||||
// ─── Annotated screenshot (-a) ────────────────────────────
|
// ─── Annotated screenshot (-a) ────────────────────────────
|
||||||
if (opts.annotate) {
|
if (opts.annotate) {
|
||||||
const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`;
|
const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`;
|
||||||
// Validate output path (consistent with screenshot/pdf/responsive)
|
// Validate output path — resolve symlinks to prevent symlink traversal attacks
|
||||||
const resolvedPath = require('path').resolve(screenshotPath);
|
{
|
||||||
const safeDirs = [TEMP_DIR, process.cwd()];
|
const nodePath = require('path') as typeof import('path');
|
||||||
if (!safeDirs.some((dir: string) => isPathWithin(resolvedPath, dir))) {
|
const nodeFs = require('fs') as typeof import('fs');
|
||||||
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
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 {
|
try {
|
||||||
// Inject overlay divs at each ref's bounding box
|
// 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).
|
* Localhost and private IPs are allowed (primary use case: QA testing local dev servers).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const BLOCKED_METADATA_HOSTS = new Set([
|
export const BLOCKED_METADATA_HOSTS = new Set([
|
||||||
'169.254.169.254', // AWS/GCP/Azure instance metadata (IPv4 link-local)
|
'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
|
||||||
'fd00::', // IPv6 unique local (metadata in some cloud setups)
|
|
||||||
'::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
|
||||||
'metadata.google.internal', // GCP metadata
|
'metadata.google.internal', // GCP metadata
|
||||||
'metadata.azure.internal', // Azure IMDS
|
'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:
|
* Normalize hostname for blocklist comparison:
|
||||||
* - Strip trailing dot (DNS fully-qualified notation)
|
* - Strip trailing dot (DNS fully-qualified notation)
|
||||||
@@ -37,7 +56,7 @@ function isMetadataIp(hostname: string): boolean {
|
|||||||
try {
|
try {
|
||||||
const probe = new URL(`http://${hostname}`);
|
const probe = new URL(`http://${hostname}`);
|
||||||
const normalized = probe.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
|
// Also check after stripping trailing dot
|
||||||
if (normalized.endsWith('.') && BLOCKED_METADATA_HOSTS.has(normalized.slice(0, -1))) return true;
|
if (normalized.endsWith('.') && BLOCKED_METADATA_HOSTS.has(normalized.slice(0, -1))) return true;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -69,7 +88,7 @@ async function resolvesToBlockedIp(hostname: string): Promise<boolean> {
|
|||||||
const v6Check = resolve6(hostname).then(
|
const v6Check = resolve6(hostname).then(
|
||||||
(addresses) => addresses.some(addr => {
|
(addresses) => addresses.some(addr => {
|
||||||
const normalized = addr.toLowerCase();
|
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)
|
// fe80::/10 is link-local — always block (covers all fe80:: addresses)
|
||||||
normalized.startsWith('fe80:');
|
normalized.startsWith('fe80:');
|
||||||
}),
|
}),
|
||||||
@@ -100,7 +119,7 @@ export async function validateNavigationUrl(url: string): Promise<void> {
|
|||||||
|
|
||||||
const hostname = normalizeHostname(parsed.hostname.toLowerCase());
|
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(
|
throw new Error(
|
||||||
`Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.`
|
`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';
|
import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector';
|
||||||
|
|
||||||
// Security: Path validation for screenshot output
|
// 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 {
|
function validateOutputPath(filePath: string): void {
|
||||||
const resolved = path.resolve(filePath);
|
const resolved = path.resolve(filePath);
|
||||||
@@ -326,7 +329,9 @@ export async function handleWriteCommand(
|
|||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (!selector) throw new Error('Usage: browse wait <selector|--networkidle|--load|--domcontentloaded>');
|
if (!selector) throw new Error('Usage: browse wait <selector|--networkidle|--load|--domcontentloaded>');
|
||||||
if (selector === '--networkidle') {
|
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 });
|
await page.waitForLoadState('networkidle', { timeout });
|
||||||
return 'Network idle';
|
return 'Network idle';
|
||||||
}
|
}
|
||||||
@@ -338,7 +343,9 @@ export async function handleWriteCommand(
|
|||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
return 'DOM content loaded';
|
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);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.waitFor({ state: 'visible', timeout });
|
await resolved.locator.waitFor({ state: 'visible', timeout });
|
||||||
@@ -351,7 +358,9 @@ export async function handleWriteCommand(
|
|||||||
case 'viewport': {
|
case 'viewport': {
|
||||||
const size = args[0];
|
const size = args[0];
|
||||||
if (!size || !size.includes('x')) throw new Error('Usage: browse viewport <WxH> (e.g., 375x812)');
|
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);
|
await bm.setViewport(w, h);
|
||||||
return `Viewport set to ${w}x${h}`;
|
return `Viewport set to ${w}x${h}`;
|
||||||
}
|
}
|
||||||
@@ -399,9 +408,19 @@ export async function handleWriteCommand(
|
|||||||
const [selector, ...filePaths] = args;
|
const [selector, ...filePaths] = args;
|
||||||
if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2...]');
|
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) {
|
for (const fp of filePaths) {
|
||||||
if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
|
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);
|
const resolved = await bm.resolveRef(selector);
|
||||||
@@ -459,7 +478,14 @@ export async function handleWriteCommand(
|
|||||||
|
|
||||||
for (const c of cookies) {
|
for (const c of cookies) {
|
||||||
if (!c.name || c.value === undefined) throw new Error('Each cookie must have "name" and "value" fields');
|
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 = '/';
|
if (!c.path) c.path = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +505,12 @@ export async function handleWriteCommand(
|
|||||||
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
||||||
// Direct import mode — no UI
|
// Direct import mode — no UI
|
||||||
const domain = args[domainIdx + 1];
|
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 browser = browserArg || 'comet';
|
||||||
const result = await importCookies(browser, [domain], profile);
|
const result = await importCookies(browser, [domain], profile);
|
||||||
if (result.cookies.length > 0) {
|
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.`);
|
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);
|
const mod = await modifyStyle(page, selector, property, value);
|
||||||
return `Style modified: ${selector} { ${property}: ${mod.oldValue || '(none)'} → ${value} } (${mod.method})`;
|
return `Style modified: ${selector} { ${property}: ${mod.oldValue || '(none)'} → ${value} } (${mod.method})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1577,7 +1577,8 @@ describe('Cookie import', () => {
|
|||||||
test('cookie-import preserves explicit domain', async () => {
|
test('cookie-import preserves explicit domain', async () => {
|
||||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||||
const tempFile = '/tmp/browse-test-cookies-domain.json';
|
const tempFile = '/tmp/browse-test-cookies-domain.json';
|
||||||
const cookies = [{ name: 'explicit', value: 'domain', domain: 'example.com', path: '/foo' }];
|
// Domain must match page hostname (127.0.0.1) — cross-domain cookies are now rejected
|
||||||
|
const cookies = [{ name: 'explicit', value: 'domain', domain: '127.0.0.1', path: '/foo' }];
|
||||||
fs.writeFileSync(tempFile, JSON.stringify(cookies));
|
fs.writeFileSync(tempFile, JSON.stringify(cookies));
|
||||||
|
|
||||||
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
const result = await handleWriteCommand('cookie-import', [tempFile], bm);
|
||||||
@@ -1837,7 +1838,7 @@ describe('Chain with cookie-import', () => {
|
|||||||
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
|
||||||
const tmpCookies = '/tmp/test-chain-cookies.json';
|
const tmpCookies = '/tmp/test-chain-cookies.json';
|
||||||
fs.writeFileSync(tmpCookies, JSON.stringify([
|
fs.writeFileSync(tmpCookies, JSON.stringify([
|
||||||
{ name: 'chain_test', value: 'chain_value', domain: 'localhost', path: '/' }
|
{ name: 'chain_test', value: 'chain_value', domain: '127.0.0.1', path: '/' }
|
||||||
]));
|
]));
|
||||||
try {
|
try {
|
||||||
const commands = JSON.stringify([
|
const commands = JSON.stringify([
|
||||||
|
|||||||
33
browse/test/learnings-injection.test.ts
Normal file
33
browse/test/learnings-injection.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
|
||||||
|
const SCRIPT_PATH = path.join(import.meta.dir, '../../bin/gstack-learnings-search');
|
||||||
|
const SCRIPT = fs.readFileSync(SCRIPT_PATH, 'utf-8');
|
||||||
|
const BIN_DIR = path.join(import.meta.dir, '../../bin');
|
||||||
|
|
||||||
|
describe('gstack-learnings-search injection safety', () => {
|
||||||
|
it('must not interpolate variables into JS string literals', () => {
|
||||||
|
const jsBlock = SCRIPT.slice(SCRIPT.indexOf('bun -e'));
|
||||||
|
expect(jsBlock).not.toMatch(/const \w+ = '\$\{/);
|
||||||
|
expect(jsBlock).not.toMatch(/= \$\{[A-Z_]+\};/);
|
||||||
|
expect(jsBlock).not.toMatch(/'\$\{CROSS_PROJECT\}'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('must use process.env for parameters', () => {
|
||||||
|
const jsBlock = SCRIPT.slice(SCRIPT.indexOf('bun -e'));
|
||||||
|
expect(jsBlock).toContain('process.env');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gstack-learnings-search injection behavioral', () => {
|
||||||
|
it('handles single quotes in query safely', () => {
|
||||||
|
const result = spawnSync('bash', [
|
||||||
|
path.join(BIN_DIR, 'gstack-learnings-search'),
|
||||||
|
'--query', "test'; process.exit(99); //",
|
||||||
|
'--limit', '1'
|
||||||
|
], { encoding: 'utf-8', timeout: 5000, env: { ...process.env, HOME: '/tmp/nonexistent-gstack-test' } });
|
||||||
|
expect(result.status).not.toBe(99);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect } from 'bun:test';
|
import { describe, it, expect } from 'bun:test';
|
||||||
import { validateOutputPath } from '../src/meta-commands';
|
import { validateOutputPath } from '../src/meta-commands';
|
||||||
import { validateReadPath } from '../src/read-commands';
|
import { validateReadPath, SENSITIVE_COOKIE_NAME, SENSITIVE_COOKIE_VALUE } from '../src/read-commands';
|
||||||
import { symlinkSync, unlinkSync, writeFileSync } from 'fs';
|
import { BLOCKED_METADATA_HOSTS } from '../src/url-validation';
|
||||||
|
import { readFileSync, symlinkSync, unlinkSync, writeFileSync, realpathSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
@@ -35,6 +36,26 @@ describe('validateOutputPath', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('upload command path validation', () => {
|
||||||
|
const src = readFileSync(join(__dirname, '..', 'src', 'write-commands.ts'), 'utf-8');
|
||||||
|
|
||||||
|
it('validates upload paths with isPathWithin', () => {
|
||||||
|
const uploadBlock = src.slice(src.indexOf("case 'upload'"), src.indexOf("case 'dialog-accept'"));
|
||||||
|
expect(uploadBlock).toContain('isPathWithin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks path traversal in upload', () => {
|
||||||
|
const uploadBlock = src.slice(src.indexOf("case 'upload'"), src.indexOf("case 'dialog-accept'"));
|
||||||
|
expect(uploadBlock).toContain("'..'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks absolute paths against safe directories', () => {
|
||||||
|
const uploadBlock = src.slice(src.indexOf("case 'upload'"), src.indexOf("case 'dialog-accept'"));
|
||||||
|
expect(uploadBlock).toContain('path.isAbsolute');
|
||||||
|
expect(uploadBlock).toContain('SAFE_DIRECTORIES');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('validateReadPath', () => {
|
describe('validateReadPath', () => {
|
||||||
it('allows absolute paths within /tmp', () => {
|
it('allows absolute paths within /tmp', () => {
|
||||||
expect(() => validateReadPath('/tmp/script.js')).not.toThrow();
|
expect(() => validateReadPath('/tmp/script.js')).not.toThrow();
|
||||||
@@ -89,3 +110,85 @@ describe('validateReadPath', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validateOutputPath — symlink resolution', () => {
|
||||||
|
it('blocks symlink inside /tmp pointing outside safe dirs', () => {
|
||||||
|
const linkPath = join(tmpdir(), 'test-output-symlink-' + Date.now() + '.png');
|
||||||
|
try {
|
||||||
|
symlinkSync('/etc/crontab', linkPath);
|
||||||
|
expect(() => validateOutputPath(linkPath)).toThrow(/Path must be within/);
|
||||||
|
} finally {
|
||||||
|
try { unlinkSync(linkPath); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows symlink inside /tmp pointing to another /tmp path', () => {
|
||||||
|
// Use /tmp (TEMP_DIR on macOS/Linux), not os.tmpdir() which may be a different path
|
||||||
|
const realTmp = realpathSync('/tmp');
|
||||||
|
const targetPath = join(realTmp, 'test-output-real-' + Date.now() + '.png');
|
||||||
|
const linkPath = join(realTmp, 'test-output-link-' + Date.now() + '.png');
|
||||||
|
try {
|
||||||
|
writeFileSync(targetPath, '');
|
||||||
|
symlinkSync(targetPath, linkPath);
|
||||||
|
expect(() => validateOutputPath(linkPath)).not.toThrow();
|
||||||
|
} finally {
|
||||||
|
try { unlinkSync(linkPath); } catch {}
|
||||||
|
try { unlinkSync(targetPath); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks new file in symlinked directory pointing outside', () => {
|
||||||
|
const linkDir = join(tmpdir(), 'test-dirlink-' + Date.now());
|
||||||
|
try {
|
||||||
|
symlinkSync('/etc', linkDir);
|
||||||
|
expect(() => validateOutputPath(join(linkDir, 'evil.png'))).toThrow(/Path must be within/);
|
||||||
|
} finally {
|
||||||
|
try { unlinkSync(linkDir); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cookie redaction — production patterns', () => {
|
||||||
|
it('detects sensitive cookie names', () => {
|
||||||
|
expect(SENSITIVE_COOKIE_NAME.test('session_id')).toBe(true);
|
||||||
|
expect(SENSITIVE_COOKIE_NAME.test('auth_token')).toBe(true);
|
||||||
|
expect(SENSITIVE_COOKIE_NAME.test('csrf-token')).toBe(true);
|
||||||
|
expect(SENSITIVE_COOKIE_NAME.test('api_key')).toBe(true);
|
||||||
|
expect(SENSITIVE_COOKIE_NAME.test('jwt.payload')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-sensitive cookie names', () => {
|
||||||
|
expect(SENSITIVE_COOKIE_NAME.test('theme')).toBe(false);
|
||||||
|
expect(SENSITIVE_COOKIE_NAME.test('locale')).toBe(false);
|
||||||
|
expect(SENSITIVE_COOKIE_NAME.test('_ga')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects sensitive cookie value prefixes', () => {
|
||||||
|
expect(SENSITIVE_COOKIE_VALUE.test('eyJhbGciOiJIUzI1NiJ9')).toBe(true); // JWT
|
||||||
|
expect(SENSITIVE_COOKIE_VALUE.test('sk-ant-abc123')).toBe(true); // Anthropic
|
||||||
|
expect(SENSITIVE_COOKIE_VALUE.test('ghp_xxxxxxxxxxxx')).toBe(true); // GitHub PAT
|
||||||
|
expect(SENSITIVE_COOKIE_VALUE.test('xoxb-token')).toBe(true); // Slack
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-sensitive values', () => {
|
||||||
|
expect(SENSITIVE_COOKIE_VALUE.test('dark')).toBe(false);
|
||||||
|
expect(SENSITIVE_COOKIE_VALUE.test('en-US')).toBe(false);
|
||||||
|
expect(SENSITIVE_COOKIE_VALUE.test('1234567890')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DNS rebinding — production blocklist', () => {
|
||||||
|
it('blocks fd00:: IPv6 metadata address via validateNavigationUrl', async () => {
|
||||||
|
const { validateNavigationUrl } = await import('../src/url-validation');
|
||||||
|
await expect(validateNavigationUrl('http://[fd00::]/')).rejects.toThrow(/cloud metadata/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks AWS/GCP IPv4 metadata address', () => {
|
||||||
|
expect(BLOCKED_METADATA_HOSTS.has('169.254.169.254')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block normal addresses', () => {
|
||||||
|
expect(BLOCKED_METADATA_HOSTS.has('8.8.8.8')).toBe(false);
|
||||||
|
expect(BLOCKED_METADATA_HOSTS.has('2001:4860:4860::8888')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
717
browse/test/security-audit-r2.test.ts
Normal file
717
browse/test/security-audit-r2.test.ts
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
/**
|
||||||
|
* Security audit round-2 tests — static source checks + behavioral verification.
|
||||||
|
*
|
||||||
|
* These tests verify that security fixes are present at the source level and
|
||||||
|
* behave correctly at runtime. Source-level checks guard against regressions
|
||||||
|
* that could silently remove a fix without breaking compilation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
// ─── Shared source reads (used across multiple test sections) ───────────────
|
||||||
|
const META_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/meta-commands.ts'), 'utf-8');
|
||||||
|
const WRITE_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/write-commands.ts'), 'utf-8');
|
||||||
|
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
|
||||||
|
const AGENT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'), 'utf-8');
|
||||||
|
const SNAPSHOT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/snapshot.ts'), 'utf-8');
|
||||||
|
|
||||||
|
// ─── Helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the source text between two string markers.
|
||||||
|
*/
|
||||||
|
function sliceBetween(src: string, startMarker: string, endMarker: string): string {
|
||||||
|
const start = src.indexOf(startMarker);
|
||||||
|
if (start === -1) return '';
|
||||||
|
const end = src.indexOf(endMarker, start + startMarker.length);
|
||||||
|
if (end === -1) return src.slice(start);
|
||||||
|
return src.slice(start, end + endMarker.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a function body by name — finds `function name(` or `export function name(`
|
||||||
|
* and returns the full balanced-brace block.
|
||||||
|
*/
|
||||||
|
function extractFunction(src: string, name: string): string {
|
||||||
|
const pattern = new RegExp(`(?:export\\s+)?function\\s+${name}\\s*\\(`);
|
||||||
|
const match = pattern.exec(src);
|
||||||
|
if (!match) return '';
|
||||||
|
let depth = 0;
|
||||||
|
let inBody = false;
|
||||||
|
const start = match.index;
|
||||||
|
for (let i = start; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') { depth++; inBody = true; }
|
||||||
|
else if (src[i] === '}') { depth--; }
|
||||||
|
if (inBody && depth === 0) return src.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
return src.slice(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Task 4: Agent queue poisoning — full schema validation + permissions ───
|
||||||
|
|
||||||
|
describe('Agent queue security', () => {
|
||||||
|
it('server queue directory must use restricted permissions', () => {
|
||||||
|
const queueSection = SERVER_SRC.slice(SERVER_SRC.indexOf('agentQueue'), SERVER_SRC.indexOf('agentQueue') + 2000);
|
||||||
|
expect(queueSection).toMatch(/0o700/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sidebar-agent queue directory must use restricted permissions', () => {
|
||||||
|
// The mkdirSync for the queue dir lives in main() — search the main() body
|
||||||
|
const mainStart = AGENT_SRC.indexOf('async function main');
|
||||||
|
const queueSection = AGENT_SRC.slice(mainStart);
|
||||||
|
expect(queueSection).toMatch(/0o700/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cli.ts queue file creation must use restricted permissions', () => {
|
||||||
|
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
|
||||||
|
const queueSection = CLI_SRC.slice(CLI_SRC.indexOf('queue') || 0, CLI_SRC.indexOf('queue') + 2000);
|
||||||
|
expect(queueSection).toMatch(/0o700|0o600|mode/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queue reader must have a validator function covering all fields', () => {
|
||||||
|
// Extract ONLY the validator function body by walking braces
|
||||||
|
const validatorStart = AGENT_SRC.indexOf('function isValidQueueEntry');
|
||||||
|
expect(validatorStart).toBeGreaterThan(-1);
|
||||||
|
let depth = 0;
|
||||||
|
let bodyStart = AGENT_SRC.indexOf('{', validatorStart);
|
||||||
|
let bodyEnd = bodyStart;
|
||||||
|
for (let i = bodyStart; i < AGENT_SRC.length; i++) {
|
||||||
|
if (AGENT_SRC[i] === '{') depth++;
|
||||||
|
if (AGENT_SRC[i] === '}') depth--;
|
||||||
|
if (depth === 0) { bodyEnd = i + 1; break; }
|
||||||
|
}
|
||||||
|
const validatorBlock = AGENT_SRC.slice(validatorStart, bodyEnd);
|
||||||
|
|
||||||
|
expect(validatorBlock).toMatch(/prompt.*string/);
|
||||||
|
expect(validatorBlock).toMatch(/Array\.isArray/);
|
||||||
|
expect(validatorBlock).toMatch(/\.\./);
|
||||||
|
expect(validatorBlock).toContain('stateFile');
|
||||||
|
expect(validatorBlock).toContain('tabId');
|
||||||
|
expect(validatorBlock).toMatch(/number/);
|
||||||
|
expect(validatorBlock).toContain('null');
|
||||||
|
expect(validatorBlock).toContain('message');
|
||||||
|
expect(validatorBlock).toContain('pageUrl');
|
||||||
|
expect(validatorBlock).toContain('sessionId');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Shared source reads for CSS validator tests ────────────────────────────
|
||||||
|
const CDP_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cdp-inspector.ts'), 'utf-8');
|
||||||
|
const EXTENSION_SRC = fs.readFileSync(
|
||||||
|
path.join(import.meta.dir, '../../extension/inspector.js'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Task 2: Shared CSS value validator ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Task 2: CSS value validator blocks dangerous patterns', () => {
|
||||||
|
describe('source-level checks', () => {
|
||||||
|
it('write-commands.ts style handler contains DANGEROUS_CSS url check', () => {
|
||||||
|
const styleBlock = sliceBetween(WRITE_SRC, "case 'style':", 'case \'cleanup\'');
|
||||||
|
expect(styleBlock).toMatch(/url\\s\*\\\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('write-commands.ts style handler blocks expression()', () => {
|
||||||
|
const styleBlock = sliceBetween(WRITE_SRC, "case 'style':", "case 'cleanup'");
|
||||||
|
expect(styleBlock).toMatch(/expression\\s\*\\\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('write-commands.ts style handler blocks @import', () => {
|
||||||
|
const styleBlock = sliceBetween(WRITE_SRC, "case 'style':", "case 'cleanup'");
|
||||||
|
expect(styleBlock).toContain('@import');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cdp-inspector.ts modifyStyle contains DANGEROUS_CSS url check', () => {
|
||||||
|
const fn = extractFunction(CDP_SRC, 'modifyStyle');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
expect(fn).toMatch(/url\\s\*\\\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cdp-inspector.ts modifyStyle blocks @import', () => {
|
||||||
|
const fn = extractFunction(CDP_SRC, 'modifyStyle');
|
||||||
|
expect(fn).toContain('@import');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extension injectCSS validates id format', () => {
|
||||||
|
const fn = extractFunction(EXTENSION_SRC, 'injectCSS');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
// Should contain a regex test for valid id characters
|
||||||
|
expect(fn).toMatch(/\^?\[a-zA-Z0-9_-\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extension injectCSS blocks dangerous CSS patterns', () => {
|
||||||
|
const fn = extractFunction(EXTENSION_SRC, 'injectCSS');
|
||||||
|
expect(fn).toMatch(/url\\s\*\\\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extension toggleClass validates className format', () => {
|
||||||
|
const fn = extractFunction(EXTENSION_SRC, 'toggleClass');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
expect(fn).toMatch(/\^?\[a-zA-Z0-9_-\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 1: Harden validateOutputPath to use realpathSync ──────────────────
|
||||||
|
|
||||||
|
describe('Task 1: validateOutputPath uses realpathSync', () => {
|
||||||
|
describe('source-level checks', () => {
|
||||||
|
it('meta-commands.ts validateOutputPath contains realpathSync', () => {
|
||||||
|
const fn = extractFunction(META_SRC, 'validateOutputPath');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
expect(fn).toContain('realpathSync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('write-commands.ts validateOutputPath contains realpathSync', () => {
|
||||||
|
const fn = extractFunction(WRITE_SRC, 'validateOutputPath');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
expect(fn).toContain('realpathSync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meta-commands.ts SAFE_DIRECTORIES resolves with realpathSync', () => {
|
||||||
|
const safeBlock = sliceBetween(META_SRC, 'const SAFE_DIRECTORIES', ';');
|
||||||
|
expect(safeBlock).toContain('realpathSync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('write-commands.ts SAFE_DIRECTORIES resolves with realpathSync', () => {
|
||||||
|
const safeBlock = sliceBetween(WRITE_SRC, 'const SAFE_DIRECTORIES', ';');
|
||||||
|
expect(safeBlock).toContain('realpathSync');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('behavioral checks', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let symlinkPath: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-sec-test-'));
|
||||||
|
symlinkPath = path.join(tmpDir, 'evil-link');
|
||||||
|
try {
|
||||||
|
fs.symlinkSync('/etc', symlinkPath);
|
||||||
|
} catch {
|
||||||
|
symlinkPath = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
try {
|
||||||
|
if (symlinkPath) fs.unlinkSync(symlinkPath);
|
||||||
|
fs.rmdirSync(tmpDir);
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meta-commands validateOutputPath rejects path through /etc symlink', async () => {
|
||||||
|
if (!symlinkPath) {
|
||||||
|
console.warn('Skipping: symlink creation failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mod = await import('../src/meta-commands.ts');
|
||||||
|
const attackPath = path.join(symlinkPath, 'passwd');
|
||||||
|
expect(() => mod.validateOutputPath(attackPath)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('realpathSync on symlink-to-/etc resolves to /etc (out of safe dirs)', () => {
|
||||||
|
if (!symlinkPath) {
|
||||||
|
console.warn('Skipping: symlink creation failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolvedLink = fs.realpathSync(symlinkPath);
|
||||||
|
// macOS: /etc -> /private/etc
|
||||||
|
expect(resolvedLink).toBe(fs.realpathSync('/etc'));
|
||||||
|
const TEMP_DIR_VAL = process.platform === 'win32' ? os.tmpdir() : '/tmp';
|
||||||
|
const safeDirs = [TEMP_DIR_VAL, process.cwd()].map(d => {
|
||||||
|
try { return fs.realpathSync(d); } catch { return d; }
|
||||||
|
});
|
||||||
|
const passwdReal = path.join(resolvedLink, 'passwd');
|
||||||
|
const isSafe = safeDirs.some(d => passwdReal === d || passwdReal.startsWith(d + path.sep));
|
||||||
|
expect(isSafe).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meta-commands validateOutputPath accepts legitimate tmpdir paths', async () => {
|
||||||
|
const mod = await import('../src/meta-commands.ts');
|
||||||
|
// Use /tmp (which resolves to /private/tmp on macOS) — matches SAFE_DIRECTORIES
|
||||||
|
const tmpBase = process.platform === 'darwin' ? '/tmp' : os.tmpdir();
|
||||||
|
const legitimatePath = path.join(tmpBase, 'gstack-screenshot.png');
|
||||||
|
expect(() => mod.validateOutputPath(legitimatePath)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meta-commands validateOutputPath accepts paths in cwd', async () => {
|
||||||
|
const mod = await import('../src/meta-commands.ts');
|
||||||
|
const cwdPath = path.join(process.cwd(), 'output.png');
|
||||||
|
expect(() => mod.validateOutputPath(cwdPath)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meta-commands validateOutputPath rejects paths outside safe dirs', async () => {
|
||||||
|
const mod = await import('../src/meta-commands.ts');
|
||||||
|
expect(() => mod.validateOutputPath('/home/user/secret.png')).toThrow(/Path must be within/);
|
||||||
|
expect(() => mod.validateOutputPath('/var/log/access.log')).toThrow(/Path must be within/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Round-2 review findings: applyStyle CSS check ──────────────────────────
|
||||||
|
|
||||||
|
describe('Round-2 finding 1: extension applyStyle blocks dangerous CSS values', () => {
|
||||||
|
const INSPECTOR_SRC = fs.readFileSync(
|
||||||
|
path.join(import.meta.dir, '../../extension/inspector.js'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
it('applyStyle function exists in inspector.js', () => {
|
||||||
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyStyle validates CSS value with url() block', () => {
|
||||||
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
||||||
|
// Source contains literal regex /url\s*\(/ — match the source-level escape sequence
|
||||||
|
expect(fn).toMatch(/url\\s\*\\\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyStyle blocks expression()', () => {
|
||||||
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
||||||
|
expect(fn).toMatch(/expression\\s\*\\\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyStyle blocks @import', () => {
|
||||||
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
||||||
|
expect(fn).toContain('@import');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyStyle blocks javascript: scheme', () => {
|
||||||
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
||||||
|
expect(fn).toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyStyle blocks data: scheme', () => {
|
||||||
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
||||||
|
expect(fn).toContain('data:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyStyle value check appears before setProperty call', () => {
|
||||||
|
const fn = extractFunction(INSPECTOR_SRC, 'applyStyle');
|
||||||
|
// Check that the CSS value guard (url\s*\() appears before setProperty
|
||||||
|
const valueCheckIdx = fn.search(/url\\s\*\\\(/);
|
||||||
|
const setPropIdx = fn.indexOf('setProperty');
|
||||||
|
expect(valueCheckIdx).toBeGreaterThan(-1);
|
||||||
|
expect(setPropIdx).toBeGreaterThan(-1);
|
||||||
|
expect(valueCheckIdx).toBeLessThan(setPropIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Round-2 finding 2: snapshot.ts annotated path uses realpathSync ────────
|
||||||
|
|
||||||
|
describe('Round-2 finding 2: snapshot.ts annotated path uses realpathSync', () => {
|
||||||
|
it('snapshot.ts annotated screenshot section contains realpathSync', () => {
|
||||||
|
// Slice the annotated screenshot block from the source
|
||||||
|
const annotateStart = SNAPSHOT_SRC.indexOf('opts.annotate');
|
||||||
|
expect(annotateStart).toBeGreaterThan(-1);
|
||||||
|
const annotateBlock = SNAPSHOT_SRC.slice(annotateStart, annotateStart + 2000);
|
||||||
|
expect(annotateBlock).toContain('realpathSync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snapshot.ts annotated path validation resolves safe dirs with realpathSync', () => {
|
||||||
|
const annotateStart = SNAPSHOT_SRC.indexOf('opts.annotate');
|
||||||
|
const annotateBlock = SNAPSHOT_SRC.slice(annotateStart, annotateStart + 2000);
|
||||||
|
// safeDirs array must be built with .map() that calls realpathSync
|
||||||
|
// Pattern: [TEMP_DIR, process.cwd()].map(...realpathSync...)
|
||||||
|
expect(annotateBlock).toContain('[TEMP_DIR, process.cwd()].map');
|
||||||
|
expect(annotateBlock).toContain('realpathSync');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Round-2 finding 3: stateFile path traversal check in isValidQueueEntry ─
|
||||||
|
|
||||||
|
describe('Round-2 finding 3: isValidQueueEntry checks stateFile for path traversal', () => {
|
||||||
|
it('isValidQueueEntry checks stateFile for .. traversal sequences', () => {
|
||||||
|
const fn = extractFunction(AGENT_SRC, 'isValidQueueEntry');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
// Must check stateFile for '..' — find the stateFile block and look for '..' string
|
||||||
|
const stateFileIdx = fn.indexOf('stateFile');
|
||||||
|
expect(stateFileIdx).toBeGreaterThan(-1);
|
||||||
|
const stateFileBlock = fn.slice(stateFileIdx, stateFileIdx + 200);
|
||||||
|
// The block must contain a check for the two-dot traversal sequence
|
||||||
|
expect(stateFileBlock).toMatch(/'\.\.'|"\.\."|\.\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isValidQueueEntry stateFile block contains both type check and traversal check', () => {
|
||||||
|
const fn = extractFunction(AGENT_SRC, 'isValidQueueEntry');
|
||||||
|
const stateFileIdx = fn.indexOf('stateFile');
|
||||||
|
const stateBlock = fn.slice(stateFileIdx, stateFileIdx + 300);
|
||||||
|
// Must contain the type check
|
||||||
|
expect(stateBlock).toContain('typeof obj.stateFile');
|
||||||
|
// Must contain the includes('..') call
|
||||||
|
expect(stateBlock).toMatch(/includes\s*\(\s*['"]\.\.['"]\s*\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 5: /health endpoint must not expose sensitive fields ───────────────
|
||||||
|
|
||||||
|
describe('/health endpoint security', () => {
|
||||||
|
it('must not expose currentMessage', () => {
|
||||||
|
const block = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/refs'");
|
||||||
|
expect(block).not.toContain('currentMessage');
|
||||||
|
});
|
||||||
|
it('must not expose currentUrl', () => {
|
||||||
|
const block = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/refs'");
|
||||||
|
expect(block).not.toContain('currentUrl');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 6: frame --url ReDoS fix ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('frame --url ReDoS fix', () => {
|
||||||
|
it('frame --url section does not pass raw user input to new RegExp()', () => {
|
||||||
|
const block = sliceBetween(META_SRC, "target === '--url'", 'else {');
|
||||||
|
expect(block).not.toMatch(/new RegExp\(args\[/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frame --url section uses escapeRegExp before constructing RegExp', () => {
|
||||||
|
const block = sliceBetween(META_SRC, "target === '--url'", 'else {');
|
||||||
|
expect(block).toContain('escapeRegExp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapeRegExp neutralizes catastrophic patterns (behavioral)', async () => {
|
||||||
|
const mod = await import('../src/meta-commands.ts');
|
||||||
|
const { escapeRegExp } = mod as any;
|
||||||
|
expect(typeof escapeRegExp).toBe('function');
|
||||||
|
const evil = '(a+)+$';
|
||||||
|
const escaped = escapeRegExp(evil);
|
||||||
|
const start = Date.now();
|
||||||
|
new RegExp(escaped).test('aaaaaaaaaaaaaaaaaaaaaaaaaaa!');
|
||||||
|
expect(Date.now() - start).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 7: watch-mode guard in chain command ───────────────────────────────
|
||||||
|
|
||||||
|
describe('chain command watch-mode guard', () => {
|
||||||
|
it('chain loop contains isWatching() guard before write dispatch', () => {
|
||||||
|
const block = sliceBetween(META_SRC, 'for (const cmd of commands)', 'Wait for network to settle');
|
||||||
|
expect(block).toContain('isWatching');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chain loop BLOCKED message appears for write commands in watch mode', () => {
|
||||||
|
const block = sliceBetween(META_SRC, 'for (const cmd of commands)', 'Wait for network to settle');
|
||||||
|
expect(block).toContain('BLOCKED: write commands disabled in watch mode');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 8: Cookie domain validation ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('cookie-import domain validation', () => {
|
||||||
|
it('cookie-import handler validates cookie domain against page domain', () => {
|
||||||
|
const block = sliceBetween(WRITE_SRC, "case 'cookie-import':", "case 'cookie-import-browser':");
|
||||||
|
expect(block).toContain('cookieDomain');
|
||||||
|
expect(block).toContain('defaultDomain');
|
||||||
|
expect(block).toContain('does not match current page domain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cookie-import-browser handler validates --domain against page hostname', () => {
|
||||||
|
const block = sliceBetween(WRITE_SRC, "case 'cookie-import-browser':", "case 'style':");
|
||||||
|
expect(block).toContain('normalizedDomain');
|
||||||
|
expect(block).toContain('pageHostname');
|
||||||
|
expect(block).toContain('does not match current page domain');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 9: loadSession ID validation ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('loadSession session ID validation', () => {
|
||||||
|
it('loadSession validates session ID format before using it in a path', () => {
|
||||||
|
const fn = extractFunction(SERVER_SRC, 'loadSession');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
// Must contain the alphanumeric regex guard
|
||||||
|
expect(fn).toMatch(/\[a-zA-Z0-9_-\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadSession returns null on invalid session ID', () => {
|
||||||
|
const fn = extractFunction(SERVER_SRC, 'loadSession');
|
||||||
|
const block = fn.slice(fn.indexOf('activeData.id'));
|
||||||
|
// Must warn and return null
|
||||||
|
expect(block).toContain('Invalid session ID');
|
||||||
|
expect(block).toContain('return null');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 10: Responsive screenshot path validation ──────────────────────────
|
||||||
|
|
||||||
|
describe('Task 10: responsive screenshot path validation', () => {
|
||||||
|
it('responsive loop contains validateOutputPath before page.screenshot()', () => {
|
||||||
|
// Extract the responsive case block
|
||||||
|
const block = sliceBetween(META_SRC, "case 'responsive':", 'Restore original viewport');
|
||||||
|
expect(block).toBeTruthy();
|
||||||
|
expect(block).toContain('validateOutputPath');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('responsive loop calls validateOutputPath on the per-viewport path, not just the prefix', () => {
|
||||||
|
const block = sliceBetween(META_SRC, 'for (const vp of viewports)', 'Restore original viewport');
|
||||||
|
expect(block).toContain('validateOutputPath');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateOutputPath appears before page.screenshot() in the loop', () => {
|
||||||
|
const block = sliceBetween(META_SRC, 'for (const vp of viewports)', 'Restore original viewport');
|
||||||
|
const validateIdx = block.indexOf('validateOutputPath');
|
||||||
|
const screenshotIdx = block.indexOf('page.screenshot');
|
||||||
|
expect(validateIdx).toBeGreaterThan(-1);
|
||||||
|
expect(screenshotIdx).toBeGreaterThan(-1);
|
||||||
|
expect(validateIdx).toBeLessThan(screenshotIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('results.push is present in the loop block (loop structure intact)', () => {
|
||||||
|
const block = sliceBetween(META_SRC, 'for (const vp of viewports)', 'Restore original viewport');
|
||||||
|
expect(block).toContain('results.push');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 11: State load — cookie + page URL validation ──────────────────────
|
||||||
|
|
||||||
|
const BROWSER_MANAGER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/browser-manager.ts'), 'utf-8');
|
||||||
|
|
||||||
|
describe('Task 11: state load cookie validation', () => {
|
||||||
|
it('state load block filters cookies by domain and type', () => {
|
||||||
|
const block = sliceBetween(META_SRC, "action === 'load'", "throw new Error('Usage: state save|load");
|
||||||
|
expect(block).toContain('cookie');
|
||||||
|
expect(block).toContain('domain');
|
||||||
|
expect(block).toContain('filter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('state load block checks for localhost and .internal in cookie domains', () => {
|
||||||
|
const block = sliceBetween(META_SRC, "action === 'load'", "throw new Error('Usage: state save|load");
|
||||||
|
expect(block).toContain('localhost');
|
||||||
|
expect(block).toContain('.internal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('state load block uses validatedCookies when calling restoreState', () => {
|
||||||
|
const block = sliceBetween(META_SRC, "action === 'load'", "throw new Error('Usage: state save|load");
|
||||||
|
expect(block).toContain('validatedCookies');
|
||||||
|
// Must pass validatedCookies to restoreState, not the raw data.cookies
|
||||||
|
const restoreIdx = block.indexOf('restoreState');
|
||||||
|
const restoreBlock = block.slice(restoreIdx, restoreIdx + 200);
|
||||||
|
expect(restoreBlock).toContain('validatedCookies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('browser-manager restoreState validates page URL before goto', () => {
|
||||||
|
// restoreState is a class method — use sliceBetween to extract the method body
|
||||||
|
const restoreFn = sliceBetween(BROWSER_MANAGER_SRC, 'async restoreState(', 'async recreateContext(');
|
||||||
|
expect(restoreFn).toBeTruthy();
|
||||||
|
expect(restoreFn).toContain('validateNavigationUrl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('browser-manager restoreState skips invalid URLs with a warning', () => {
|
||||||
|
const restoreFn = sliceBetween(BROWSER_MANAGER_SRC, 'async restoreState(', 'async recreateContext(');
|
||||||
|
expect(restoreFn).toContain('Skipping invalid URL');
|
||||||
|
expect(restoreFn).toContain('continue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateNavigationUrl call appears before page.goto in restoreState', () => {
|
||||||
|
const restoreFn = sliceBetween(BROWSER_MANAGER_SRC, 'async restoreState(', 'async recreateContext(');
|
||||||
|
const validateIdx = restoreFn.indexOf('validateNavigationUrl');
|
||||||
|
const gotoIdx = restoreFn.indexOf('page.goto');
|
||||||
|
expect(validateIdx).toBeGreaterThan(-1);
|
||||||
|
expect(gotoIdx).toBeGreaterThan(-1);
|
||||||
|
expect(validateIdx).toBeLessThan(gotoIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 12: Validate activeTabUrl before syncActiveTabByUrl ─────────────────
|
||||||
|
|
||||||
|
describe('Task 12: activeTabUrl sanitized before syncActiveTabByUrl', () => {
|
||||||
|
it('sidebar-tabs route sanitizes activeUrl before syncActiveTabByUrl', () => {
|
||||||
|
const block = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-tabs'", "url.pathname === '/sidebar-tabs/switch'");
|
||||||
|
expect(block).toContain('sanitizeExtensionUrl');
|
||||||
|
expect(block).toContain('syncActiveTabByUrl');
|
||||||
|
const sanitizeIdx = block.indexOf('sanitizeExtensionUrl');
|
||||||
|
const syncIdx = block.indexOf('syncActiveTabByUrl');
|
||||||
|
expect(sanitizeIdx).toBeLessThan(syncIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sidebar-command route sanitizes extensionUrl before syncActiveTabByUrl', () => {
|
||||||
|
const block = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-command'", "url.pathname === '/sidebar-chat/clear'");
|
||||||
|
expect(block).toContain('sanitizeExtensionUrl');
|
||||||
|
expect(block).toContain('syncActiveTabByUrl');
|
||||||
|
const sanitizeIdx = block.indexOf('sanitizeExtensionUrl');
|
||||||
|
const syncIdx = block.indexOf('syncActiveTabByUrl');
|
||||||
|
expect(sanitizeIdx).toBeLessThan(syncIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('direct unsanitized syncActiveTabByUrl calls are not present (all calls go through sanitize)', () => {
|
||||||
|
// Every syncActiveTabByUrl call should be preceded by sanitizeExtensionUrl in the nearby code
|
||||||
|
// We verify there are no direct browserManager.syncActiveTabByUrl(activeUrl) or
|
||||||
|
// browserManager.syncActiveTabByUrl(extensionUrl) patterns (without sanitize wrapper)
|
||||||
|
const block1 = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-tabs'", "url.pathname === '/sidebar-tabs/switch'");
|
||||||
|
// Should NOT contain direct call with raw activeUrl
|
||||||
|
expect(block1).not.toMatch(/syncActiveTabByUrl\(activeUrl\)/);
|
||||||
|
|
||||||
|
const block2 = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-command'", "url.pathname === '/sidebar-chat/clear'");
|
||||||
|
// Should NOT contain direct call with raw extensionUrl
|
||||||
|
expect(block2).not.toMatch(/syncActiveTabByUrl\(extensionUrl\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 13: Inbox output wrapped as untrusted ──────────────────────────────
|
||||||
|
|
||||||
|
describe('Task 13: inbox output wrapped as untrusted content', () => {
|
||||||
|
it('inbox handler wraps userMessage with wrapUntrustedContent', () => {
|
||||||
|
const block = sliceBetween(META_SRC, "case 'inbox':", "case 'state':");
|
||||||
|
expect(block).toContain('wrapUntrustedContent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inbox handler applies wrapUntrustedContent to userMessage', () => {
|
||||||
|
const block = sliceBetween(META_SRC, "case 'inbox':", "case 'state':");
|
||||||
|
// Should wrap userMessage
|
||||||
|
expect(block).toMatch(/wrapUntrustedContent.*userMessage|userMessage.*wrapUntrustedContent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inbox handler applies wrapUntrustedContent to url', () => {
|
||||||
|
const block = sliceBetween(META_SRC, "case 'inbox':", "case 'state':");
|
||||||
|
// Should also wrap url
|
||||||
|
expect(block).toMatch(/wrapUntrustedContent.*msg\.url|msg\.url.*wrapUntrustedContent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wrapUntrustedContent calls appear in the message formatting loop', () => {
|
||||||
|
const block = sliceBetween(META_SRC, 'for (const msg of messages)', 'Handle --clear flag');
|
||||||
|
expect(block).toContain('wrapUntrustedContent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 14: DOM serialization round-trip replaced with DocumentFragment ─────
|
||||||
|
|
||||||
|
const SIDEPANEL_SRC = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8');
|
||||||
|
|
||||||
|
describe('Task 14: switchChatTab uses DocumentFragment, not innerHTML round-trip', () => {
|
||||||
|
it('switchChatTab does NOT use innerHTML to restore chat (string-based re-parse removed)', () => {
|
||||||
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
// Must NOT have the dangerous pattern of assigning chatDomByTab value back to innerHTML
|
||||||
|
expect(fn).not.toMatch(/chatMessages\.innerHTML\s*=\s*chatDomByTab/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchChatTab uses createDocumentFragment to save chat DOM', () => {
|
||||||
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
||||||
|
expect(fn).toContain('createDocumentFragment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchChatTab moves nodes via appendChild/firstChild (not innerHTML assignment)', () => {
|
||||||
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
||||||
|
// Must use appendChild to restore nodes from fragment
|
||||||
|
expect(fn).toContain('chatMessages.appendChild');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chatDomByTab comment documents that values are DocumentFragments, not strings', () => {
|
||||||
|
// Check module-level comment on chatDomByTab
|
||||||
|
const commentIdx = SIDEPANEL_SRC.indexOf('chatDomByTab');
|
||||||
|
const commentLine = SIDEPANEL_SRC.slice(commentIdx, commentIdx + 120);
|
||||||
|
expect(commentLine).toMatch(/DocumentFragment|fragment/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('welcome screen is built with DOM methods in the else branch (not innerHTML)', () => {
|
||||||
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
||||||
|
// The else branch must use createElement, not innerHTML template literal
|
||||||
|
expect(fn).toContain('createElement');
|
||||||
|
// The specific innerHTML template with chat-welcome must be gone
|
||||||
|
expect(fn).not.toMatch(/innerHTML\s*=\s*`[\s\S]*?chat-welcome/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 15: pollChat/switchChatTab reentrancy guard ────────────────────────
|
||||||
|
|
||||||
|
describe('Task 15: pollChat reentrancy guard and deferred call in switchChatTab', () => {
|
||||||
|
it('pollInProgress guard variable is declared at module scope', () => {
|
||||||
|
// Must be declared before any function definitions (within first 2000 chars)
|
||||||
|
const moduleTop = SIDEPANEL_SRC.slice(0, 2000);
|
||||||
|
expect(moduleTop).toContain('pollInProgress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pollChat function checks and sets pollInProgress', () => {
|
||||||
|
const fn = extractFunction(SIDEPANEL_SRC, 'pollChat');
|
||||||
|
expect(fn).toBeTruthy();
|
||||||
|
expect(fn).toContain('pollInProgress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pollChat resets pollInProgress in finally block', () => {
|
||||||
|
const fn = extractFunction(SIDEPANEL_SRC, 'pollChat');
|
||||||
|
// The finally block must contain the reset
|
||||||
|
const finallyIdx = fn.indexOf('finally');
|
||||||
|
expect(finallyIdx).toBeGreaterThan(-1);
|
||||||
|
const finallyBlock = fn.slice(finallyIdx, finallyIdx + 60);
|
||||||
|
expect(finallyBlock).toContain('pollInProgress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchChatTab calls pollChat via setTimeout (not directly)', () => {
|
||||||
|
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
|
||||||
|
// Must use setTimeout to defer pollChat — no direct call at the end
|
||||||
|
expect(fn).toMatch(/setTimeout\s*\(\s*pollChat/);
|
||||||
|
// Must NOT have a bare direct call `pollChat()` at the end (outside setTimeout)
|
||||||
|
// We check that there is no standalone `pollChat()` call (outside setTimeout wrapper)
|
||||||
|
const withoutSetTimeout = fn.replace(/setTimeout\s*\(\s*pollChat[^)]*\)/g, '');
|
||||||
|
expect(withoutSetTimeout).not.toMatch(/\bpollChat\s*\(\s*\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 16: SIGKILL escalation in sidebar-agent timeout ────────────────────
|
||||||
|
|
||||||
|
describe('Task 16: sidebar-agent timeout handler uses SIGTERM→SIGKILL escalation', () => {
|
||||||
|
it('timeout block sends SIGTERM first', () => {
|
||||||
|
// Slice from "Timed out" / setTimeout block to processingTabs.delete
|
||||||
|
const timeoutStart = AGENT_SRC.indexOf("SIDEBAR_AGENT_TIMEOUT");
|
||||||
|
expect(timeoutStart).toBeGreaterThan(-1);
|
||||||
|
const timeoutBlock = AGENT_SRC.slice(timeoutStart, timeoutStart + 600);
|
||||||
|
expect(timeoutBlock).toContain('SIGTERM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('timeout block escalates to SIGKILL after delay', () => {
|
||||||
|
const timeoutStart = AGENT_SRC.indexOf("SIDEBAR_AGENT_TIMEOUT");
|
||||||
|
const timeoutBlock = AGENT_SRC.slice(timeoutStart, timeoutStart + 600);
|
||||||
|
expect(timeoutBlock).toContain('SIGKILL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SIGTERM appears before SIGKILL in timeout block', () => {
|
||||||
|
const timeoutStart = AGENT_SRC.indexOf("SIDEBAR_AGENT_TIMEOUT");
|
||||||
|
const timeoutBlock = AGENT_SRC.slice(timeoutStart, timeoutStart + 600);
|
||||||
|
const sigtermIdx = timeoutBlock.indexOf('SIGTERM');
|
||||||
|
const sigkillIdx = timeoutBlock.indexOf('SIGKILL');
|
||||||
|
expect(sigtermIdx).toBeGreaterThan(-1);
|
||||||
|
expect(sigkillIdx).toBeGreaterThan(-1);
|
||||||
|
expect(sigtermIdx).toBeLessThan(sigkillIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Task 17: viewport and wait bounds clamping ──────────────────────────────
|
||||||
|
|
||||||
|
describe('Task 17: viewport dimensions and wait timeouts are clamped', () => {
|
||||||
|
it('viewport case clamps width and height with Math.min/Math.max', () => {
|
||||||
|
const block = sliceBetween(WRITE_SRC, "case 'viewport':", "case 'cookie':");
|
||||||
|
expect(block).toBeTruthy();
|
||||||
|
expect(block).toMatch(/Math\.min|Math\.max/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('viewport case uses rawW/rawH before clamping (not direct destructure)', () => {
|
||||||
|
const block = sliceBetween(WRITE_SRC, "case 'viewport':", "case 'cookie':");
|
||||||
|
expect(block).toContain('rawW');
|
||||||
|
expect(block).toContain('rawH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wait case (networkidle branch) clamps timeout with MAX_WAIT_MS', () => {
|
||||||
|
const block = sliceBetween(WRITE_SRC, "case 'wait':", "case 'viewport':");
|
||||||
|
expect(block).toBeTruthy();
|
||||||
|
expect(block).toMatch(/MAX_WAIT_MS/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wait case (element branch) also clamps timeout', () => {
|
||||||
|
const block = sliceBetween(WRITE_SRC, "case 'wait':", "case 'viewport':");
|
||||||
|
// Both the networkidle and element branches declare MAX_WAIT_MS
|
||||||
|
const maxWaitCount = (block.match(/MAX_WAIT_MS/g) || []).length;
|
||||||
|
expect(maxWaitCount).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wait case uses MIN_WAIT_MS as a floor', () => {
|
||||||
|
const block = sliceBetween(WRITE_SRC, "case 'wait':", "case 'viewport':");
|
||||||
|
expect(block).toContain('MIN_WAIT_MS');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -441,7 +441,7 @@ describe('browser→sidebar tab sync', () => {
|
|||||||
test('/sidebar-tabs reads activeUrl param and calls syncActiveTabByUrl', () => {
|
test('/sidebar-tabs reads activeUrl param and calls syncActiveTabByUrl', () => {
|
||||||
const handler = serverSrc.slice(
|
const handler = serverSrc.slice(
|
||||||
serverSrc.indexOf("/sidebar-tabs'"),
|
serverSrc.indexOf("/sidebar-tabs'"),
|
||||||
serverSrc.indexOf("/sidebar-tabs'") + 500,
|
serverSrc.indexOf("/sidebar-tabs'") + 700,
|
||||||
);
|
);
|
||||||
expect(handler).toContain("get('activeUrl')");
|
expect(handler).toContain("get('activeUrl')");
|
||||||
expect(handler).toContain('syncActiveTabByUrl');
|
expect(handler).toContain('syncActiveTabByUrl');
|
||||||
@@ -626,7 +626,7 @@ describe('per-tab chat context (sidepanel.js)', () => {
|
|||||||
js.indexOf('function switchChatTab(') + 800,
|
js.indexOf('function switchChatTab(') + 800,
|
||||||
);
|
);
|
||||||
expect(fn).toContain('chatDomByTab');
|
expect(fn).toContain('chatDomByTab');
|
||||||
expect(fn).toContain('innerHTML');
|
expect(fn).toContain('createDocumentFragment');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sendMessage includes tabId in message', () => {
|
test('sendMessage includes tabId in message', () => {
|
||||||
@@ -1253,13 +1253,15 @@ describe('server /welcome endpoint', () => {
|
|||||||
expect(welcomeSection).toContain("'Content-Type': 'text/html");
|
expect(welcomeSection).toContain("'Content-Type': 'text/html");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/welcome redirects to about:blank if no welcome file found', () => {
|
test('/welcome serves fallback HTML if no welcome file found', () => {
|
||||||
const welcomeSection = serverSrc.slice(
|
const welcomeSection = serverSrc.slice(
|
||||||
serverSrc.indexOf("url.pathname === '/welcome'"),
|
serverSrc.indexOf("url.pathname === '/welcome'"),
|
||||||
serverSrc.indexOf("url.pathname === '/health'"),
|
serverSrc.indexOf("url.pathname === '/health'"),
|
||||||
);
|
);
|
||||||
expect(welcomeSection).toContain('302');
|
// Changed from 302 redirect to about:blank (ERR_UNSAFE_REDIRECT on Windows)
|
||||||
expect(welcomeSection).toContain('about:blank');
|
// to inline HTML fallback page (PR #822)
|
||||||
|
expect(welcomeSection).toContain('GStack Browser ready');
|
||||||
|
expect(welcomeSection).toContain('status: 200');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -62,11 +62,53 @@ describe('validateNavigationUrl', () => {
|
|||||||
await expect(validateNavigationUrl('http://0251.0376.0251.0376/')).rejects.toThrow(/cloud metadata/i);
|
await expect(validateNavigationUrl('http://0251.0376.0251.0376/')).rejects.toThrow(/cloud metadata/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks IPv6 metadata with brackets', async () => {
|
it('blocks IPv6 metadata with brackets (fd00::)', async () => {
|
||||||
await expect(validateNavigationUrl('http://[fd00::]/')).rejects.toThrow(/cloud metadata/i);
|
await expect(validateNavigationUrl('http://[fd00::]/')).rejects.toThrow(/cloud metadata/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks IPv6 ULA fd00::1 (not just fd00::)', async () => {
|
||||||
|
await expect(validateNavigationUrl('http://[fd00::1]/')).rejects.toThrow(/cloud metadata/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks IPv6 ULA fd12:3456::1', async () => {
|
||||||
|
await expect(validateNavigationUrl('http://[fd12:3456::1]/')).rejects.toThrow(/cloud metadata/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks IPv6 ULA fc00:: (full fc00::/7 range)', async () => {
|
||||||
|
await expect(validateNavigationUrl('http://[fc00::]/')).rejects.toThrow(/cloud metadata/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block hostnames starting with fd (e.g. fd.example.com)', async () => {
|
||||||
|
await expect(validateNavigationUrl('https://fd.example.com/')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block hostnames starting with fc (e.g. fcustomer.com)', async () => {
|
||||||
|
await expect(validateNavigationUrl('https://fcustomer.com/')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('throws on malformed URLs', async () => {
|
it('throws on malformed URLs', async () => {
|
||||||
await expect(validateNavigationUrl('not-a-url')).rejects.toThrow(/Invalid URL/i);
|
await expect(validateNavigationUrl('not-a-url')).rejects.toThrow(/Invalid URL/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validateNavigationUrl — restoreState coverage', () => {
|
||||||
|
it('blocks file:// URLs that could appear in saved state', async () => {
|
||||||
|
await expect(validateNavigationUrl('file:///etc/passwd')).rejects.toThrow(/scheme.*not allowed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks chrome:// URLs that could appear in saved state', async () => {
|
||||||
|
await expect(validateNavigationUrl('chrome://settings')).rejects.toThrow(/scheme.*not allowed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks metadata IPs that could be injected into state files', async () => {
|
||||||
|
await expect(validateNavigationUrl('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/cloud metadata/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows normal https URLs from saved state', async () => {
|
||||||
|
await expect(validateNavigationUrl('https://example.com/page')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows localhost URLs from saved state', async () => {
|
||||||
|
await expect(validateNavigationUrl('http://localhost:3000/app')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export async function serve(options: ServeOptions): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: anchor all file reads to the initial HTML's directory.
|
||||||
|
// Prevents /api/reload from reading arbitrary files via path traversal.
|
||||||
|
const allowedDir = fs.realpathSync(path.dirname(path.resolve(html)));
|
||||||
|
|
||||||
let htmlContent = fs.readFileSync(html, "utf-8");
|
let htmlContent = fs.readFileSync(html, "utf-8");
|
||||||
let state: ServerState = "serving";
|
let state: ServerState = "serving";
|
||||||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -185,19 +189,19 @@ export async function serve(options: ServeOptions): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate path is within cwd or temp directory
|
// Security: resolve symlinks and validate the reload path is within the
|
||||||
const resolved = path.resolve(newHtmlPath);
|
// allowed directory (anchored to the initial HTML file's parent).
|
||||||
const safeDirs = [process.cwd(), os.tmpdir()];
|
// Prevents path traversal via /api/reload reading arbitrary files.
|
||||||
const isSafe = safeDirs.some(dir => resolved.startsWith(dir + path.sep) || resolved === dir);
|
const resolvedReload = fs.realpathSync(path.resolve(newHtmlPath));
|
||||||
if (!isSafe) {
|
if (!resolvedReload.startsWith(allowedDir + path.sep) && resolvedReload !== allowedDir) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: `Path must be within working directory or temp` },
|
{ error: `Path must be within: ${allowedDir}` },
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap the HTML content
|
// Swap the HTML content
|
||||||
htmlContent = fs.readFileSync(newHtmlPath, "utf-8");
|
htmlContent = fs.readFileSync(resolvedReload, "utf-8");
|
||||||
state = "serving";
|
state = "serving";
|
||||||
|
|
||||||
console.error(`SERVE_RELOADED: html=${newHtmlPath}`);
|
console.error(`SERVE_RELOADED: html=${newHtmlPath}`);
|
||||||
|
|||||||
@@ -274,6 +274,103 @@ describe('Serve HTTP endpoints', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Path traversal protection in /api/reload ─────────────────────
|
||||||
|
|
||||||
|
describe('Serve /api/reload — path traversal protection', () => {
|
||||||
|
let server: ReturnType<typeof Bun.serve>;
|
||||||
|
let baseUrl: string;
|
||||||
|
let htmlContent: string;
|
||||||
|
let allowedDir: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Production-equivalent allowedDir anchored to tmpDir
|
||||||
|
allowedDir = fs.realpathSync(tmpDir);
|
||||||
|
htmlContent = fs.readFileSync(boardHtml, 'utf-8');
|
||||||
|
|
||||||
|
// This server mirrors the production serve() with the path validation fix
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/') {
|
||||||
|
return new Response(htmlContent, {
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && url.pathname === '/api/reload') {
|
||||||
|
return (async () => {
|
||||||
|
let body: any;
|
||||||
|
try { body = await req.json(); } catch { return Response.json({ error: 'Invalid JSON' }, { status: 400 }); }
|
||||||
|
if (!body.html || !fs.existsSync(body.html)) {
|
||||||
|
return Response.json({ error: `HTML file not found: ${body.html}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Production path validation — same as design/src/serve.ts
|
||||||
|
const resolvedReload = fs.realpathSync(path.resolve(body.html));
|
||||||
|
if (!resolvedReload.startsWith(allowedDir + path.sep) && resolvedReload !== allowedDir) {
|
||||||
|
return Response.json({ error: `Path must be within: ${allowedDir}` }, { status: 403 });
|
||||||
|
}
|
||||||
|
htmlContent = fs.readFileSync(resolvedReload, 'utf-8');
|
||||||
|
return Response.json({ reloaded: true });
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
baseUrl = `http://localhost:${server.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks reload with path outside allowed directory', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ html: '/etc/passwd' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.error).toContain('Path must be within');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks reload with symlink pointing outside allowed directory', async () => {
|
||||||
|
const linkPath = path.join(tmpDir, 'evil-link.html');
|
||||||
|
try {
|
||||||
|
fs.symlinkSync('/etc/passwd', linkPath);
|
||||||
|
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ html: linkPath }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(linkPath); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows reload with file inside allowed directory', async () => {
|
||||||
|
const goodPath = path.join(tmpDir, 'safe-board.html');
|
||||||
|
fs.writeFileSync(goodPath, '<html><body>Safe reload</body></html>');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/reload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ html: goodPath }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.reloaded).toBe(true);
|
||||||
|
|
||||||
|
// Verify the new content is served
|
||||||
|
const page = await fetch(baseUrl);
|
||||||
|
expect(await page.text()).toContain('Safe reload');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Full lifecycle: regeneration round-trip ──────────────────────
|
// ─── Full lifecycle: regeneration round-trip ──────────────────────
|
||||||
|
|
||||||
describe('Full regeneration lifecycle', () => {
|
describe('Full regeneration lifecycle', () => {
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ function setConnected(healthData) {
|
|||||||
chrome.action.setBadgeBackgroundColor({ color: '#F59E0B' });
|
chrome.action.setBadgeBackgroundColor({ color: '#F59E0B' });
|
||||||
chrome.action.setBadgeText({ text: ' ' });
|
chrome.action.setBadgeText({ text: ' ' });
|
||||||
|
|
||||||
// Broadcast health to popup and side panel (include token for sidepanel auth)
|
// Broadcast health to popup and side panel (token excluded — use getToken message instead)
|
||||||
chrome.runtime.sendMessage({ type: 'health', data: { ...healthData, token: authToken } }).catch((err) => {
|
chrome.runtime.sendMessage({ type: 'health', data: healthData }).catch((err) => {
|
||||||
console.debug('[gstack bg] No listener for health broadcast:', err.message);
|
console.debug('[gstack bg] No listener for health broadcast:', err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
'getPort', 'setPort', 'getServerUrl', 'fetchRefs',
|
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
|
||||||
'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command',
|
'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command',
|
||||||
// Inspector message types
|
// Inspector message types
|
||||||
'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled',
|
'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled',
|
||||||
@@ -315,7 +315,18 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// getToken handler removed — token distributed via health broadcast
|
// Token delivered via targeted sendResponse, not broadcast — limits exposure.
|
||||||
|
// Only respond to extension pages (sidepanel/popup) — content scripts have
|
||||||
|
// sender.tab set, so reject those to prevent token access from injected contexts.
|
||||||
|
if (msg.type === 'getToken') {
|
||||||
|
if (sender.tab) {
|
||||||
|
console.warn('[gstack] Rejected getToken from content script context');
|
||||||
|
sendResponse({ token: null });
|
||||||
|
} else {
|
||||||
|
sendResponse({ token: authToken });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'fetchRefs') {
|
if (msg.type === 'fetchRefs') {
|
||||||
fetchAndRelayRefs().then(() => sendResponse({ ok: true }));
|
fetchAndRelayRefs().then(() => sendResponse({ ok: true }));
|
||||||
|
|||||||
@@ -355,6 +355,10 @@
|
|||||||
function applyStyle(selector, property, value) {
|
function applyStyle(selector, property, value) {
|
||||||
// Validate property name: alphanumeric + hyphens only
|
// Validate property name: alphanumeric + hyphens only
|
||||||
if (!/^[a-zA-Z-]+$/.test(property)) return { error: 'Invalid property name' };
|
if (!/^[a-zA-Z-]+$/.test(property)) return { error: 'Invalid property name' };
|
||||||
|
// Validate CSS value: block exfiltration vectors (url(), expression(), @import, javascript:, data:)
|
||||||
|
if (/url\s*\(|expression\s*\(|@import|javascript:|data:/i.test(value)) {
|
||||||
|
return { error: 'CSS value contains blocked pattern' };
|
||||||
|
}
|
||||||
|
|
||||||
const el = findElement(selector);
|
const el = findElement(selector);
|
||||||
if (!el) return { error: 'Element not found' };
|
if (!el) return { error: 'Element not found' };
|
||||||
@@ -373,6 +377,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleClass(selector, className, action) {
|
function toggleClass(selector, className, action) {
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(className)) {
|
||||||
|
return { error: 'Invalid class name' };
|
||||||
|
}
|
||||||
const el = findElement(selector);
|
const el = findElement(selector);
|
||||||
if (!el) return { error: 'Element not found' };
|
if (!el) return { error: 'Element not found' };
|
||||||
|
|
||||||
@@ -387,6 +394,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function injectCSS(id, css) {
|
function injectCSS(id, css) {
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
|
||||||
|
return { error: 'Invalid CSS injection id' };
|
||||||
|
}
|
||||||
|
if (/url\s*\(|expression\s*\(|@import|javascript:|data:/i.test(css)) {
|
||||||
|
return { error: 'CSS contains blocked pattern (url, expression, @import)' };
|
||||||
|
}
|
||||||
const styleId = `gstack-inject-${id}`;
|
const styleId = `gstack-inject-${id}`;
|
||||||
let styleEl = document.getElementById(styleId);
|
let styleEl = document.getElementById(styleId);
|
||||||
if (!styleEl) {
|
if (!styleEl) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ let connState = 'disconnected'; // disconnected | connected | reconnecting | dea
|
|||||||
let lastOptimisticMsg = null; // track optimistically rendered user msg to avoid dupes
|
let lastOptimisticMsg = null; // track optimistically rendered user msg to avoid dupes
|
||||||
let sidebarActiveTabId = null; // which browser tab's chat we're showing
|
let sidebarActiveTabId = null; // which browser tab's chat we're showing
|
||||||
const chatLineCountByTab = {}; // tabId -> last seen chatLineCount
|
const chatLineCountByTab = {}; // tabId -> last seen chatLineCount
|
||||||
const chatDomByTab = {}; // tabId -> saved innerHTML
|
const chatDomByTab = {}; // tabId -> saved DocumentFragment (never serialized HTML)
|
||||||
|
let pollInProgress = false; // reentrancy guard — prevents concurrent/recursive pollChat calls
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead"
|
const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead"
|
||||||
@@ -390,7 +391,9 @@ document.getElementById('stop-agent-btn').addEventListener('click', stopAgent);
|
|||||||
let initialLoadDone = false;
|
let initialLoadDone = false;
|
||||||
|
|
||||||
async function pollChat() {
|
async function pollChat() {
|
||||||
if (!serverUrl || !serverToken) return;
|
if (pollInProgress) return;
|
||||||
|
pollInProgress = true;
|
||||||
|
if (!serverUrl || !serverToken) { pollInProgress = false; return; }
|
||||||
try {
|
try {
|
||||||
// Request chat for the currently displayed tab
|
// Request chat for the currently displayed tab
|
||||||
const tabParam = sidebarActiveTabId !== null ? `&tabId=${sidebarActiveTabId}` : '';
|
const tabParam = sidebarActiveTabId !== null ? `&tabId=${sidebarActiveTabId}` : '';
|
||||||
@@ -449,6 +452,8 @@ async function pollChat() {
|
|||||||
updateStopButton(data.agentStatus === 'processing');
|
updateStopButton(data.agentStatus === 'processing');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[gstack sidebar] Chat poll error:', err.message);
|
console.error('[gstack sidebar] Chat poll error:', err.message);
|
||||||
|
} finally {
|
||||||
|
pollInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +463,11 @@ function switchChatTab(newTabId) {
|
|||||||
|
|
||||||
// Save current tab's chat DOM + scroll position
|
// Save current tab's chat DOM + scroll position
|
||||||
if (sidebarActiveTabId !== null) {
|
if (sidebarActiveTabId !== null) {
|
||||||
chatDomByTab[sidebarActiveTabId] = chatMessages.innerHTML;
|
const frag = document.createDocumentFragment();
|
||||||
|
while (chatMessages.firstChild) {
|
||||||
|
frag.appendChild(chatMessages.firstChild);
|
||||||
|
}
|
||||||
|
chatDomByTab[sidebarActiveTabId] = frag;
|
||||||
chatLineCountByTab[sidebarActiveTabId] = chatLineCount;
|
chatLineCountByTab[sidebarActiveTabId] = chatLineCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +477,8 @@ function switchChatTab(newTabId) {
|
|||||||
// mid-message (the server may have switched tabs because the user's
|
// mid-message (the server may have switched tabs because the user's
|
||||||
// Chrome tab changed, but we still want to show the optimistic UI).
|
// Chrome tab changed, but we still want to show the optimistic UI).
|
||||||
if (chatDomByTab[newTabId]) {
|
if (chatDomByTab[newTabId]) {
|
||||||
chatMessages.innerHTML = chatDomByTab[newTabId];
|
while (chatMessages.firstChild) chatMessages.removeChild(chatMessages.firstChild);
|
||||||
|
chatMessages.appendChild(chatDomByTab[newTabId]);
|
||||||
chatLineCount = chatLineCountByTab[newTabId] || 0;
|
chatLineCount = chatLineCountByTab[newTabId] || 0;
|
||||||
// Reset agent state for restored tab
|
// Reset agent state for restored tab
|
||||||
agentContainer = null;
|
agentContainer = null;
|
||||||
@@ -480,12 +490,22 @@ function switchChatTab(newTabId) {
|
|||||||
chatLineCount = 0;
|
chatLineCount = 0;
|
||||||
// agentContainer/agentTextEl are already set from sendMessage()
|
// agentContainer/agentTextEl are already set from sendMessage()
|
||||||
} else {
|
} else {
|
||||||
chatMessages.innerHTML = `
|
while (chatMessages.firstChild) chatMessages.removeChild(chatMessages.firstChild);
|
||||||
<div class="chat-welcome" id="chat-welcome">
|
const welcomeDiv = document.createElement('div');
|
||||||
<div class="chat-welcome-icon">G</div>
|
welcomeDiv.className = 'chat-welcome';
|
||||||
<p>Send a message about this page.</p>
|
welcomeDiv.id = 'chat-welcome';
|
||||||
<p class="muted">Each tab has its own conversation.</p>
|
const iconDiv = document.createElement('div');
|
||||||
</div>`;
|
iconDiv.className = 'chat-welcome-icon';
|
||||||
|
iconDiv.textContent = 'G';
|
||||||
|
welcomeDiv.appendChild(iconDiv);
|
||||||
|
const p1 = document.createElement('p');
|
||||||
|
p1.textContent = 'Send a message about this page.';
|
||||||
|
welcomeDiv.appendChild(p1);
|
||||||
|
const p2 = document.createElement('p');
|
||||||
|
p2.className = 'muted';
|
||||||
|
p2.textContent = 'Each tab has its own conversation.';
|
||||||
|
welcomeDiv.appendChild(p2);
|
||||||
|
chatMessages.appendChild(welcomeDiv);
|
||||||
chatLineCount = 0;
|
chatLineCount = 0;
|
||||||
// Reset agent state for fresh tab
|
// Reset agent state for fresh tab
|
||||||
agentContainer = null;
|
agentContainer = null;
|
||||||
@@ -494,7 +514,7 @@ function switchChatTab(newTabId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Immediately poll the new tab's chat
|
// Immediately poll the new tab's chat
|
||||||
pollChat();
|
setTimeout(pollChat, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStopButton(agentRunning) {
|
function updateStopButton(agentRunning) {
|
||||||
@@ -1570,7 +1590,10 @@ chrome.runtime.onMessage.addListener((msg) => {
|
|||||||
if (msg.type === 'health') {
|
if (msg.type === 'health') {
|
||||||
if (msg.data) {
|
if (msg.data) {
|
||||||
const url = `http://127.0.0.1:${msg.data.port || 34567}`;
|
const url = `http://127.0.0.1:${msg.data.port || 34567}`;
|
||||||
updateConnection(url, msg.data.token);
|
// Request token via targeted sendResponse (not broadcast) to limit exposure
|
||||||
|
chrome.runtime.sendMessage({ type: 'getToken' }, (resp) => {
|
||||||
|
updateConnection(url, resp?.token || null);
|
||||||
|
});
|
||||||
applyChatEnabled(!!msg.data.chatEnabled);
|
applyChatEnabled(!!msg.data.chatEnabled);
|
||||||
} else {
|
} else {
|
||||||
updateConnection(null);
|
updateConnection(null);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "0.15.8.0",
|
"version": "0.15.13.0",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
25
supabase/migrations/003_installations_upsert_policy.sql
Normal file
25
supabase/migrations/003_installations_upsert_policy.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- 003_installations_upsert_policy.sql
|
||||||
|
-- Re-add a scoped UPDATE policy for installations so the telemetry-ingest
|
||||||
|
-- edge function can upsert (update last_seen) using the caller's anon key
|
||||||
|
-- instead of the service role key.
|
||||||
|
--
|
||||||
|
-- Migration 002 dropped the overly broad "anon_update_last_seen" policy
|
||||||
|
-- (which allowed UPDATE on ALL columns). This replacement uses:
|
||||||
|
-- 1. An RLS policy to allow UPDATE (required for any row access)
|
||||||
|
-- 2. Column-level GRANT to restrict anon to only the tracking columns
|
||||||
|
-- the edge function actually writes (last_seen, gstack_version, os)
|
||||||
|
--
|
||||||
|
-- This means anon callers cannot UPDATE first_seen or installation_id,
|
||||||
|
-- closing the residual risk from the broad RLS-only approach.
|
||||||
|
|
||||||
|
-- RLS policy: allow UPDATE on rows (required for PostgREST/upsert)
|
||||||
|
CREATE POLICY "anon_update_tracking" ON installations
|
||||||
|
FOR UPDATE
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Column-level restriction: anon can only UPDATE these three columns.
|
||||||
|
-- PostgreSQL GRANT UPDATE (col, ...) is enforced at the query level —
|
||||||
|
-- any UPDATE touching other columns will be rejected with a permission error.
|
||||||
|
REVOKE UPDATE ON installations FROM anon;
|
||||||
|
GRANT UPDATE (last_seen, gstack_version, os) ON installations TO anon;
|
||||||
2503
test/fixtures/golden-ship-claude.md
vendored
Normal file
2503
test/fixtures/golden-ship-claude.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
48
test/learnings-injection.test.ts
Normal file
48
test/learnings-injection.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const SCRIPT = path.join(import.meta.dir, "..", "bin", "gstack-learnings-search");
|
||||||
|
|
||||||
|
describe("gstack-learnings-search injection prevention", () => {
|
||||||
|
const script = readFileSync(SCRIPT, "utf-8");
|
||||||
|
|
||||||
|
test("no shell interpolation inside bun -e string", () => {
|
||||||
|
// Extract the bun -e block (everything between `bun -e "` and the closing `"`)
|
||||||
|
const bunBlock = script.slice(script.indexOf('bun -e "'));
|
||||||
|
|
||||||
|
// Should NOT contain ${VAR} patterns (shell interpolation)
|
||||||
|
// These are RCE vectors: a malicious learnings entry with '; rm -rf / ;' in the
|
||||||
|
// query field would execute arbitrary commands via shell interpolation.
|
||||||
|
const shellInterpolations = bunBlock.match(/'\$\{[A-Z_]+\}'/g) || [];
|
||||||
|
const bareInterpolations = bunBlock.match(/\$\{[A-Z_]+\}/g) || [];
|
||||||
|
|
||||||
|
// Filter out any that are inside process.env references (those are safe)
|
||||||
|
const unsafeInterpolations = [
|
||||||
|
...shellInterpolations,
|
||||||
|
...bareInterpolations,
|
||||||
|
].filter((m) => !m.includes("process.env"));
|
||||||
|
|
||||||
|
expect(unsafeInterpolations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses process.env for all user-controlled values", () => {
|
||||||
|
const bunBlock = script.slice(script.indexOf('bun -e "'));
|
||||||
|
|
||||||
|
// Must use process.env for TYPE, QUERY, LIMIT, SLUG, CROSS_PROJECT
|
||||||
|
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_TYPE");
|
||||||
|
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_QUERY");
|
||||||
|
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_LIMIT");
|
||||||
|
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_SLUG");
|
||||||
|
expect(bunBlock).toContain("process.env.GSTACK_SEARCH_CROSS");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("env vars are set on the bun command line", () => {
|
||||||
|
// The env vars must be passed to bun, not just set in the shell
|
||||||
|
expect(script).toContain("GSTACK_SEARCH_TYPE=");
|
||||||
|
expect(script).toContain("GSTACK_SEARCH_QUERY=");
|
||||||
|
expect(script).toContain("GSTACK_SEARCH_LIMIT=");
|
||||||
|
expect(script).toContain("GSTACK_SEARCH_SLUG=");
|
||||||
|
expect(script).toContain("GSTACK_SEARCH_CROSS=");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user