Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 115d81d

Browse files
garrytanGonzihclaudegaragon
authored
fix: security wave 1 — 14 fixes for audit garrytan#783 (v0.15.7.0) (garrytan#810)
* fix: DNS rebinding protection checks AAAA (IPv6) records too Cherry-pick PR garrytan#744 by @Gonzih. Closes the IPv6-only DNS rebinding gap by checking both A and AAAA records independently. Co-Authored-By: Gonzih <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: validateOutputPath symlink bypass — resolve real path before safe-dir check Cherry-pick PR garrytan#745 by @Gonzih. Adds a second pass using fs.realpathSync() to resolve symlinks after lexical path validation. Co-Authored-By: Gonzih <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: validate saved URLs before navigation in restoreState Cherry-pick PR garrytan#751 by @Gonzih. Prevents navigation to cloud metadata endpoints or file:// URIs embedded in user-writable state files. Co-Authored-By: Gonzih <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: telemetry-ingest uses anon key instead of service role key Cherry-pick PR garrytan#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 <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: killAgent() actually kills the sidebar claude subprocess Cherry-pick PR garrytan#743 by @Gonzih. Implements cross-process kill signaling via kill-file + polling pattern, tracks active processes per-tab. Co-Authored-By: Gonzih <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(design): bind server to localhost and validate reload paths Cherry-pick PR garrytan#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 garrytan#783. Co-Authored-By: garagon <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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 garrytan#783. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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 garrytan#783. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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 garrytan#783. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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 garrytan#783. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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 garrytan#783. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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 garrytan#783. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * chore: bump version and changelog (v0.15.7.0) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Gonzih <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: garagon <[email protected]>
1 parent 31943b2 commit 115d81d

14 files changed

Lines changed: 207 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# Changelog
22

