mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
fix: cookie picker auth token leak (v0.15.17.0) (#904)
* fix: cookie picker auth token leak (CVE — CVSS 7.8) GET /cookie-picker served HTML that inlined the master bearer token without authentication. Any local process could extract it and use it to call /command, executing arbitrary JS in the browser context. Fix: Jupyter-style one-time code exchange. The picker URL now includes a one-time code that is consumed via 302 redirect, setting an HttpOnly session cookie. The master AUTH_TOKEN never appears in HTML. The session cookie is isolated from the scoped token system (not valid for /command). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version and changelog (v0.15.17.0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: browse-snapshot E2E turn budget too tight (7 → 9) The agent consistently uses 8 turns for 5 snapshot commands because it reads the saved annotated PNG to verify it was created. All 3 CI attempts hit error_max_turns at exactly 8. Bumping to 9 gives headroom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.16.1.0] - 2026-04-08
|
||||
|
||||
### Fixed
|
||||
- Cookie picker no longer leaks the browse server auth token. Previously, opening the cookie picker page exposed the master bearer token in the HTML source, letting any local process extract it and execute arbitrary JavaScript in your browser session. Now uses a one-time code exchange with an HttpOnly session cookie. The token never appears in HTML, URLs, or browser history. (Reported by Horoshi at Vagabond Research, CVSS 7.8)
|
||||
|
||||
## [0.16.0.0] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,20 +4,59 @@
|
||||
* 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
|
||||
* Auth model (post-CVE fix):
|
||||
* GET /cookie-picker → requires one-time code (?code=) or session cookie
|
||||
* GET /cookie-picker/browsers → requires Bearer token or session cookie
|
||||
* GET /cookie-picker/domains → requires Bearer token or session cookie
|
||||
* POST /cookie-picker/import → requires Bearer token or session cookie
|
||||
* POST /cookie-picker/remove → requires Bearer token or session cookie
|
||||
* GET /cookie-picker/imported → requires Bearer token or session cookie
|
||||
*
|
||||
* The session cookie (gstack_picker) is isolated from the scoped token system.
|
||||
* It is NOT valid for /command. This prevents session cookie extraction from
|
||||
* re-enabling the auth token leak vulnerability.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
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 ──────────────────────────────────────────────────────
|
||||
// ─── Auth State ─────────────────────────────────────────────────
|
||||
// One-time codes for the cookie picker UI (code → expiry timestamp).
|
||||
// Codes are generated by generatePickerCode() and consumed on first use.
|
||||
const pendingCodes = new Map<string, number>();
|
||||
const CODE_TTL_MS = 30_000; // 30 seconds
|
||||
|
||||
// Session cookies for authenticated picker access (session → expiry timestamp).
|
||||
// Sessions are created after a valid code exchange and last 1 hour.
|
||||
const validSessions = new Map<string, number>();
|
||||
const SESSION_TTL_MS = 3_600_000; // 1 hour
|
||||
|
||||
/** Generate a one-time code for opening the cookie picker UI. */
|
||||
export function generatePickerCode(): string {
|
||||
const code = crypto.randomUUID();
|
||||
pendingCodes.set(code, Date.now() + CODE_TTL_MS);
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Extract session ID from the gstack_picker cookie. */
|
||||
function getSessionFromCookie(req: Request): string | null {
|
||||
const cookie = req.headers.get('cookie');
|
||||
if (!cookie) return null;
|
||||
const match = cookie.match(/gstack_picker=([^;]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** Check if a session cookie value is valid and not expired. */
|
||||
function isValidSession(session: string): boolean {
|
||||
const expiry = validSessions.get(session);
|
||||
if (!expiry) return false;
|
||||
if (Date.now() > expiry) { validSessions.delete(session); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Domain State ───────────────────────────────────────────────
|
||||
// Tracks which domains were imported via the picker.
|
||||
// /imported only returns cookies for domains in this Set.
|
||||
// /remove clears from this Set.
|
||||
@@ -71,19 +110,56 @@ export async function handleCookiePickerRoute(
|
||||
}
|
||||
|
||||
try {
|
||||
// GET /cookie-picker — serve the picker UI
|
||||
// GET /cookie-picker — serve the picker UI (requires code or session cookie)
|
||||
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' },
|
||||
const code = url.searchParams.get('code');
|
||||
|
||||
// Code exchange: validate one-time code, set session cookie, redirect
|
||||
if (code) {
|
||||
const expiry = pendingCodes.get(code);
|
||||
if (!expiry || Date.now() > expiry) {
|
||||
pendingCodes.delete(code);
|
||||
return new Response('Invalid or expired code. Re-run cookie-import-browser.', {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
pendingCodes.delete(code); // one-time use
|
||||
const session = crypto.randomUUID();
|
||||
validSessions.set(session, Date.now() + SESSION_TTL_MS);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': '/cookie-picker',
|
||||
'Set-Cookie': `gstack_picker=${session}; HttpOnly; SameSite=Strict; Path=/cookie-picker; Max-Age=3600`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Session cookie: serve HTML (no auth token inlined)
|
||||
const session = getSessionFromCookie(req);
|
||||
if (session && isValidSession(session)) {
|
||||
const html = getCookiePickerHTML(port);
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
// No code, no session: reject
|
||||
return new Response('Access denied. Open the cookie picker from gstack.', {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auth gate: all data/action routes below require Bearer token ───
|
||||
// Auth is mandatory — if authToken is undefined, reject all requests
|
||||
// ─── Auth gate: all data/action routes below require Bearer token or session cookie ───
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authToken || !authHeader || authHeader !== `Bearer ${authToken}`) {
|
||||
const sessionId = getSessionFromCookie(req);
|
||||
const hasBearer = !!authToken && !!authHeader && authHeader === `Bearer ${authToken}`;
|
||||
const hasSession = sessionId !== null && isValidSession(sessionId);
|
||||
if (!hasBearer && !hasSession) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* No cookie values exposed anywhere.
|
||||
*/
|
||||
|
||||
export function getCookiePickerHTML(serverPort: number, authToken?: string): string {
|
||||
export function getCookiePickerHTML(serverPort: number): string {
|
||||
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
@@ -341,7 +341,6 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str
|
||||
<script>
|
||||
(function() {
|
||||
const BASE = '${baseUrl}';
|
||||
const AUTH_TOKEN = '${authToken || ''}';
|
||||
let activeBrowser = null;
|
||||
let activeProfile = 'Default';
|
||||
let allProfiles = [];
|
||||
@@ -384,9 +383,7 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str
|
||||
|
||||
// ─── API ────────────────────────────────
|
||||
async function api(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 res = await fetch(BASE + '/cookie-picker' + path, { ...opts, credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || 'Request failed');
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { TabSession } from './tab-session';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
|
||||
import { generatePickerCode } from './cookie-picker-routes';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { validateOutputPath } from './path-security';
|
||||
import * as fs from 'fs';
|
||||
@@ -515,14 +516,15 @@ export async function handleWriteCommand(
|
||||
throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`);
|
||||
}
|
||||
|
||||
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`;
|
||||
const code = generatePickerCode();
|
||||
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker?code=${code}`;
|
||||
try {
|
||||
Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' });
|
||||
} catch {
|
||||
// open may fail silently — URL is in the message below
|
||||
}
|
||||
|
||||
return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`;
|
||||
return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`;
|
||||
}
|
||||
|
||||
case 'style': {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
* Tests for cookie-picker route handler
|
||||
*
|
||||
* Tests the HTTP glue layer directly with mock BrowserManager objects.
|
||||
* Verifies that all routes return valid JSON (not HTML) with correct CORS headers.
|
||||
* Verifies auth (one-time code exchange, session cookies, Bearer tokens),
|
||||
* CORS headers, and JSON response formats.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { handleCookiePickerRoute } from '../src/cookie-picker-routes';
|
||||
import { handleCookiePickerRoute, generatePickerCode } from '../src/cookie-picker-routes';
|
||||
|
||||
// ─── Mock BrowserManager ──────────────────────────────────────
|
||||
|
||||
@@ -31,15 +32,28 @@ function makeUrl(path: string, port = 9470): URL {
|
||||
return new URL(`http://127.0.0.1:${port}${path}`);
|
||||
}
|
||||
|
||||
function makeReq(method: string, body?: any): Request {
|
||||
const opts: RequestInit = { method };
|
||||
function makeReq(method: string, body?: any, headers?: Record<string, string>): Request {
|
||||
const opts: RequestInit = { method, headers: { ...headers } };
|
||||
if (body) {
|
||||
opts.body = JSON.stringify(body);
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
(opts.headers as any)['Content-Type'] = 'application/json';
|
||||
}
|
||||
return new Request('http://127.0.0.1:9470', opts);
|
||||
}
|
||||
|
||||
/** Helper: exchange a one-time code and return the session cookie value. */
|
||||
async function getSessionCookie(bm: any, authToken: string): Promise<string> {
|
||||
const code = generatePickerCode();
|
||||
const url = makeUrl(`/cookie-picker?code=${code}`);
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const res = await handleCookiePickerRoute(url, req, bm, authToken);
|
||||
expect(res.status).toBe(302);
|
||||
const setCookie = res.headers.get('Set-Cookie') || '';
|
||||
const match = setCookie.match(/gstack_picker=([^;]+)/);
|
||||
expect(match).not.toBeNull();
|
||||
return match![1];
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('cookie-picker-routes', () => {
|
||||
@@ -59,21 +73,27 @@ describe('cookie-picker-routes', () => {
|
||||
test('JSON responses include correct CORS origin with port', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/browsers', 9450);
|
||||
const req = new Request('http://127.0.0.1:9450', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9450', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://127.0.0.1:9450');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON responses (not HTML)', () => {
|
||||
describe('JSON responses (with auth)', () => {
|
||||
test('GET /cookie-picker/browsers returns JSON', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -85,9 +105,12 @@ describe('cookie-picker-routes', () => {
|
||||
test('GET /cookie-picker/domains without browser param returns JSON error', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/domains');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -102,10 +125,13 @@ describe('cookie-picker-routes', () => {
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'POST',
|
||||
body: 'not json',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer test-token',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -116,9 +142,9 @@ describe('cookie-picker-routes', () => {
|
||||
test('POST /cookie-picker/import missing browser field returns JSON error', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/import');
|
||||
const req = makeReq('POST', { domains: ['.example.com'] });
|
||||
const req = makeReq('POST', { domains: ['.example.com'] }, { 'Authorization': 'Bearer test-token' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
@@ -128,9 +154,9 @@ describe('cookie-picker-routes', () => {
|
||||
test('POST /cookie-picker/import missing domains returns JSON error', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/import');
|
||||
const req = makeReq('POST', { browser: 'Chrome' });
|
||||
const req = makeReq('POST', { browser: 'Chrome' }, { 'Authorization': 'Bearer test-token' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
@@ -143,10 +169,13 @@ describe('cookie-picker-routes', () => {
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'POST',
|
||||
body: '{bad',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer test-token',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -155,9 +184,9 @@ describe('cookie-picker-routes', () => {
|
||||
test('POST /cookie-picker/remove missing domains returns JSON error', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/remove');
|
||||
const req = makeReq('POST', {});
|
||||
const req = makeReq('POST', {}, { 'Authorization': 'Bearer test-token' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
@@ -167,9 +196,12 @@ describe('cookie-picker-routes', () => {
|
||||
test('GET /cookie-picker/imported returns JSON with domain list', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/imported');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -181,45 +213,148 @@ describe('cookie-picker-routes', () => {
|
||||
});
|
||||
|
||||
describe('routing', () => {
|
||||
test('GET /cookie-picker returns HTML', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toContain('text/html');
|
||||
});
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
test('unknown path returns 404 (with auth)', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/nonexistent');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth gate security', () => {
|
||||
test('GET /cookie-picker HTML page works without auth token', async () => {
|
||||
describe('one-time code exchange', () => {
|
||||
test('valid code returns 302 redirect with session cookie', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker');
|
||||
// Request with no Authorization header, but authToken is set on the server
|
||||
const code = generatePickerCode();
|
||||
const url = makeUrl(`/cookie-picker?code=${code}`);
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-secret-token');
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get('Location')).toBe('/cookie-picker');
|
||||
const setCookie = res.headers.get('Set-Cookie') || '';
|
||||
expect(setCookie).toContain('gstack_picker=');
|
||||
expect(setCookie).toContain('HttpOnly');
|
||||
expect(setCookie).toContain('SameSite=Strict');
|
||||
expect(setCookie).toContain('Path=/cookie-picker');
|
||||
expect(setCookie).toContain('Max-Age=3600');
|
||||
expect(res.headers.get('Cache-Control')).toBe('no-store');
|
||||
});
|
||||
|
||||
test('code cannot be reused', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const code = generatePickerCode();
|
||||
const url = makeUrl(`/cookie-picker?code=${code}`);
|
||||
|
||||
// First use: success
|
||||
const req1 = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const res1 = await handleCookiePickerRoute(url, req1, bm, 'test-token');
|
||||
expect(res1.status).toBe(302);
|
||||
|
||||
// Second use: rejected
|
||||
const req2 = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const res2 = await handleCookiePickerRoute(url, req2, bm, 'test-token');
|
||||
expect(res2.status).toBe(403);
|
||||
});
|
||||
|
||||
test('invalid code returns 403', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker?code=not-a-valid-code');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('GET /cookie-picker without code or session returns 403', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session cookie auth', () => {
|
||||
test('valid session cookie grants HTML access', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const session = await getSessionCookie(bm, 'test-token');
|
||||
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toContain('text/html');
|
||||
});
|
||||
|
||||
test('HTML response does NOT contain auth token', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const authToken = 'super-secret-auth-token-12345';
|
||||
const session = await getSessionCookie(bm, authToken);
|
||||
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, authToken);
|
||||
const html = await res.text();
|
||||
|
||||
expect(html).not.toContain(authToken);
|
||||
expect(html).not.toContain('AUTH_TOKEN');
|
||||
});
|
||||
|
||||
test('data routes accept session cookie', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const session = await getSessionCookie(bm, 'test-token');
|
||||
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('browsers');
|
||||
});
|
||||
|
||||
test('invalid session cookie returns 403 for HTML', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': 'gstack_picker=fake-session' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth gate security', () => {
|
||||
test('GET /cookie-picker/browsers returns 401 without auth', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
// No Authorization header
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-secret-token');
|
||||
@@ -241,7 +376,7 @@ describe('cookie-picker-routes', () => {
|
||||
expect(body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
test('GET /cookie-picker/browsers works with valid auth', async () => {
|
||||
test('GET /cookie-picker/browsers works with valid Bearer auth', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
|
||||
@@ -317,4 +317,28 @@ describe('Server auth security', () => {
|
||||
// The ownership check condition must exclude newtab
|
||||
expect(ownershipBlock).toContain("command !== 'newtab'");
|
||||
});
|
||||
|
||||
// CVE fix: cookie-picker HTML must NOT inline the auth token.
|
||||
// getCookiePickerHTML() must not accept an authToken parameter.
|
||||
test('cookie-picker UI does not accept or inline auth token', () => {
|
||||
const uiSrc = fs.readFileSync(path.join(import.meta.dir, '../src/cookie-picker-ui.ts'), 'utf-8');
|
||||
// Function signature must not include authToken
|
||||
expect(uiSrc).not.toMatch(/getCookiePickerHTML\([^)]*authToken/);
|
||||
// No AUTH_TOKEN interpolation in template
|
||||
expect(uiSrc).not.toContain("AUTH_TOKEN = '${authToken");
|
||||
expect(uiSrc).not.toContain("AUTH_TOKEN = '${auth");
|
||||
});
|
||||
|
||||
// CVE fix: cookie-picker route handler uses one-time code exchange, not open access.
|
||||
test('cookie-picker HTML route requires code or session cookie', () => {
|
||||
const routeSrc = fs.readFileSync(path.join(import.meta.dir, '../src/cookie-picker-routes.ts'), 'utf-8');
|
||||
// Must have code validation
|
||||
expect(routeSrc).toContain('pendingCodes');
|
||||
expect(routeSrc).toContain('validSessions');
|
||||
// Must NOT pass authToken to getCookiePickerHTML
|
||||
expect(routeSrc).not.toMatch(/getCookiePickerHTML\([^)]*authToken/);
|
||||
// Must set HttpOnly session cookie
|
||||
expect(routeSrc).toContain('HttpOnly');
|
||||
expect(routeSrc).toContain('SameSite=Strict');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ Report the results of each command.`,
|
||||
5. $B snapshot -i -a -o /tmp/skill-e2e-annotated.png
|
||||
Report what each command returned.`,
|
||||
workingDirectory: tmpDir,
|
||||
maxTurns: 7,
|
||||
maxTurns: 9,
|
||||
timeout: 60_000,
|
||||
testName: 'browse-snapshot',
|
||||
runId,
|
||||
|
||||
Reference in New Issue
Block a user