Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ bin/gstack-global-discover
.claude/skills/
.agents/
.context/
extension/.auth.json
.gstack-worktrees/
/tmp/
*.log
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.13.0.0
0.13.1.0
18 changes: 15 additions & 3 deletions bin/gstack-config
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,28 @@ CONFIG_FILE="$STATE_DIR/config.yaml"
case "${1:-}" in
get)
KEY="${2:?Usage: gstack-config get <key>}"
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 <key> <value>}"
VALUE="${3:?Usage: gstack-config set <key> <value>}"
# 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
Expand Down
2 changes: 2 additions & 0 deletions bin/gstack-telemetry-log
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 26 additions & 1 deletion browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
async launchHeaded(authToken?: string): Promise<void> {
// Clear old state before repopulating
this.pages.clear();
this.refMap.clear();
Expand All @@ -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.
Expand Down Expand Up @@ -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');
Expand Down
16 changes: 14 additions & 2 deletions browse/src/cookie-picker-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export async function handleCookiePickerRoute(
url: URL,
req: Request,
bm: BrowserManager,
authToken?: string,
): Promise<Response> {
const pathname = url.pathname;
const port = parseInt(url.port, 10) || 9400;
Expand All @@ -64,21 +65,32 @@ 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',
},
});
}

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();
Expand Down
7 changes: 5 additions & 2 deletions browse/src/cookie-picker-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!DOCTYPE html>
Expand Down Expand Up @@ -330,6 +330,7 @@ export function getCookiePickerHTML(serverPort: number): string {
<script>
(function() {
const BASE = '${baseUrl}';
const AUTH_TOKEN = '${authToken || ''}';
let activeBrowser = null;
let activeProfile = 'Default';
let allProfiles = [];
Expand Down Expand Up @@ -372,7 +373,9 @@ export function getCookiePickerHTML(serverPort: number): string {
// ─── API ────────────────────────────────
async function api(path, opts) {
const res = await fetch(BASE + '/cookie-picker' + path, opts);
const headers = { ...(opts?.headers || {}) };
if (AUTH_TOKEN) headers['Authorization'] = 'Bearer ' + AUTH_TOKEN;
const res = await fetch(BASE + '/cookie-picker' + path, { ...opts, headers });
const data = await res.json();
if (!res.ok) {
const err = new Error(data.error || 'Request failed');
Expand Down
11 changes: 10 additions & 1 deletion browse/src/meta-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,11 +474,12 @@ export async function handleMetaCommand(
// V1: cookies + URLs only (not localStorage — breaks on load-before-navigate)
const saveData = {
version: 1,
savedAt: new Date().toISOString(),
cookies: state.cookies,
pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })),
};
fs.writeFileSync(statePath, JSON.stringify(saveData, null, 2), { mode: 0o600 });
return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages — treat as sensitive)`;
return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages)\n⚠️ Cookies stored in plaintext. Delete when no longer needed.`;
}

if (action === 'load') {
Expand All @@ -487,6 +488,14 @@ export async function handleMetaCommand(
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
throw new Error('Invalid state file: expected cookies and pages arrays');
}
// Warn on state files older than 7 days
if (data.savedAt) {
const ageMs = Date.now() - new Date(data.savedAt).getTime();
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
if (ageMs > SEVEN_DAYS) {
console.warn(`[browse] Warning: State file is ${Math.round(ageMs / 86400000)} days old. Consider re-saving.`);
}
}
// Close existing pages, then restore (replace, not merge)
bm.setFrame(null);
await bm.closeAllPages();
Expand Down
33 changes: 24 additions & 9 deletions browse/src/read-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,34 @@ function wrapForEvaluate(code: string): string {
}

// 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 validateReadPath(filePath: string): void {
if (path.isAbsolute(filePath)) {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
if (!isSafe) {
throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
// Always resolve to absolute first (fixes relative path symlink bypass)
const resolved = path.resolve(filePath);
// Resolve symlinks — throw on non-ENOENT errors
let realPath: string;
try {
realPath = fs.realpathSync(resolved);
} catch (err: any) {
if (err.code === 'ENOENT') {
// File doesn't exist — resolve directory part for symlinks (e.g., /tmp → /private/tmp)
try {
const dir = fs.realpathSync(path.dirname(resolved));
realPath = path.join(dir, path.basename(resolved));
} catch {
realPath = resolved;
}
} else {
throw new Error(`Cannot resolve real path: ${filePath} (${err.code})`);
}
}
const normalized = path.normalize(filePath);
if (normalized.includes('..')) {
throw new Error('Path traversal sequences (..) are not allowed');
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realPath, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
}

Expand Down
62 changes: 46 additions & 16 deletions browse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@ async function start() {
if (!skipBrowser) {
const headed = process.env.BROWSE_HEADED === '1';
if (headed) {
await browserManager.launchHeaded();
await browserManager.launchHeaded(AUTH_TOKEN);
console.log(`[browse] Launched headed Chromium with extension`);
} else {
await browserManager.launch();
Expand All @@ -819,9 +819,9 @@ async function start() {
fetch: async (req) => {
const url = new URL(req.url);

// Cookie picker routes — no auth required (localhost-only)
// Cookie picker routes — HTML page unauthenticated, data/action routes require auth
if (url.pathname.startsWith('/cookie-picker')) {
return handleCookiePickerRoute(url, req, browserManager);
return handleCookiePickerRoute(url, req, browserManager, AUTH_TOKEN);
}

// Health check — no auth required, does NOT reset idle timer
Expand All @@ -833,7 +833,7 @@ async function start() {
uptime: Math.floor((Date.now() - startTime) / 1000),
tabs: browserManager.getTabCount(),
currentUrl: browserManager.getCurrentUrl(),
token: AUTH_TOKEN, // Extension uses this for Bearer auth
// token removed — see .auth.json for extension bootstrap
chatEnabled: true,
agent: {
status: agentStatus,
Expand All @@ -848,24 +848,35 @@ async function start() {
});
}

// Refs endpoint — no auth required (localhost-only), does NOT reset idle timer
// Refs endpoint — auth required, does NOT reset idle timer
if (url.pathname === '/refs') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const refs = browserManager.getRefMap();
return new Response(JSON.stringify({
refs,
url: browserManager.getCurrentUrl(),
mode: browserManager.getConnectionMode(),
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
headers: { 'Content-Type': 'application/json' },
});
}

// Activity stream — SSE, no auth (localhost-only), does NOT reset idle timer
// Activity stream — SSE, auth required, does NOT reset idle timer
if (url.pathname === '/activity/stream') {
// Inline auth: accept Bearer header OR ?token= query param (EventSource can't send headers)
const streamToken = url.searchParams.get('token');
if (!validateAuth(req) && streamToken !== AUTH_TOKEN) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
const encoder = new TextEncoder();

Expand Down Expand Up @@ -913,21 +924,23 @@ async function start() {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
},
});
}

// Activity history — REST, no auth (localhost-only), does NOT reset idle timer
// Activity history — REST, auth required, does NOT reset idle timer
if (url.pathname === '/activity/history') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const { entries, totalAdded } = getActivityHistory(limit);
return new Response(JSON.stringify({ entries, totalAdded, subscribers: getSubscriberCount() }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
headers: { 'Content-Type': 'application/json' },
});
}

Expand Down Expand Up @@ -1139,6 +1152,23 @@ async function start() {
fs.renameSync(tmpFile, config.stateFile);

browserManager.serverPort = port;

// Clean up stale state files (older than 7 days)
try {
const stateDir = path.join(config.stateDir, 'browse-states');
if (fs.existsSync(stateDir)) {
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
for (const file of fs.readdirSync(stateDir)) {
const filePath = path.join(stateDir, file);
const stat = fs.statSync(filePath);
if (Date.now() - stat.mtimeMs > SEVEN_DAYS) {
fs.unlinkSync(filePath);
console.log(`[browse] Deleted stale state file: ${file}`);
}
}
}
} catch {}

console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
console.log(`[browse] State file: ${config.stateFile}`);
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
Expand Down
Loading
Loading