mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-22 04:38:24 +08:00
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>
This commit is contained in:
@@ -4,20 +4,59 @@
|
|||||||
* Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts
|
* Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts
|
||||||
* (decryption) and cookie-picker-ui.ts (HTML generation).
|
* (decryption) and cookie-picker-ui.ts (HTML generation).
|
||||||
*
|
*
|
||||||
* Routes (no auth — localhost-only, accepted risk):
|
* Auth model (post-CVE fix):
|
||||||
* GET /cookie-picker → serves the picker HTML page
|
* GET /cookie-picker → requires one-time code (?code=) or session cookie
|
||||||
* GET /cookie-picker/browsers → list installed browsers
|
* GET /cookie-picker/browsers → requires Bearer token or session cookie
|
||||||
* GET /cookie-picker/domains → list domains + counts for a browser
|
* GET /cookie-picker/domains → requires Bearer token or session cookie
|
||||||
* POST /cookie-picker/import → decrypt + import cookies to Playwright
|
* POST /cookie-picker/import → requires Bearer token or session cookie
|
||||||
* POST /cookie-picker/remove → clear cookies for domains
|
* POST /cookie-picker/remove → requires Bearer token or session cookie
|
||||||
* GET /cookie-picker/imported → currently imported domains + counts
|
* 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 type { BrowserManager } from './browser-manager';
|
||||||
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
|
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
|
||||||
import { getCookiePickerHTML } from './cookie-picker-ui';
|
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.
|
// Tracks which domains were imported via the picker.
|
||||||
// /imported only returns cookies for domains in this Set.
|
// /imported only returns cookies for domains in this Set.
|
||||||
// /remove clears from this Set.
|
// /remove clears from this Set.
|
||||||
@@ -71,19 +110,56 @@ export async function handleCookiePickerRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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') {
|
if (pathname === '/cookie-picker' && req.method === 'GET') {
|
||||||
const html = getCookiePickerHTML(port, authToken);
|
const code = url.searchParams.get('code');
|
||||||
return new Response(html, {
|
|
||||||
status: 200,
|
// Code exchange: validate one-time code, set session cookie, redirect
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
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 gate: all data/action routes below require Bearer token or session cookie ───
|
||||||
// Auth is mandatory — if authToken is undefined, reject all requests
|
|
||||||
const authHeader = req.headers.get('authorization');
|
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' }), {
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* No cookie values exposed anywhere.
|
* 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}`;
|
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
@@ -341,7 +341,6 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str
|
|||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const BASE = '${baseUrl}';
|
const BASE = '${baseUrl}';
|
||||||
const AUTH_TOKEN = '${authToken || ''}';
|
|
||||||
let activeBrowser = null;
|
let activeBrowser = null;
|
||||||
let activeProfile = 'Default';
|
let activeProfile = 'Default';
|
||||||
let allProfiles = [];
|
let allProfiles = [];
|
||||||
@@ -384,9 +383,7 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str
|
|||||||
|
|
||||||
// ─── API ────────────────────────────────
|
// ─── API ────────────────────────────────
|
||||||
async function api(path, opts) {
|
async function api(path, opts) {
|
||||||
const headers = { ...(opts?.headers || {}) };
|
const res = await fetch(BASE + '/cookie-picker' + path, { ...opts, credentials: 'same-origin' });
|
||||||
if (AUTH_TOKEN) headers['Authorization'] = 'Bearer ' + AUTH_TOKEN;
|
|
||||||
const res = await fetch(BASE + '/cookie-picker' + path, { ...opts, headers });
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error(data.error || 'Request failed');
|
const err = new Error(data.error || 'Request failed');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import type { BrowserManager } from './browser-manager';
|
import type { BrowserManager } from './browser-manager';
|
||||||
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
|
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
|
||||||
|
import { generatePickerCode } from './cookie-picker-routes';
|
||||||
import { validateNavigationUrl } from './url-validation';
|
import { validateNavigationUrl } from './url-validation';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -530,14 +531,15 @@ export async function handleWriteCommand(
|
|||||||
throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`);
|
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 {
|
try {
|
||||||
Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' });
|
Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' });
|
||||||
} catch {
|
} catch {
|
||||||
// open may fail silently — URL is in the message below
|
// 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': {
|
case 'style': {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
* Tests for cookie-picker route handler
|
* Tests for cookie-picker route handler
|
||||||
*
|
*
|
||||||
* Tests the HTTP glue layer directly with mock BrowserManager objects.
|
* 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 { describe, test, expect } from 'bun:test';
|
||||||
import { handleCookiePickerRoute } from '../src/cookie-picker-routes';
|
import { handleCookiePickerRoute, generatePickerCode } from '../src/cookie-picker-routes';
|
||||||
|
|
||||||
// ─── Mock BrowserManager ──────────────────────────────────────
|
// ─── Mock BrowserManager ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -31,15 +32,28 @@ function makeUrl(path: string, port = 9470): URL {
|
|||||||
return new URL(`http://127.0.0.1:${port}${path}`);
|
return new URL(`http://127.0.0.1:${port}${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeReq(method: string, body?: any): Request {
|
function makeReq(method: string, body?: any, headers?: Record<string, string>): Request {
|
||||||
const opts: RequestInit = { method };
|
const opts: RequestInit = { method, headers: { ...headers } };
|
||||||
if (body) {
|
if (body) {
|
||||||
opts.body = JSON.stringify(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);
|
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 ──────────────────────────────────────────────────────
|
// ─── Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('cookie-picker-routes', () => {
|
describe('cookie-picker-routes', () => {
|
||||||
@@ -59,21 +73,27 @@ describe('cookie-picker-routes', () => {
|
|||||||
test('JSON responses include correct CORS origin with port', async () => {
|
test('JSON responses include correct CORS origin with port', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/browsers', 9450);
|
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');
|
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 () => {
|
test('GET /cookie-picker/browsers returns JSON', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/browsers');
|
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.status).toBe(200);
|
||||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
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 () => {
|
test('GET /cookie-picker/domains without browser param returns JSON error', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/domains');
|
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.status).toBe(400);
|
||||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
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', {
|
const req = new Request('http://127.0.0.1:9470', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: 'not json',
|
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.status).toBe(400);
|
||||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
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 () => {
|
test('POST /cookie-picker/import missing browser field returns JSON error', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/import');
|
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);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -128,9 +154,9 @@ describe('cookie-picker-routes', () => {
|
|||||||
test('POST /cookie-picker/import missing domains returns JSON error', async () => {
|
test('POST /cookie-picker/import missing domains returns JSON error', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/import');
|
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);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -143,10 +169,13 @@ describe('cookie-picker-routes', () => {
|
|||||||
const req = new Request('http://127.0.0.1:9470', {
|
const req = new Request('http://127.0.0.1:9470', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: '{bad',
|
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.status).toBe(400);
|
||||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
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 () => {
|
test('POST /cookie-picker/remove missing domains returns JSON error', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/remove');
|
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);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -167,9 +196,12 @@ describe('cookie-picker-routes', () => {
|
|||||||
test('GET /cookie-picker/imported returns JSON with domain list', async () => {
|
test('GET /cookie-picker/imported returns JSON with domain list', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/imported');
|
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.status).toBe(200);
|
||||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||||
@@ -181,45 +213,148 @@ describe('cookie-picker-routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('routing', () => {
|
describe('routing', () => {
|
||||||
test('GET /cookie-picker returns HTML', async () => {
|
test('unknown path returns 404 (with auth)', 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 () => {
|
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/nonexistent');
|
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);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('auth gate security', () => {
|
describe('one-time code exchange', () => {
|
||||||
test('GET /cookie-picker HTML page works without auth token', async () => {
|
test('valid code returns 302 redirect with session cookie', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker');
|
const code = generatePickerCode();
|
||||||
// Request with no Authorization header, but authToken is set on the server
|
const url = makeUrl(`/cookie-picker?code=${code}`);
|
||||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
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.status).toBe(200);
|
||||||
expect(res.headers.get('Content-Type')).toContain('text/html');
|
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 () => {
|
test('GET /cookie-picker/browsers returns 401 without auth', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/browsers');
|
const url = makeUrl('/cookie-picker/browsers');
|
||||||
// No Authorization header
|
|
||||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
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-secret-token');
|
||||||
@@ -241,7 +376,7 @@ describe('cookie-picker-routes', () => {
|
|||||||
expect(body.error).toBe('Unauthorized');
|
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 { bm } = mockBrowserManager();
|
||||||
const url = makeUrl('/cookie-picker/browsers');
|
const url = makeUrl('/cookie-picker/browsers');
|
||||||
const req = new Request('http://127.0.0.1:9470', {
|
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
|
// The ownership check condition must exclude newtab
|
||||||
expect(ownershipBlock).toContain("command !== '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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user