Files
gstack/browse/src/url-validation.ts
T
Garry Tan 03973c2fab 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>
2026-04-06 00:47:04 -07:00

139 lines
5.4 KiB
TypeScript

/**
* URL validation for navigation commands — blocks dangerous schemes and cloud metadata endpoints.
* Localhost and private IPs are allowed (primary use case: QA testing local dev servers).
*/
export const BLOCKED_METADATA_HOSTS = new Set([
'169.254.169.254', // AWS/GCP/Azure instance metadata
'fe80::1', // IPv6 link-local — common metadata endpoint alias
'::ffff:169.254.169.254', // IPv4-mapped IPv6 form of the metadata IP
'metadata.google.internal', // GCP metadata
'metadata.azure.internal', // Azure IMDS
]);
/**
* IPv6 prefixes to block (CIDR-style). Any address starting with these
* hex prefixes is rejected. Covers the full ULA range (fc00::/7 = fc00:: and fd00::).
*/
const BLOCKED_IPV6_PREFIXES = ['fc', 'fd'];
/**
* Check if an IPv6 address falls within a blocked prefix range.
* Handles the full ULA range (fc00::/7), not just the exact literal fd00::.
* Only matches actual IPv6 addresses (must contain ':'), not hostnames
* like fd.example.com or fcustomer.com.
*/
function isBlockedIpv6(addr: string): boolean {
const normalized = addr.toLowerCase().replace(/^\[|\]$/g, '');
// Must contain a colon to be an IPv6 address — avoids false positives on
// hostnames like fd.example.com or fcustomer.com
if (!normalized.includes(':')) return false;
return BLOCKED_IPV6_PREFIXES.some(prefix => normalized.startsWith(prefix));
}
/**
* Normalize hostname for blocklist comparison:
* - Strip trailing dot (DNS fully-qualified notation)
* - Strip IPv6 brackets (URL.hostname includes [] for IPv6)
* - Resolve hex (0xA9FEA9FE) and decimal (2852039166) IP representations
*/
function normalizeHostname(hostname: string): string {
// Strip IPv6 brackets
let h = hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname;
// Strip trailing dot
if (h.endsWith('.')) h = h.slice(0, -1);
return h;
}
/**
* Check if a hostname resolves to the link-local metadata IP 169.254.169.254.
* Catches hex (0xA9FEA9FE), decimal (2852039166), and octal (0251.0376.0251.0376) forms.
*/
function isMetadataIp(hostname: string): boolean {
// Try to parse as a numeric IP via URL constructor — it normalizes all forms
try {
const probe = new URL(`http://${hostname}`);
const normalized = probe.hostname;
if (BLOCKED_METADATA_HOSTS.has(normalized) || isBlockedIpv6(normalized)) return true;
// Also check after stripping trailing dot
if (normalized.endsWith('.') && BLOCKED_METADATA_HOSTS.has(normalized.slice(0, -1))) return true;
} catch {
// Not a valid hostname — can't be a metadata IP
}
return false;
}
/**
* Resolve a hostname to its IP addresses and check if any resolve to blocked metadata IPs.
* Mitigates DNS rebinding: even if the hostname looks safe, the resolved IP might not be.
*
* Checks both A (IPv4) and AAAA (IPv6) records — an attacker can use AAAA-only DNS to
* bypass IPv4-only checks. Each record family is tried independently; failure of one
* (e.g. no AAAA records exist) is not treated as a rebinding risk.
*/
async function resolvesToBlockedIp(hostname: string): Promise<boolean> {
try {
const dns = await import('node:dns');
const { resolve4, resolve6 } = dns.promises;
// Check IPv4 A records
const v4Check = resolve4(hostname).then(
(addresses) => addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr)),
() => false, // ENODATA / ENOTFOUND — no A records, not a risk
);
// Check IPv6 AAAA records — the gap that issue #668 identified
const v6Check = resolve6(hostname).then(
(addresses) => addresses.some(addr => {
const normalized = addr.toLowerCase();
return BLOCKED_METADATA_HOSTS.has(normalized) || isBlockedIpv6(normalized) ||
// fe80::/10 is link-local — always block (covers all fe80:: addresses)
normalized.startsWith('fe80:');
}),
() => false, // ENODATA / ENOTFOUND — no AAAA records, not a risk
);
const [v4Blocked, v6Blocked] = await Promise.all([v4Check, v6Check]);
return v4Blocked || v6Blocked;
} catch {
// Unexpected error — fail open (don't block navigation on DNS infrastructure failure)
return false;
}
}
export async function validateNavigationUrl(url: string): Promise<void> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid URL: ${url}`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(
`Blocked: scheme "${parsed.protocol}" is not allowed. Only http: and https: URLs are permitted.`
);
}
const hostname = normalizeHostname(parsed.hostname.toLowerCase());
if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname) || isBlockedIpv6(hostname)) {
throw new Error(
`Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.`
);
}
// DNS rebinding protection: resolve hostname and check if it points to metadata IPs.
// Skip for loopback/private IPs — they can't be DNS-rebinded and the async DNS
// resolution adds latency that breaks concurrent E2E tests under load.
const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
const isPrivateNet = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/.test(hostname);
if (!isLoopback && !isPrivateNet && await resolvesToBlockedIp(hostname)) {
throw new Error(
`Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.`
);
}
}