mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-20 22:40:58 +02:00
security(N1): replace ?token= SSE auth with HttpOnly session cookie
Activity stream and inspector events SSE endpoints accepted the root
AUTH_TOKEN via `?token=` query param (EventSource can't send Authorization
headers). URLs leak to browser history, referer headers, server logs,
crash reports, and refactoring accidents. Codex flagged this during the
/plan-ceo-review outside voice pass.
New auth model: the extension calls POST /sse-session with a Bearer token
and receives a view-only session cookie (HttpOnly, SameSite=Strict, 30-min
TTL). EventSource is opened with `withCredentials: true` so the browser
sends the cookie back on the SSE connection. The ?token= query param is
GONE — no more URL-borne secrets.
Scope isolation (prior learning cookie-picker-auth-isolation, 10/10
confidence): the SSE session cookie grants access to /activity/stream and
/inspector/events ONLY. The token is never valid against /command, /token,
or any mutating endpoint. A leaked cookie can watch activity; it cannot
execute browser commands.
Components
* browse/src/sse-session-cookie.ts — registry: mint/validate/extract/
build-cookie. 256-bit tokens, 30-min TTL, lazy expiry pruning,
no imports from token-registry (scope isolation enforced by module
boundary).
* browse/src/server.ts — POST /sse-session mint endpoint (requires
Bearer). /activity/stream and /inspector/events now accept Bearer
OR the session cookie, and reject ?token= query param.
* extension/sidepanel.js — ensureSseSessionCookie() bootstrap call,
EventSource opened with withCredentials:true on both SSE endpoints.
Tested via the source guards; behavioral test is the E2E pairing
flow that lands later in the wave.
* browse/test/sse-session-cookie.test.ts — 20 unit tests covering
mint entropy, TTL enforcement, cookie flag invariants, cookie
parsing from multi-cookie headers, and scope-isolation contract
guard (module must not import token-registry).
* browse/test/server-auth.test.ts — existing /activity/stream auth
test updated to assert the new cookie-based gate and the absence
of the ?token= query param.
Cookie flag choices:
* HttpOnly: token not readable from page JS (mitigates XSS
exfiltration).
* SameSite=Strict: cookie not sent on cross-site requests (mitigates
CSRF). Fine for SSE because the extension connects to 127.0.0.1
directly.
* Path=/: cookie scoped to the whole origin.
* Max-Age=1800: 30 minutes, matches TTL. Extension re-mints on
reconnect when daemon restarts.
* Secure NOT set: daemon binds to 127.0.0.1 over plain HTTP. Adding
Secure would block the browser from ever sending the cookie back.
Add Secure when gstack ships over HTTPS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+47
-5
@@ -42,6 +42,10 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory
|
|||||||
// fail posix_spawn on all executables including /bin/bash)
|
// fail posix_spawn on all executables including /bin/bash)
|
||||||
import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
|
import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
|
||||||
import { logTunnelDenial } from './tunnel-denial-log';
|
import { logTunnelDenial } from './tunnel-denial-log';
|
||||||
|
import {
|
||||||
|
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
||||||
|
buildSseSetCookie, SSE_COOKIE_NAME,
|
||||||
|
} from './sse-session-cookie';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -1910,6 +1914,37 @@ async function start() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── SSE session cookie mint (auth required) ──────────────────
|
||||||
|
//
|
||||||
|
// Issues a short-lived view-only token in an HttpOnly SameSite=Strict
|
||||||
|
// cookie so EventSource calls can authenticate without putting the
|
||||||
|
// root token in a URL. The returned cookie is valid ONLY on the SSE
|
||||||
|
// endpoints (/activity/stream, /inspector/events); it is not a
|
||||||
|
// scoped token and cannot be used against /command.
|
||||||
|
//
|
||||||
|
// The extension calls this once at bootstrap with the root Bearer
|
||||||
|
// header, then opens EventSource with `withCredentials: true` which
|
||||||
|
// sends the cookie back automatically.
|
||||||
|
if (url.pathname === '/sse-session' && req.method === 'POST') {
|
||||||
|
if (!validateAuth(req)) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const minted = mintSseSessionToken();
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
expiresAt: minted.expiresAt,
|
||||||
|
cookie: SSE_COOKIE_NAME,
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Set-Cookie': buildSseSetCookie(minted.token),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Refs endpoint — auth required, does NOT reset idle timer
|
// Refs endpoint — auth required, does NOT reset idle timer
|
||||||
if (url.pathname === '/refs') {
|
if (url.pathname === '/refs') {
|
||||||
if (!validateAuth(req)) {
|
if (!validateAuth(req)) {
|
||||||
@@ -1931,9 +1966,14 @@ async function start() {
|
|||||||
|
|
||||||
// Activity stream — SSE, auth required, does NOT reset idle timer
|
// Activity stream — SSE, auth required, does NOT reset idle timer
|
||||||
if (url.pathname === '/activity/stream') {
|
if (url.pathname === '/activity/stream') {
|
||||||
// Inline auth: accept Bearer header OR ?token= query param (EventSource can't send headers)
|
// Auth: Bearer header OR view-only SSE session cookie (EventSource
|
||||||
const streamToken = url.searchParams.get('token');
|
// can't send Authorization headers, so the extension fetches a cookie
|
||||||
if (!validateAuth(req) && streamToken !== AUTH_TOKEN) {
|
// via POST /sse-session first, then opens EventSource with
|
||||||
|
// withCredentials: true). The ?token= query param is NO LONGER
|
||||||
|
// accepted — URLs leak to logs/referer/history. See N1 in the
|
||||||
|
// v1.6.0.0 security wave plan.
|
||||||
|
const cookieToken = extractSseCookie(req);
|
||||||
|
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
|
||||||
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' },
|
||||||
@@ -2563,8 +2603,10 @@ async function start() {
|
|||||||
|
|
||||||
// GET /inspector/events — SSE for inspector state changes (auth required)
|
// GET /inspector/events — SSE for inspector state changes (auth required)
|
||||||
if (url.pathname === '/inspector/events' && req.method === 'GET') {
|
if (url.pathname === '/inspector/events' && req.method === 'GET') {
|
||||||
const streamToken = url.searchParams.get('token');
|
// Same auth model as /activity/stream: Bearer OR view-only cookie.
|
||||||
if (!validateAuth(req) && streamToken !== AUTH_TOKEN) {
|
// ?token= query param dropped (see N1 in the v1.6.0.0 security plan).
|
||||||
|
const cookieToken = extractSseCookie(req);
|
||||||
|
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
status: 401, headers: { 'Content-Type': 'application/json' },
|
status: 401, headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* View-only session cookie registry for SSE endpoints.
|
||||||
|
*
|
||||||
|
* Why this exists: EventSource cannot send Authorization headers, so
|
||||||
|
* /activity/stream and /inspector/events historically took a `?token=`
|
||||||
|
* query param with the root AUTH_TOKEN. URLs leak through browser history,
|
||||||
|
* referer headers, server logs, crash reports, and refactoring accidents
|
||||||
|
* (Codex's plan-review outside voice called this out). This module issues
|
||||||
|
* a separate short-lived token, scoped to SSE reads only, delivered via
|
||||||
|
* an HttpOnly SameSite=Strict cookie that EventSource can pick up with
|
||||||
|
* `withCredentials: true`.
|
||||||
|
*
|
||||||
|
* Design notes:
|
||||||
|
* - TTL 30 minutes. Long enough for a normal coding session; short enough
|
||||||
|
* that a leaked cookie expires quickly.
|
||||||
|
* - Scope is implicit: validating a cookie only grants read access to
|
||||||
|
* /activity/stream and /inspector/events. The cookie is NEVER valid on
|
||||||
|
* /command, /token, or any mutating endpoint. Matches the
|
||||||
|
* cookie-picker-auth-isolation pattern (prior learning, 10/10 confidence):
|
||||||
|
* cookie-based session tokens must not be valid as scoped tokens.
|
||||||
|
* - In-memory only. No persistence across daemon restarts — extension
|
||||||
|
* re-mints on reconnect.
|
||||||
|
* - Tokens are 32 random bytes (URL-safe base64). 256 bits, unbruteforceable.
|
||||||
|
*/
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
|
||||||
|
export const SSE_COOKIE_NAME = 'gstack_sse';
|
||||||
|
|
||||||
|
/** Mint a fresh view-only SSE session token. */
|
||||||
|
export function mintSseSessionToken(): { token: string; expiresAt: number } {
|
||||||
|
// 32 random bytes → 43-char URL-safe base64 (no padding)
|
||||||
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = now + TTL_MS;
|
||||||
|
sessions.set(token, { createdAt: now, expiresAt });
|
||||||
|
pruneExpired(now);
|
||||||
|
return { token, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a token. Returns true only if the token exists AND is not expired.
|
||||||
|
* Expired tokens are lazily removed.
|
||||||
|
*/
|
||||||
|
export function validateSseSessionToken(token: string | null | undefined): boolean {
|
||||||
|
if (!token) return false;
|
||||||
|
const s = sessions.get(token);
|
||||||
|
if (!s) return false;
|
||||||
|
if (Date.now() > s.expiresAt) {
|
||||||
|
sessions.delete(token);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the SSE session token from a Cookie header. */
|
||||||
|
export function extractSseCookie(req: Request): string | null {
|
||||||
|
const cookieHeader = req.headers.get('cookie');
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
for (const part of cookieHeader.split(';')) {
|
||||||
|
const [name, ...valueParts] = part.trim().split('=');
|
||||||
|
if (name === SSE_COOKIE_NAME) {
|
||||||
|
return valueParts.join('=') || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Set-Cookie header value for the SSE session cookie.
|
||||||
|
* - HttpOnly: not readable from JS (mitigates XSS token exfiltration)
|
||||||
|
* - SameSite=Strict: not sent on cross-site requests (mitigates CSRF)
|
||||||
|
* - Path=/: scope to the whole origin so SSE endpoints can read it
|
||||||
|
* - Max-Age matches the TTL
|
||||||
|
*
|
||||||
|
* Secure is intentionally omitted: the daemon binds to 127.0.0.1 over
|
||||||
|
* plain HTTP, and setting Secure would prevent the browser from ever
|
||||||
|
* sending the cookie back. If gstack ever ships over HTTPS, add Secure.
|
||||||
|
*/
|
||||||
|
export function buildSseSetCookie(token: string): string {
|
||||||
|
const maxAge = Math.floor(TTL_MS / 1000);
|
||||||
|
return `${SSE_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a Set-Cookie header that clears the SSE session cookie. */
|
||||||
|
export function buildSseClearCookie(): string {
|
||||||
|
return `${SSE_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExpired(now: number): void {
|
||||||
|
// Opportunistic cleanup: at most 10 per mint call so we don't stall
|
||||||
|
// on a massive registry. O(1) amortized.
|
||||||
|
let checked = 0;
|
||||||
|
for (const [token, session] of sessions) {
|
||||||
|
if (checked++ >= 10) break;
|
||||||
|
if (session.expiresAt <= now) sessions.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only reset.
|
||||||
|
export function __resetSseSessions(): void {
|
||||||
|
sessions.clear();
|
||||||
|
}
|
||||||
@@ -72,13 +72,16 @@ describe('Server auth security', () => {
|
|||||||
expect(historyBlock).not.toContain("'*'");
|
expect(historyBlock).not.toContain("'*'");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 6: /activity/stream requires auth (inline Bearer or ?token= check)
|
// Test 6: /activity/stream requires auth via Bearer OR view-only session cookie
|
||||||
|
// (N1: ?token= query param was dropped in v1.6.0.0 — URLs leak to logs/referer)
|
||||||
test('/activity/stream requires authentication with inline token check', () => {
|
test('/activity/stream requires authentication with inline token check', () => {
|
||||||
const streamBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/stream'", "url.pathname === '/activity/history'");
|
const streamBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/stream'", "url.pathname === '/activity/history'");
|
||||||
expect(streamBlock).toContain('validateAuth');
|
expect(streamBlock).toContain('validateAuth');
|
||||||
expect(streamBlock).toContain('AUTH_TOKEN');
|
expect(streamBlock).toContain('validateSseSessionToken');
|
||||||
// Should not have wildcard CORS for the SSE stream
|
// Should not have wildcard CORS for the SSE stream
|
||||||
expect(streamBlock).not.toContain("Access-Control-Allow-Origin': '*'");
|
expect(streamBlock).not.toContain("Access-Control-Allow-Origin': '*'");
|
||||||
|
// ?token= query param must NOT be accepted anymore
|
||||||
|
expect(streamBlock).not.toContain("searchParams.get('token')");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 7: /command accepts scoped tokens (not just root)
|
// Test 7: /command accepts scoped tokens (not just root)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the view-only SSE session cookie module.
|
||||||
|
*
|
||||||
|
* Verifies the registry lifecycle (mint/validate/expire), cookie flag
|
||||||
|
* invariants (HttpOnly, SameSite=Strict, no Secure), token entropy, and
|
||||||
|
* that scope is implicit (the registry has no cross-endpoint footprint
|
||||||
|
* that could be used to escalate the cookie to a scoped token).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
||||||
|
buildSseSetCookie, buildSseClearCookie, SSE_COOKIE_NAME,
|
||||||
|
__resetSseSessions,
|
||||||
|
} from '../src/sse-session-cookie';
|
||||||
|
|
||||||
|
const MODULE_SRC = fs.readFileSync(
|
||||||
|
path.join(import.meta.dir, '../src/sse-session-cookie.ts'), 'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => __resetSseSessions());
|
||||||
|
|
||||||
|
describe('SSE session cookie: mint + validate', () => {
|
||||||
|
test('mint returns a token and an expiry', () => {
|
||||||
|
const { token, expiresAt } = mintSseSessionToken();
|
||||||
|
expect(typeof token).toBe('string');
|
||||||
|
expect(token.length).toBeGreaterThan(20);
|
||||||
|
expect(expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mint uses 32 random bytes (256-bit entropy)', () => {
|
||||||
|
// base64url of 32 bytes is 43 chars (no padding)
|
||||||
|
const { token } = mintSseSessionToken();
|
||||||
|
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two mint calls produce different tokens', () => {
|
||||||
|
const a = mintSseSessionToken();
|
||||||
|
const b = mintSseSessionToken();
|
||||||
|
expect(a.token).not.toBe(b.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate returns true for a just-minted token', () => {
|
||||||
|
const { token } = mintSseSessionToken();
|
||||||
|
expect(validateSseSessionToken(token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate returns false for an unknown token', () => {
|
||||||
|
expect(validateSseSessionToken('not-a-real-token')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validate returns false for null/undefined/empty', () => {
|
||||||
|
expect(validateSseSessionToken(null)).toBe(false);
|
||||||
|
expect(validateSseSessionToken(undefined)).toBe(false);
|
||||||
|
expect(validateSseSessionToken('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SSE session cookie: TTL enforcement', () => {
|
||||||
|
test('TTL is 30 minutes', () => {
|
||||||
|
// Assert via source — the actual constant is module-private
|
||||||
|
expect(MODULE_SRC).toContain('const TTL_MS = 30 * 60 * 1000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a token with artificially rewound expiry is rejected', () => {
|
||||||
|
// Mint a token, then monkey-patch Date.now to simulate 31 minutes elapsed.
|
||||||
|
const { token, expiresAt } = mintSseSessionToken();
|
||||||
|
const originalNow = Date.now;
|
||||||
|
try {
|
||||||
|
Date.now = () => expiresAt + 1;
|
||||||
|
expect(validateSseSessionToken(token)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
Date.now = originalNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SSE session cookie: cookie flag invariants', () => {
|
||||||
|
test('Set-Cookie is HttpOnly', () => {
|
||||||
|
const { token } = mintSseSessionToken();
|
||||||
|
expect(buildSseSetCookie(token)).toContain('HttpOnly');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set-Cookie is SameSite=Strict', () => {
|
||||||
|
const { token } = mintSseSessionToken();
|
||||||
|
expect(buildSseSetCookie(token)).toContain('SameSite=Strict');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set-Cookie includes the token value', () => {
|
||||||
|
const { token } = mintSseSessionToken();
|
||||||
|
expect(buildSseSetCookie(token)).toContain(`${SSE_COOKIE_NAME}=${token}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set-Cookie Max-Age matches TTL', () => {
|
||||||
|
const { token } = mintSseSessionToken();
|
||||||
|
// 30 minutes = 1800 seconds
|
||||||
|
expect(buildSseSetCookie(token)).toContain('Max-Age=1800');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set-Cookie does NOT set Secure (local HTTP daemon)', () => {
|
||||||
|
const { token } = mintSseSessionToken();
|
||||||
|
// Adding Secure would block the browser from ever sending the cookie
|
||||||
|
// back to a 127.0.0.1 daemon over HTTP. If gstack ever moves to HTTPS,
|
||||||
|
// add Secure then.
|
||||||
|
expect(buildSseSetCookie(token)).not.toContain('Secure');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clear-Cookie has Max-Age=0', () => {
|
||||||
|
expect(buildSseClearCookie()).toContain('Max-Age=0');
|
||||||
|
expect(buildSseClearCookie()).toContain('HttpOnly');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SSE session cookie: extract from request', () => {
|
||||||
|
function mockReq(cookieHeader: string | null): Request {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (cookieHeader !== null) headers.set('cookie', cookieHeader);
|
||||||
|
return new Request('http://127.0.0.1/activity/stream', { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('extracts the token when cookie is present', () => {
|
||||||
|
const req = mockReq(`${SSE_COOKIE_NAME}=abc123`);
|
||||||
|
expect(extractSseCookie(req)).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when no cookie header', () => {
|
||||||
|
const req = mockReq(null);
|
||||||
|
expect(extractSseCookie(req)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when cookie header has no gstack_sse', () => {
|
||||||
|
const req = mockReq('other=x; unrelated=y');
|
||||||
|
expect(extractSseCookie(req)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts gstack_sse from a multi-cookie header', () => {
|
||||||
|
const req = mockReq(`other=x; ${SSE_COOKIE_NAME}=real-token; trailing=y`);
|
||||||
|
expect(extractSseCookie(req)).toBe('real-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles tokens with base64url padding-like chars', () => {
|
||||||
|
// real tokens contain A-Z, a-z, 0-9, _, -
|
||||||
|
const req = mockReq(`${SSE_COOKIE_NAME}=AbCd-_xyz`);
|
||||||
|
expect(extractSseCookie(req)).toBe('AbCd-_xyz');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SSE session cookie: scope isolation (prior learning cookie-picker-auth-isolation)', () => {
|
||||||
|
test('the module exposes ONLY view-only functions, no scoped-token hooks', () => {
|
||||||
|
// This is a contract guard: if someone later makes SSE session tokens
|
||||||
|
// valid as scoped tokens (e.g., by exporting a helper that registers
|
||||||
|
// them in the main token registry), a leaked cookie could execute
|
||||||
|
// /command. The module must not import from token-registry.
|
||||||
|
expect(MODULE_SRC).not.toContain("from './token-registry'");
|
||||||
|
expect(MODULE_SRC).not.toContain('createToken');
|
||||||
|
expect(MODULE_SRC).not.toContain('initRegistry');
|
||||||
|
});
|
||||||
|
});
|
||||||
+31
-8
@@ -1036,13 +1036,34 @@ function escapeHtml(str) {
|
|||||||
|
|
||||||
// ─── SSE Connection ─────────────────────────────────────────────
|
// ─── SSE Connection ─────────────────────────────────────────────
|
||||||
|
|
||||||
function connectSSE() {
|
// Fetch a view-only SSE session cookie before opening EventSource.
|
||||||
|
// EventSource can't send Authorization headers, and putting the root
|
||||||
|
// token in the URL (the old ?token= path) leaks it to logs, referer
|
||||||
|
// headers, and browser history. POST /sse-session issues an HttpOnly
|
||||||
|
// SameSite=Strict cookie scoped to SSE reads only; withCredentials:true
|
||||||
|
// on EventSource makes the browser send it back.
|
||||||
|
async function ensureSseSessionCookie() {
|
||||||
|
if (!serverUrl || !serverToken) return false;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${serverUrl}/sse-session`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Authorization': `Bearer ${serverToken}` },
|
||||||
|
});
|
||||||
|
return resp.ok;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[gstack sidebar] Failed to mint SSE session cookie:', err && err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSSE() {
|
||||||
if (!serverUrl) return;
|
if (!serverUrl) return;
|
||||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||||
|
|
||||||
const tokenParam = serverToken ? `&token=${serverToken}` : '';
|
await ensureSseSessionCookie();
|
||||||
const url = `${serverUrl}/activity/stream?after=${lastId}${tokenParam}`;
|
const url = `${serverUrl}/activity/stream?after=${lastId}`;
|
||||||
eventSource = new EventSource(url);
|
eventSource = new EventSource(url, { withCredentials: true });
|
||||||
|
|
||||||
eventSource.addEventListener('activity', (e) => {
|
eventSource.addEventListener('activity', (e) => {
|
||||||
try { addEntry(JSON.parse(e.data)); } catch (err) {
|
try { addEntry(JSON.parse(e.data)); } catch (err) {
|
||||||
@@ -1595,15 +1616,17 @@ document.querySelectorAll('.inspector-section-toggle').forEach(toggle => {
|
|||||||
|
|
||||||
// ─── Inspector SSE ──────────────────────────────────────────────
|
// ─── Inspector SSE ──────────────────────────────────────────────
|
||||||
|
|
||||||
function connectInspectorSSE() {
|
async function connectInspectorSSE() {
|
||||||
if (!serverUrl || !serverToken) return;
|
if (!serverUrl || !serverToken) return;
|
||||||
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
||||||
|
|
||||||
const tokenParam = serverToken ? `&token=${serverToken}` : '';
|
// Same session-cookie pattern as connectSSE. ?token= is gone (see N1
|
||||||
const url = `${serverUrl}/inspector/events?_=${Date.now()}${tokenParam}`;
|
// in the v1.6.0.0 security wave plan).
|
||||||
|
await ensureSseSessionCookie();
|
||||||
|
const url = `${serverUrl}/inspector/events?_=${Date.now()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
inspectorSSE = new EventSource(url);
|
inspectorSSE = new EventSource(url, { withCredentials: true });
|
||||||
|
|
||||||
inspectorSSE.addEventListener('inspectResult', (e) => {
|
inspectorSSE.addEventListener('inspectResult', (e) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user