3+
## [0.15.7.0] - 2026-04-05 — Security Wave 1
4+
5+
Fourteen fixes for the security audit (#783). Design server no longer binds all interfaces. Path traversal, auth bypass, CORS wildcard, world-readable files, prompt injection, and symlink race conditions all closed. Community PRs from @Gonzih and @garagon included.
6+
7+
### Fixed
8+
9+
- **Design server binds localhost only.** Previously bound 0.0.0.0, meaning anyone on your WiFi could access mockups and hit all endpoints. Now 127.0.0.1 only, matching the browse server.
10+
- **Path traversal on /api/reload blocked.** Could previously read any file on disk (including ~/.ssh/id_rsa) by passing an arbitrary path in the JSON body. Now validates paths stay within cwd or tmpdir.
11+
- **Auth gate on /inspector/events.** SSE endpoint was unauthenticated while /activity/stream required tokens. Now both require the same Bearer or ?token= check.
12+
- **Prompt injection defense in design feedback.** User feedback is now wrapped in XML trust boundary markers with tag escaping. Accumulated feedback capped to last 5 iterations to limit poisoning.
13+
- **File and directory permissions hardened.** All ~/.gstack/ dirs now created with mode 0o700, files with 0o600. Setup script sets umask 077. Auth tokens, chat history, and browser logs no longer world-readable.
14+
- **TOCTOU race in setup symlink creation.** Removed existence check before mkdir -p (idempotent). Validates target isn't a symlink before creating the link.
15+
- **CORS wildcard removed.** Browse server no longer sends Access-Control-Allow-Origin: *. Chrome extension uses manifest host_permissions and isn't affected. Blocks malicious websites from making cross-origin requests.
16+
- **Cookie picker auth mandatory.** Previously skipped auth when authToken was undefined. Now always requires Bearer token for all data/action routes.
17+
- **/health token gated on extension Origin.** Auth token only returned when request comes from chrome-extension:// origin. Prevents token leak when browse server is tunneled.
18+
- **DNS rebinding protection checks IPv6.** AAAA records now validated alongside A records. Blocks fe80:: link-local addresses.
19+
- **Symlink bypass in validateOutputPath.** Real path resolved after lexical validation to catch symlinks inside safe directories.
20+
- **URL validation on restoreState.** Saved URLs validated before navigation to prevent state file tampering.
21+
- **Telemetry endpoint uses anon key.** Service role key (bypasses RLS) replaced with anon key for the public telemetry endpoint.
22+
- **killAgent actually kills subprocess.** Cross-process kill signaling via kill-file + polling.
23+
324
## [0.15.6.2] - 2026-04-04 — Anti-Skip Review Rule
425

526
Review skills now enforce that every section gets evaluated, regardless of plan type. No more "this is a strategy doc so implementation sections don't apply." If a section genuinely has nothing to flag, say so and move on, but you have to look.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.15.6.2
1+
0.15.7.0

browse/src/browser-manager.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,15 @@ export class BrowserManager {
822822
this.wirePageEvents(page);
823823

824824
if (saved.url) {
825-
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
825+
// Validate the saved URL before navigating — the state file is user-writable and
826+
// a tampered URL could navigate to cloud metadata endpoints or file:// URIs.
827+
try {
828+
await validateNavigationUrl(saved.url);
829+
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
830+
} catch {
831+
// Invalid URL in saved state — skip navigation, leave blank page
832+
console.log(`[browse] restoreState: skipping unsafe URL: ${saved.url}`);
833+
}
826834
}
827835

828836
if (saved.storage) {

browse/src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export function resolveConfig(
7979
*/
8080
export function ensureStateDir(config: BrowseConfig): void {
8181
try {
82-
fs.mkdirSync(config.stateDir, { recursive: true });
82+
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
8383
} catch (err: any) {
8484
if (err.code === 'EACCES') {
8585
throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`);

browse/src/cookie-picker-routes.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,13 @@ export async function handleCookiePickerRoute(
8181
}
8282

8383
// ─── Auth gate: all data/action routes below require Bearer token ───
84-
if (authToken) {
85-
const authHeader = req.headers.get('authorization');
86-
if (!authHeader || authHeader !== `Bearer ${authToken}`) {
87-
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
88-
status: 401,
89-
headers: { 'Content-Type': 'application/json' },
90-
});
91-
}
84+
// Auth is mandatory — if authToken is undefined, reject all requests
85+
const authHeader = req.headers.get('authorization');
86+
if (!authToken || !authHeader || authHeader !== `Bearer ${authToken}`) {
87+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
88+
status: 401,
89+
headers: { 'Content-Type': 'application/json' },
90+
});
9291
}
9392

9493
// GET /cookie-picker/browsers — list installed browsers

browse/src/server.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,10 @@ function createSession(): SidebarSession {
398398
lastActiveAt: new Date().toISOString(),
399399
};
400400
const sessionDir = path.join(SESSIONS_DIR, id);
401-
fs.mkdirSync(sessionDir, { recursive: true });
402-
fs.writeFileSync(path.join(sessionDir, 'session.json'), JSON.stringify(session, null, 2));
403-
fs.writeFileSync(path.join(sessionDir, 'chat.jsonl'), '');
404-
fs.writeFileSync(path.join(SESSIONS_DIR, 'active.json'), JSON.stringify({ id }));
401+
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
402+
fs.writeFileSync(path.join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), { mode: 0o600 });
403+
fs.writeFileSync(path.join(sessionDir, 'chat.jsonl'), '', { mode: 0o600 });
404+
fs.writeFileSync(path.join(SESSIONS_DIR, 'active.json'), JSON.stringify({ id }), { mode: 0o600 });
405405
chatBuffer = [];
406406
chatNextId = 0;
407407
return session;
@@ -411,7 +411,7 @@ function saveSession(): void {
411411
if (!sidebarSession) return;
412412
sidebarSession.lastActiveAt = new Date().toISOString();
413413
const sessionFile = path.join(SESSIONS_DIR, sidebarSession.id, 'session.json');
414-
try { fs.writeFileSync(sessionFile, JSON.stringify(sidebarSession, null, 2)); } catch (err: any) {
414+
try { fs.writeFileSync(sessionFile, JSON.stringify(sidebarSession, null, 2), { mode: 0o600 }); } catch (err: any) {
415415
console.error('[browse] Failed to save session:', err.message);
416416
}
417417
}
@@ -558,7 +558,7 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId
558558
tabId: agentTabId,
559559
});
560560
try {
561-
fs.mkdirSync(gstackDir, { recursive: true });
561+
fs.mkdirSync(gstackDir, { recursive: true, mode: 0o700 });
562562
fs.appendFileSync(agentQueue, entry + '\n');
563563
} catch (err: any) {
564564
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
@@ -585,6 +585,13 @@ function killAgent(): void {
585585
agentStartTime = null;
586586
currentMessage = null;
587587
agentStatus = 'idle';
588+
589+
// Signal sidebar-agent.ts to kill its active claude subprocess.
590+
// sidebar-agent runs in a separate non-compiled Bun process (posix_spawn
591+
// limitation). It polls the kill-signal file and terminates on any write.
592+
const agentQueue = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
593+
const killFile = path.join(path.dirname(agentQueue), 'sidebar-agent-kill');
594+
try { fs.writeFileSync(killFile, String(Date.now())); } catch {}
588595
}
589596

590597
// Agent health check — detect hung processes
@@ -607,7 +614,7 @@ function startAgentHealthCheck(): void {
607614

608615
// Initialize session on startup
609616
function initSidebarSession(): void {
610-
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
617+
fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
611618
sidebarSession = loadSession();
612619
if (!sidebarSession) {
613620
sidebarSession = createSession();
@@ -1086,10 +1093,11 @@ async function start() {
10861093
uptime: Math.floor((Date.now() - startTime) / 1000),
10871094
tabs: browserManager.getTabCount(),
10881095
currentUrl: browserManager.getCurrentUrl(),
1089-
// Auth token for extension bootstrap. Safe: /health is localhost-only.
1090-
// Previously served via .auth.json in extension dir, but that breaks
1091-
// read-only .app bundles and codesigning. Extension reads token from here.
1092-
token: AUTH_TOKEN,
1096+
// Auth token for extension bootstrap. Only returned when the request
1097+
// comes from a Chrome extension (Origin: chrome-extension://...).
1098+
// Previously served unconditionally, but that leaks the token if the
1099+
// server is tunneled to the internet (ngrok, SSH tunnel).
1100+
...(req.headers.get('origin')?.startsWith('chrome-extension://') ? { token: AUTH_TOKEN } : {}),
10931101
chatEnabled: true,
10941102
agent: {
10951103
status: agentStatus,
@@ -1222,12 +1230,12 @@ async function start() {
12221230
const tabs = await browserManager.getTabListWithTitles();
12231231
return new Response(JSON.stringify({ tabs }), {
12241232
status: 200,
1225-
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
1233+
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
12261234
});
12271235
} catch (err: any) {
12281236
return new Response(JSON.stringify({ tabs: [], error: err.message }), {
12291237
status: 200,
1230-
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
1238+
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
12311239
});
12321240
}
12331241
}
@@ -1246,7 +1254,7 @@ async function start() {
12461254
browserManager.switchTab(tabId);
12471255
return new Response(JSON.stringify({ ok: true, activeTab: tabId }), {
12481256
status: 200,
1249-
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
1257+
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
12501258
});
12511259
} catch (err: any) {
12521260
return new Response(JSON.stringify({ error: err.message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
@@ -1268,7 +1276,7 @@ async function start() {
12681276
const tabAgentStatus = tabId !== null ? getTabAgentStatus(tabId) : agentStatus;
12691277
return new Response(JSON.stringify({ entries, total: chatNextId, agentStatus: tabAgentStatus, activeTabId: activeTab }), {
12701278
status: 200,
1271-
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
1279+
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://127.0.0.1' },
12721280
});
12731281
}
12741282

@@ -1324,7 +1332,7 @@ async function start() {
13241332
chatBuffer = [];
13251333
chatNextId = 0;
13261334
if (sidebarSession) {
1327-
try { fs.writeFileSync(path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl'), ''); } catch (err: any) {
1335+
try { fs.writeFileSync(path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl'), '', { mode: 0o600 }); } catch (err: any) {
13281336
console.error('[browse] Failed to clear chat file:', err.message);
13291337
}
13301338
}
@@ -1549,8 +1557,14 @@ async function start() {
15491557
});
15501558
}
15511559

1552-
// GET /inspector/events — SSE for inspector state changes
1560+
// GET /inspector/events — SSE for inspector state changes (auth required)
15531561
if (url.pathname === '/inspector/events' && req.method === 'GET') {
1562+
const streamToken = url.searchParams.get('token');
1563+
if (!validateAuth(req) && streamToken !== AUTH_TOKEN) {
1564+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
1565+
status: 401, headers: { 'Content-Type': 'application/json' },
1566+
});
1567+
}
15541568
const encoder = new TextEncoder();
15551569
const stream = new ReadableStream({
15561570
start(controller) {
@@ -1680,8 +1694,8 @@ start().catch((err) => {
16801694
// stderr because the server is launched with detached: true, stdio: 'ignore'.
16811695
try {
16821696
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
1683-
fs.mkdirSync(config.stateDir, { recursive: true });
1684-
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
1697+
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
1698+
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`, { mode: 0o600 });
16851699
} catch {
16861700
// stateDir may not exist — nothing more we can do
16871701
}

browse/src/sidebar-agent.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as fs from 'fs';
1414
import * as path from 'path';
1515

1616
const QUEUE = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
17+
const KILL_FILE = path.join(path.dirname(QUEUE), 'sidebar-agent-kill');
1718
const SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '34567', 10);
1819
const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`;
1920
const POLL_MS = 200; // 200ms poll — keeps time-to-first-token low
@@ -23,6 +24,10 @@ let lastLine = 0;
2324
let authToken: string | null = null;
2425
// Per-tab processing — each tab can run its own agent concurrently
2526
const processingTabs = new Set<number>();
27+
// Active claude subprocesses — keyed by tabId for targeted kill
28+
const activeProcs = new Map<number, ReturnType<typeof spawn>>();
29+
// Kill-file timestamp last seen — avoids double-kill on same write
30+
let lastKillTs = 0;
2631

2732
// ─── File drop relay ──────────────────────────────────────────
2833

@@ -44,7 +49,7 @@ function writeToInbox(message: string, pageUrl?: string, sessionId?: string): vo
4449
}
4550

4651
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
47-
fs.mkdirSync(inboxDir, { recursive: true });
52+
fs.mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
4853

4954
const now = new Date();
5055
const timestamp = now.toISOString().replace(/:/g, '-');
@@ -60,7 +65,7 @@ function writeToInbox(message: string, pageUrl?: string, sessionId?: string): vo
6065
sidebarSessionId: sessionId || 'unknown',
6166
};
6267

63-
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
68+
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2), { mode: 0o600 });
6469
fs.renameSync(tmpFile, finalFile);
6570
console.log(`[sidebar-agent] Wrote inbox message: ${filename}`);
6671
}
@@ -263,6 +268,9 @@ async function askClaude(queueEntry: any): Promise<void> {
263268
},
264269
});
265270

271+
// Track active procs so kill-file polling can terminate them
272+
activeProcs.set(tid, proc);
273+
266274
proc.stdin.end();
267275

268276
let buffer = '';
@@ -285,6 +293,7 @@ async function askClaude(queueEntry: any): Promise<void> {
285293
});
286294

287295
proc.on('close', (code) => {
296+
activeProcs.delete(tid);
288297
if (buffer.trim()) {
289298
try { handleStreamEvent(JSON.parse(buffer), tid); } catch (err: any) {
290299
console.error(`[sidebar-agent] Tab ${tid}: Failed to parse final buffer:`, buffer.slice(0, 100), err.message);
@@ -381,10 +390,31 @@ async function poll() {
381390

382391
// ─── Main ────────────────────────────────────────────────────────
383392

393+
function pollKillFile(): void {
394+
try {
395+
const stat = fs.statSync(KILL_FILE);
396+
const mtime = stat.mtimeMs;
397+
if (mtime > lastKillTs) {
398+
lastKillTs = mtime;
399+
if (activeProcs.size > 0) {
400+
console.log(`[sidebar-agent] Kill signal received — terminating ${activeProcs.size} active agent(s)`);
401+
for (const [tid, proc] of activeProcs) {
402+
try { proc.kill('SIGTERM'); } catch {}
403+
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 2000);
404+
processingTabs.delete(tid);
405+
}
406+
activeProcs.clear();
407+
}
408+
}
409+
} catch {
410+
// Kill file doesn't exist yet — normal state
411+
}
412+
}
413+
384414
async function main() {
385415
const dir = path.dirname(QUEUE);
386-
fs.mkdirSync(dir, { recursive: true });
387-
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '');
416+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
417+
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '', { mode: 0o600 });
388418

389419
lastLine = countLines();
390420
await refreshToken();
@@ -394,6 +424,7 @@ async function main() {
394424
console.log(`[sidebar-agent] Browse binary: ${B}`);
395425

396426
setInterval(poll, POLL_MS);
427+
setInterval(pollKillFile, POLL_MS);
397428
}
398429

399430
main().catch(console.error);

browse/src/url-validation.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
*/
55

66
const BLOCKED_METADATA_HOSTS = new Set([
7-
'169.254.169.254', // AWS/GCP/Azure instance metadata
7+
'169.254.169.254', // AWS/GCP/Azure instance metadata (IPv4 link-local)
8+
'fe80::1', // IPv6 link-local — common metadata endpoint alias
89
'fd00::', // IPv6 unique local (metadata in some cloud setups)
10+
'::ffff:169.254.169.254', // IPv4-mapped IPv6 form of the metadata IP
911
'metadata.google.internal', // GCP metadata
1012
'metadata.azure.internal', // Azure IMDS
1113
]);
@@ -47,15 +49,37 @@ function isMetadataIp(hostname: string): boolean {
4749
/**
4850
* Resolve a hostname to its IP addresses and check if any resolve to blocked metadata IPs.
4951
* Mitigates DNS rebinding: even if the hostname looks safe, the resolved IP might not be.
52+
*
53+
* Checks both A (IPv4) and AAAA (IPv6) records — an attacker can use AAAA-only DNS to
54+
* bypass IPv4-only checks. Each record family is tried independently; failure of one
55+
* (e.g. no AAAA records exist) is not treated as a rebinding risk.
5056
*/
5157
async function resolvesToBlockedIp(hostname: string): Promise<boolean> {
5258
try {
5359
const dns = await import('node:dns');
54-
const { resolve4 } = dns.promises;
55-
const addresses = await resolve4(hostname);
56-
return addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr));
60+
const { resolve4, resolve6 } = dns.promises;
61+
62+
// Check IPv4 A records
63+
const v4Check = resolve4(hostname).then(
64+
(addresses) => addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr)),
65+
() => false, // ENODATA / ENOTFOUND — no A records, not a risk
66+
);
67+
68+
// Check IPv6 AAAA records — the gap that issue #668 identified
69+
const v6Check = resolve6(hostname).then(
70+
(addresses) => addresses.some(addr => {
71+
const normalized = addr.toLowerCase();
72+
return BLOCKED_METADATA_HOSTS.has(normalized) ||
73+
// fe80::/10 is link-local — always block (covers all fe80:: addresses)
74+
normalized.startsWith('fe80:');
75+
}),
76+
() => false, // ENODATA / ENOTFOUND — no AAAA records, not a risk
77+
);
78+
79+
const [v4Blocked, v6Blocked] = await Promise.all([v4Check, v6Check]);
80+
return v4Blocked || v6Blocked;
5781
} catch {
58-
// DNS resolution failed — not a rebinding risk
82+
// Unexpected error — fail open (don't block navigation on DNS infrastructure failure)
5983
return false;
6084
}
6185
}

0 commit comments

Comments
 (0)