fix: security wave 1 — 14 fixes for audit #783 (v0.15.7.0) (#810)

* fix: DNS rebinding protection checks AAAA (IPv6) records too

Cherry-pick PR #744 by @Gonzih. Closes the IPv6-only DNS rebinding gap
by checking both A and AAAA records independently.

Co-Authored-By: Gonzih <gonzih@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: validateOutputPath symlink bypass — resolve real path before safe-dir check

Cherry-pick PR #745 by @Gonzih. Adds a second pass using fs.realpathSync()
to resolve symlinks after lexical path validation.

Co-Authored-By: Gonzih <gonzih@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: validate saved URLs before navigation in restoreState

Cherry-pick PR #751 by @Gonzih. Prevents navigation to cloud metadata
endpoints or file:// URIs embedded in user-writable state files.

Co-Authored-By: Gonzih <gonzih@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: telemetry-ingest uses anon key instead of service role key

Cherry-pick PR #750 by @Gonzih. The service role key bypasses RLS and
grants unrestricted database access — anon key + RLS is the right model
for a public telemetry endpoint.

Co-Authored-By: Gonzih <gonzih@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: killAgent() actually kills the sidebar claude subprocess

Cherry-pick PR #743 by @Gonzih. Implements cross-process kill signaling
via kill-file + polling pattern, tracks active processes per-tab.

Co-Authored-By: Gonzih <gonzih@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(design): bind server to localhost and validate reload paths

Cherry-pick PR #803 by @garagon. Adds hostname: '127.0.0.1' to Bun.serve()
and validates /api/reload paths are within cwd() or tmpdir(). Closes C1+C2
from security audit #783.

Co-Authored-By: garagon <garagon@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add auth gate to /inspector/events SSE endpoint (C3)

The /inspector/events endpoint had no authentication, unlike /activity/stream
which validates tokens. Now requires the same Bearer header or ?token= query
param check. Closes C3 from security audit #783.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sanitize design feedback with trust boundary markers (C4+H5)

Wrap user feedback in <user-feedback> XML markers with tag escaping to
prevent prompt injection via malicious feedback text. Cap accumulated
feedback to last 5 iterations to limit incremental poisoning.
Closes C4 and H5 from security audit #783.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: harden file/directory permissions to owner-only (C5+H9+M9+M10)

Add mode 0o700 to all mkdirSync calls for state/session directories.
Add mode 0o600 to all writeFileSync calls for session.json, chat.jsonl,
and log files. Add umask 077 to setup script. Prevents auth tokens, chat
history, and browser logs from being world-readable on multi-user systems.
Closes C5, H9, M9, M10 from security audit #783.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: TOCTOU race in setup symlink creation (C6)

