mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-11 15:27:22 +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:
+31
-8
@@ -1036,13 +1036,34 @@ function escapeHtml(str) {
|
||||
|
||||
// ─── 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 (eventSource) { eventSource.close(); eventSource = null; }
|
||||
|
||||
const tokenParam = serverToken ? `&token=${serverToken}` : '';
|
||||
const url = `${serverUrl}/activity/stream?after=${lastId}${tokenParam}`;
|
||||
eventSource = new EventSource(url);
|
||||
await ensureSseSessionCookie();
|
||||
const url = `${serverUrl}/activity/stream?after=${lastId}`;
|
||||
eventSource = new EventSource(url, { withCredentials: true });
|
||||
|
||||
eventSource.addEventListener('activity', (e) => {
|
||||
try { addEntry(JSON.parse(e.data)); } catch (err) {
|
||||
@@ -1595,15 +1616,17 @@ document.querySelectorAll('.inspector-section-toggle').forEach(toggle => {
|
||||
|
||||
// ─── Inspector SSE ──────────────────────────────────────────────
|
||||
|
||||
function connectInspectorSSE() {
|
||||
async function connectInspectorSSE() {
|
||||
if (!serverUrl || !serverToken) return;
|
||||
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
||||
|
||||
const tokenParam = serverToken ? `&token=${serverToken}` : '';
|
||||
const url = `${serverUrl}/inspector/events?_=${Date.now()}${tokenParam}`;
|
||||
// Same session-cookie pattern as connectSSE. ?token= is gone (see N1
|
||||
// in the v1.6.0.0 security wave plan).
|
||||
await ensureSseSessionCookie();
|
||||
const url = `${serverUrl}/inspector/events?_=${Date.now()}`;
|
||||
|
||||
try {
|
||||
inspectorSSE = new EventSource(url);
|
||||
inspectorSSE = new EventSource(url, { withCredentials: true });
|
||||
|
||||
inspectorSSE.addEventListener('inspectResult', (e) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user