Files
gstack/browse/src/cookie-picker-routes.ts
Garry Tan 115d81d792 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>
2026-04-04 22:12:04 -07:00

230 lines
8.6 KiB
TypeScript

/**
* Cookie picker route handler — HTTP + Playwright glue
*
* Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts
* (decryption) and cookie-picker-ui.ts (HTML generation).
*
* Routes (no auth — localhost-only, accepted risk):
* GET /cookie-picker → serves the picker HTML page
* GET /cookie-picker/browsers → list installed browsers
* GET /cookie-picker/domains → list domains + counts for a browser
* POST /cookie-picker/import → decrypt + import cookies to Playwright
* POST /cookie-picker/remove → clear cookies for domains
* GET /cookie-picker/imported → currently imported domains + counts
*/
import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
import { getCookiePickerHTML } from './cookie-picker-ui';
// ─── State ──────────────────────────────────────────────────────
// Tracks which domains were imported via the picker.
// /imported only returns cookies for domains in this Set.
// /remove clears from this Set.
const importedDomains = new Set<string>();
const importedCounts = new Map<string, number>();
// ─── JSON Helpers ───────────────────────────────────────────────
function corsOrigin(port: number): string {
return `http://127.0.0.1:${port}`;
}
function jsonResponse(data: any, opts: { port: number; status?: number }): Response {
return new Response(JSON.stringify(data), {
status: opts.status ?? 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': corsOrigin(opts.port),
},
});
}
function errorResponse(message: string, code: string, opts: { port: number; status?: number; action?: string }): Response {
return jsonResponse(
{ error: message, code, ...(opts.action ? { action: opts.action } : {}) },
{ port: opts.port, status: opts.status ?? 400 },
);
}
// ─── Route Handler ──────────────────────────────────────────────
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;
// CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': corsOrigin(port),
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'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, 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 ───
// 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
if (pathname === '/cookie-picker/browsers' && req.method === 'GET') {
const browsers = findInstalledBrowsers();
return jsonResponse({
browsers: browsers.map(b => ({
name: b.name,
aliases: b.aliases,
})),
}, { port });
}
// GET /cookie-picker/profiles?browser=<name> — list profiles for a browser
if (pathname === '/cookie-picker/profiles' && req.method === 'GET') {
const browserName = url.searchParams.get('browser');
if (!browserName) {
return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
}
const profiles = listProfiles(browserName);
return jsonResponse({ profiles }, { port });
}
// GET /cookie-picker/domains?browser=<name>&profile=<profile> — list domains + counts
if (pathname === '/cookie-picker/domains' && req.method === 'GET') {
const browserName = url.searchParams.get('browser');
if (!browserName) {
return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
}
const profile = url.searchParams.get('profile') || 'Default';
const result = listDomains(browserName, profile);
return jsonResponse({
browser: result.browser,
domains: result.domains,
}, { port });
}
// POST /cookie-picker/import — decrypt + import to Playwright session
if (pathname === '/cookie-picker/import' && req.method === 'POST') {
let body: any;
try {
body = await req.json();
} catch {
return errorResponse('Invalid JSON body', 'bad_request', { port });
}
const { browser, domains, profile } = body;
if (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port });
if (!domains || !Array.isArray(domains) || domains.length === 0) {
return errorResponse("Missing or empty 'domains' array", 'missing_param', { port });
}
// Decrypt cookies from the browser DB
const result = await importCookies(browser, domains, profile || 'Default');
if (result.cookies.length === 0) {
return jsonResponse({
imported: 0,
failed: result.failed,
domainCounts: {},
message: result.failed > 0
? `All ${result.failed} cookies failed to decrypt`
: 'No cookies found for the specified domains',
}, { port });
}
// Add to Playwright context
const page = bm.getPage();
await page.context().addCookies(result.cookies);
// Track what was imported
for (const domain of Object.keys(result.domainCounts)) {
importedDomains.add(domain);
importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]);
}
console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`);
return jsonResponse({
imported: result.count,
failed: result.failed,
domainCounts: result.domainCounts,
}, { port });
}
// POST /cookie-picker/remove — clear cookies for domains
if (pathname === '/cookie-picker/remove' && req.method === 'POST') {
let body: any;
try {
body = await req.json();
} catch {
return errorResponse('Invalid JSON body', 'bad_request', { port });
}
const { domains } = body;
if (!domains || !Array.isArray(domains) || domains.length === 0) {
return errorResponse("Missing or empty 'domains' array", 'missing_param', { port });
}
const page = bm.getPage();
const context = page.context();
for (const domain of domains) {
await context.clearCookies({ domain });
importedDomains.delete(domain);
importedCounts.delete(domain);
}
console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`);
return jsonResponse({
removed: domains.length,
domains,
}, { port });
}
// GET /cookie-picker/imported — currently imported domains + counts
if (pathname === '/cookie-picker/imported' && req.method === 'GET') {
const entries: Array<{ domain: string; count: number }> = [];
for (const domain of importedDomains) {
entries.push({ domain, count: importedCounts.get(domain) || 0 });
}
entries.sort((a, b) => b.count - a.count);
return jsonResponse({
domains: entries,
totalDomains: entries.length,
totalCookies: entries.reduce((sum, e) => sum + e.count, 0),
}, { port });
}
return new Response('Not found', { status: 404 });
} catch (err: any) {
if (err instanceof CookieImportError) {
return errorResponse(err.message, err.code, { port, status: 400, action: err.action });
}
console.error(`[cookie-picker] Error: ${err.message}`);
return errorResponse(err.message || 'Internal error', 'internal_error', { port, status: 500 });
}
}