Remove the existence check before mkdir -p (it's idempotent) and validate
the target isn't already a symlink before creating the link. Prevents a
local attacker from racing between the check and mkdir to redirect
SKILL.md writes. Closes C6 from security audit #783.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove CORS wildcard, restrict to localhost (H1)

Replace Access-Control-Allow-Origin: * with http://127.0.0.1 on sidebar
tab/chat endpoints. The Chrome extension uses manifest host_permissions
to bypass CORS entirely, so this only blocks malicious websites from
making cross-origin requests. Closes H1 from security audit #783.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: make cookie picker auth mandatory (H2)

Remove the conditional if(authToken) guard that skipped auth when
authToken was undefined. Now all cookie picker data/action routes
reject unauthenticated requests. Closes H2 from security audit #783.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: gate /health token on chrome-extension Origin header

Only return the auth token in /health response when the request Origin
starts with chrome-extension://. The Chrome extension always sends this
origin via manifest host_permissions. Regular HTTP requests (including
tunneled ones from ngrok/SSH) won't get the token. The extension also
has a fallback path through background.js that reads the token from the
state file directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: update server-auth test for chrome-extension Origin gating

The test previously checked for 'localhost-only' comment. Now checks for
'chrome-extension://' since the token is gated on Origin header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.15.7.0)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gonzih <gonzih@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: garagon <garagon@users.noreply.github.com>
This commit is contained in:
Garry Tan
2026-04-04 22:12:04 -07:00
committed by GitHub
parent 31943b2f02
commit 115d81d792
14 changed files with 207 additions and 56 deletions
+9 -1
View File
@@ -822,7 +822,15 @@ export class BrowserManager {
this.wirePageEvents(page);
if (saved.url) {
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
// Validate the saved URL before navigating — the state file is user-writable and
// a tampered URL could navigate to cloud metadata endpoints or file:// URIs.
try {
await validateNavigationUrl(saved.url);
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
} catch {
// Invalid URL in saved state — skip navigation, leave blank page
console.log(`[browse] restoreState: skipping unsafe URL: ${saved.url}`);
}
}
if (saved.storage) {
+1 -1
View File
@@ -79,7 +79,7 @@ export function resolveConfig(
*/
export function ensureStateDir(config: BrowseConfig): void {
try {
fs.mkdirSync(config.stateDir, { recursive: true });
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
} catch (err: any) {
if (err.code === 'EACCES') {
throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`);
+7 -8
View File
@@ -81,14 +81,13 @@ export async function handleCookiePickerRoute(
}
// ─── 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' },
});
}
// Auth is mandatory — if authToken is undefined, reject all requests
const authHeader = req.headers.get('authorization');
if (!authToken || !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
+33 -19
View File
@@ -398,10 +398,10 @@ function createSession(): SidebarSession {
lastActiveAt: new Date().toISOString(),
};
const sessionDir = path.join(SESSIONS_DIR, id);
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(path.join(sessionDir, 'session.json'), JSON.stringify(session, null, 2));
fs.writeFileSync(path.join(sessionDir, 'chat.jsonl'), '');
fs.writeFileSync(path.join(SESSIONS_DIR, 'active.json'), JSON.stringify({ id }));
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(path.join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), { mode: 0o600 });
fs.writeFileSync(path.join(sessionDir, 'chat.jsonl'), '', { mode: 0o600 });
fs.writeFileSync(path.join(SESSIONS_DIR, 'active.json'), JSON.stringify({ id }), { mode: 0o600 });
chatBuffer = [];
chatNextId = 0;
return session;
@@ -411,7 +411,7 @@ function saveSession(): void {
if (!sidebarSession) return;
sidebarSession.lastActiveAt = new Date().toISOString();
const sessionFile = path.join(SESSIONS_DIR, sidebarSession.id, 'session.json');
try { fs.writeFileSync(sessionFile, JSON.stringify(sidebarSession, null, 2)); } catch (err: any) {
try { fs.writeFileSync(sessionFile, JSON.stringify(sidebarSession, null, 2), { mode: 0o600 }); } catch (err: any) {
console.error('[browse] Failed to save session:', err.message);
}
}
@@ -558,7 +558,7 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId
tabId: agentTabId,
});
try {
fs.mkdirSync(gstackDir, { recursive: true });
fs.mkdirSync(gstackDir, { recursive: true, mode: 0o700 });
fs.appendFileSync(agentQueue, entry + '\n');
} catch (err: any) {
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
@@ -585,6 +585,13 @@ function killAgent(): void {
agentStartTime = null;
currentMessage = null;
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
@@ -607,7 +614,7 @@ function startAgentHealthCheck(): void {
// Initialize session on startup
function initSidebarSession(): void {
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
sidebarSession = loadSession();
if (!sidebarSession) {
sidebarSession = createSession();
@@ -1086,10 +1093,11 @@ async function start() {
uptime: Math.floor((Date.now() - startTime) / 1000),
tabs: browserManager.getTabCount(),
currentUrl: browserManager.getCurrentUrl(),
// Auth token for extension bootstrap. Safe: /health is localhost-only.
// Previously served via .auth.json in extension dir, but that breaks
// read-only .app bundles and codesigning. Extension reads token from here.
token: AUTH_TOKEN,
// 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
// server is tunneled to the internet (ngrok, SSH tunnel).
...(req.headers.get('origin')?.startsWith('chrome-extension://') ? { token: AUTH_TOKEN } : {}),
chatEnabled: true,
agent: {
status: agentStatus,
@@ -1222,12 +1230,12 @@ async function start() {
const tabs = await browserManager.getTabListWithTitles();
return new Response(JSON.stringify({ tabs }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
});
} catch (err: any) {
return new Response(JSON.stringify({ tabs: [], error: err.message }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
});
}
}
@@ -1246,7 +1254,7 @@ async function start() {
browserManager.switchTab(tabId);
return new Response(JSON.stringify({ ok: true, activeTab: tabId }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
});
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
@@ -1268,7 +1276,7 @@ async function start() {
const tabAgentStatus = tabId !== null ? getTabAgentStatus(tabId) : agentStatus;
return new Response(JSON.stringify({ entries, total: chatNextId, agentStatus: tabAgentStatus, activeTabId: activeTab }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
});
}
@@ -1324,7 +1332,7 @@ async function start() {
chatBuffer = [];
chatNextId = 0;
if (sidebarSession) {
try { fs.writeFileSync(path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl'), ''); } catch (err: any) {
try { fs.writeFileSync(path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl'), '', { mode: 0o600 }); } catch (err: any) {
console.error('[browse] Failed to clear chat file:', err.message);
}
}
@@ -1549,8 +1557,14 @@ async function start() {
});
}
// GET /inspector/events — SSE for inspector state changes
// GET /inspector/events — SSE for inspector state changes (auth required)
if (url.pathname === '/inspector/events' && req.method === 'GET') {
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 encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
@@ -1680,8 +1694,8 @@ start().catch((err) => {
// stderr because the server is launched with detached: true, stdio: 'ignore'.
try {
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
fs.mkdirSync(config.stateDir, { recursive: true });
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`, { mode: 0o600 });
} catch {
// stateDir may not exist — nothing more we can do
}
+35 -4
View File
@@ -14,6 +14,7 @@ import * as fs from 'fs';
import * as path from 'path';
const QUEUE = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
const KILL_FILE = path.join(path.dirname(QUEUE), 'sidebar-agent-kill');
const SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '34567', 10);
const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`;
const POLL_MS = 200; // 200ms poll — keeps time-to-first-token low
@@ -23,6 +24,10 @@ let lastLine = 0;
let authToken: string | null = null;
// Per-tab processing — each tab can run its own agent concurrently
const processingTabs = new Set<number>();
// Active claude subprocesses — keyed by tabId for targeted kill
const activeProcs = new Map<number, ReturnType<typeof spawn>>();
// Kill-file timestamp last seen — avoids double-kill on same write
let lastKillTs = 0;
// ─── File drop relay ──────────────────────────────────────────
@@ -44,7 +49,7 @@ function writeToInbox(message: string, pageUrl?: string, sessionId?: string): vo
}
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
fs.mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
const now = new Date();
const timestamp = now.toISOString().replace(/:/g, '-');
@@ -60,7 +65,7 @@ function writeToInbox(message: string, pageUrl?: string, sessionId?: string): vo
sidebarSessionId: sessionId || 'unknown',
};
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2), { mode: 0o600 });
fs.renameSync(tmpFile, finalFile);
console.log(`[sidebar-agent] Wrote inbox message: ${filename}`);
}
@@ -263,6 +268,9 @@ async function askClaude(queueEntry: any): Promise<void> {
},
});
// Track active procs so kill-file polling can terminate them
activeProcs.set(tid, proc);
proc.stdin.end();
let buffer = '';
@@ -285,6 +293,7 @@ async function askClaude(queueEntry: any): Promise<void> {
});
proc.on('close', (code) => {
activeProcs.delete(tid);
if (buffer.trim()) {
try { handleStreamEvent(JSON.parse(buffer), tid); } catch (err: any) {
console.error(`[sidebar-agent] Tab ${tid}: Failed to parse final buffer:`, buffer.slice(0, 100), err.message);
@@ -381,10 +390,31 @@ async function poll() {
// ─── Main ────────────────────────────────────────────────────────
function pollKillFile(): void {
try {
const stat = fs.statSync(KILL_FILE);
const mtime = stat.mtimeMs;
if (mtime > lastKillTs) {
lastKillTs = mtime;
if (activeProcs.size > 0) {
console.log(`[sidebar-agent] Kill signal received — terminating ${activeProcs.size} active agent(s)`);
for (const [tid, proc] of activeProcs) {
try { proc.kill('SIGTERM'); } catch {}
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 2000);
processingTabs.delete(tid);
}
activeProcs.clear();
}
}
} catch {
// Kill file doesn't exist yet — normal state
}
}
async function main() {
const dir = path.dirname(QUEUE);
fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '');
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '', { mode: 0o600 });
lastLine = countLines();
await refreshToken();
@@ -394,6 +424,7 @@ async function main() {
console.log(`[sidebar-agent] Browse binary: ${B}`);
setInterval(poll, POLL_MS);
setInterval(pollKillFile, POLL_MS);
}
main().catch(console.error);
+29 -5
View File
@@ -4,8 +4,10 @@
*/
const BLOCKED_METADATA_HOSTS = new Set([
'169.254.169.254', // AWS/GCP/Azure instance metadata
'169.254.169.254', // AWS/GCP/Azure instance metadata (IPv4 link-local)
'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
'metadata.google.internal', // GCP metadata
'metadata.azure.internal', // Azure IMDS
]);
@@ -47,15 +49,37 @@ function isMetadataIp(hostname: string): boolean {
/**
* 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 } = dns.promises;
const addresses = await resolve4(hostname);
return addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr));
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) ||
// 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 {
// DNS resolution failed — not a rebinding risk
// Unexpected error — fail open (don't block navigation on DNS infrastructure failure)
return false;
}
}
+29
View File
@@ -18,10 +18,39 @@ const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
function validateOutputPath(filePath: string): void {
const resolved = path.resolve(filePath);
// Basic containment check using lexical resolution only.
// This catches obvious traversal (../../../etc/passwd) but NOT symlinks.
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
// Symlink check: resolve the real path of the nearest existing ancestor
// directory and re-validate. This closes the symlink bypass where a
// symlink inside /tmp or cwd points outside the safe zone.
//
// We resolve the parent dir (not the file itself — it may not exist yet).
// If the parent doesn't exist either we fall back up the tree.
let dir = path.dirname(resolved);
let realDir: string;
try {
realDir = fs.realpathSync(dir);
} catch {
// Parent doesn't exist — check the grandparent, or skip if inaccessible
try {
realDir = fs.realpathSync(path.dirname(dir));
} catch {
// Can't resolve — fail safe
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
}
const realResolved = path.join(realDir, path.basename(resolved));
const isRealSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realResolved, dir));
if (!isRealSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')} (symlink target blocked)`);
}
}
/**
+6 -6
View File
@@ -22,13 +22,13 @@ function sliceBetween(source: string, startMarker: string, endMarker: string): s
describe('Server auth security', () => {
// Test 1: /health serves auth token for extension bootstrap (localhost-only, safe)
// Previously token was removed from /health, but extension needs it since
// .auth.json in the extension dir breaks read-only .app bundles and codesigning.
test('/health serves auth token with safety comment', () => {
// Token is gated on chrome-extension:// Origin header to prevent leaking
// when the server is tunneled to the internet.
test('/health serves auth token only for chrome extension origin', () => {
const healthBlock = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/refs'");
expect(healthBlock).toContain('token: AUTH_TOKEN');
// Must have a comment explaining why this is safe
expect(healthBlock).toContain('localhost-only');
expect(healthBlock).toContain('AUTH_TOKEN');
// Must be gated on chrome-extension Origin
expect(healthBlock).toContain('chrome-extension://');
});
// Test 2: /refs endpoint requires auth via validateAuth