diff --git a/.gitignore b/.gitignore index e1e6ed0e0..38b0fc282 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ bin/gstack-global-discover .claude/skills/ .agents/ .context/ +extension/.auth.json .gstack-worktrees/ /tmp/ *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a5b925fd..24b7111a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.13.1.0] - 2026-03-28 — Defense in Depth + +The browse server runs on localhost and requires a token for access, so these issues only matter if a malicious process is already running on your machine (e.g., a compromised npm postinstall script). This release hardens the attack surface so that even in that scenario, the damage is contained. + +### Fixed + +- **Auth token removed from `/health` endpoint.** Token now distributed via `.auth.json` file (0o600 permissions) instead of an unauthenticated HTTP response. +- **Cookie picker data routes now require Bearer auth.** The HTML picker page is still open (it's the UI shell), but all data and action endpoints check the token. +- **CORS tightened on `/refs` and `/activity/*`.** Removed wildcard origin header so websites can't read browse activity cross-origin. +- **State files auto-expire after 7 days.** Cookie state files now include a timestamp and warn on load if stale. Server startup cleans up files older than 7 days. +- **Extension uses `textContent` instead of `innerHTML`.** Prevents DOM injection if server-provided data ever contained markup. Standard defense-in-depth for browser extensions. +- **Path validation resolves symlinks before boundary checks.** `validateReadPath` now calls `realpathSync` and handles macOS `/tmp` symlink correctly. +- **Freeze hook uses portable path resolution.** POSIX-compatible (works on macOS without coreutils), fixes edge case where `/project-evil` could match a freeze boundary set to `/project`. +- **Shell config scripts validate input.** `gstack-config` rejects regex-special keys and escapes sed patterns. `gstack-telemetry-log` sanitizes branch/repo names in JSON output. + +### Added + +- 20 regression tests covering all hardening changes. + ## [0.13.0.0] - 2026-03-27 — Your Agent Can Design Now gstack can generate real UI mockups. Not ASCII art, not text descriptions of hex codes, real visual designs you can look at, compare, pick from, and iterate on. Run `/office-hours` on a UI idea and you'll get 3 visual concepts in Chrome with a comparison board where you pick your favorite, rate the others, and tell the agent what to change. diff --git a/VERSION b/VERSION index b6963e15b..883dcff5e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.0.0 +0.13.1.0 diff --git a/bin/gstack-config b/bin/gstack-config index 1147adddb..821a342a7 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -16,16 +16,28 @@ CONFIG_FILE="$STATE_DIR/config.yaml" case "${1:-}" in get) KEY="${2:?Usage: gstack-config get }" - grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true + # Validate key (alphanumeric + underscore only) + if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+$'; then + echo "Error: key must contain only alphanumeric characters and underscores" >&2 + exit 1 + fi + grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true ;; set) KEY="${2:?Usage: gstack-config set }" VALUE="${3:?Usage: gstack-config set }" + # Validate key (alphanumeric + underscore only) + if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+$'; then + echo "Error: key must contain only alphanumeric characters and underscores" >&2 + exit 1 + fi mkdir -p "$STATE_DIR" - if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then + # Escape sed special chars in value and drop embedded newlines + ESC_VALUE="$(printf '%s' "$VALUE" | head -1 | sed 's/[&/\]/\\&/g')" + if grep -qF "${KEY}:" "$CONFIG_FILE" 2>/dev/null; then # Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg) _tmpfile="$(mktemp "${CONFIG_FILE}.XXXXXX")" - sed "s/^${KEY}:.*/${KEY}: ${VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE" + sed "s/^${KEY}:.*/${KEY}: ${ESC_VALUE}/" "$CONFIG_FILE" > "$_tmpfile" && mv "$_tmpfile" "$CONFIG_FILE" else echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE" fi diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log index da371c38b..93db82077 100755 --- a/bin/gstack-telemetry-log +++ b/bin/gstack-telemetry-log @@ -158,6 +158,8 @@ OUTCOME="$(json_safe "$OUTCOME")" SESSION_ID="$(json_safe "$SESSION_ID")" SOURCE="$(json_safe "$SOURCE")" EVENT_TYPE="$(json_safe "$EVENT_TYPE")" +REPO_SLUG="$(json_safe "$REPO_SLUG")" +BRANCH="$(json_safe "$BRANCH")" # Escape null fields — sanitize ERROR_CLASS and FAILED_STEP via json_safe() ERR_FIELD="null" diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 1ef58e36a..a6eda991b 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -211,7 +211,7 @@ export class BrowserManager { * The browser launches headed with a visible window — the user sees * every action Claude takes in real time. */ - async launchHeaded(): Promise { + async launchHeaded(authToken?: string): Promise { // Clear old state before repopulating this.pages.clear(); this.refMap.clear(); @@ -223,6 +223,17 @@ export class BrowserManager { if (extensionPath) { launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`); + // Write auth token for extension bootstrap (read via chrome.runtime.getURL) + if (authToken) { + const fs = require('fs'); + const path = require('path'); + const authFile = path.join(extensionPath, '.auth.json'); + try { + fs.writeFileSync(authFile, JSON.stringify({ token: authToken }), { mode: 0o600 }); + } catch (err: any) { + console.warn(`[browse] Could not write .auth.json: ${err.message}`); + } + } } // Launch headed Chromium via Playwright's persistent context. @@ -751,6 +762,20 @@ export class BrowserManager { if (extensionPath) { launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`); + // Write auth token for extension bootstrap during handoff + if (this.serverPort) { + try { + const { resolveConfig } = require('./config'); + const config = resolveConfig(); + const stateFile = path.join(config.stateDir, 'browse.json'); + if (fs.existsSync(stateFile)) { + const stateData = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + if (stateData.token) { + fs.writeFileSync(path.join(extensionPath, '.auth.json'), JSON.stringify({ token: stateData.token }), { mode: 0o600 }); + } + } + } catch {} + } console.log(`[browse] Handoff: loading extension from ${extensionPath}`); } else { console.log('[browse] Handoff: extension not found — headed mode without side panel'); diff --git a/browse/src/cookie-picker-routes.ts b/browse/src/cookie-picker-routes.ts index 0e6972484..f36a66600 100644 --- a/browse/src/cookie-picker-routes.ts +++ b/browse/src/cookie-picker-routes.ts @@ -53,6 +53,7 @@ export async function handleCookiePickerRoute( url: URL, req: Request, bm: BrowserManager, + authToken?: string, ): Promise { const pathname = url.pathname; const port = parseInt(url.port, 10) || 9400; @@ -64,7 +65,7 @@ export async function handleCookiePickerRoute( headers: { 'Access-Control-Allow-Origin': corsOrigin(port), 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }, }); } @@ -72,13 +73,24 @@ export async function handleCookiePickerRoute( try { // GET /cookie-picker — serve the picker UI if (pathname === '/cookie-picker' && req.method === 'GET') { - const html = getCookiePickerHTML(port); + const html = getCookiePickerHTML(port, authToken); return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' }, }); } + // ─── Auth gate: all data/action routes below require Bearer token ─── + if (authToken) { + const authHeader = req.headers.get('authorization'); + if (!authHeader || authHeader !== `Bearer ${authToken}`) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + // GET /cookie-picker/browsers — list installed browsers if (pathname === '/cookie-picker/browsers' && req.method === 'GET') { const browsers = findInstalledBrowsers(); diff --git a/browse/src/cookie-picker-ui.ts b/browse/src/cookie-picker-ui.ts index 381cf2e2f..70faa5621 100644 --- a/browse/src/cookie-picker-ui.ts +++ b/browse/src/cookie-picker-ui.ts @@ -7,7 +7,7 @@ * No cookie values exposed anywhere. */ -export function getCookiePickerHTML(serverPort: number): string { +export function getCookiePickerHTML(serverPort: number, authToken?: string): string { const baseUrl = `http://127.0.0.1:${serverPort}`; return ` @@ -330,6 +330,7 @@ export function getCookiePickerHTML(serverPort: number): string {