From cbd083e128d11de941df7028a9958544a74af1c9 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 25 May 2026 13:26:07 +0800 Subject: [PATCH 1/8] feat(auth): add HKDF + HMAC cookie verifier module (unused) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.1 of the auth migration. Pure-function additions to server/auth.js that are not yet wired into the request path — the existing authMiddleware remains in place verbatim and is still the only thing server/index.js calls. Phase 1.2 will replace that call site with the new verifiers. New exports: - deriveSecrets(rootKey): HKDF-SHA256 with empty salt and labels ccxray/v1/upstream, ccxray/v1/session-hmac, ccxray/v1/bootstrap returning three pairwise-distinct 32-byte keys. - getRootSecret(): sha256(AUTH_TOKEN) when set, otherwise reads or creates ~/.ccxray/local-secret (32 random bytes, mode 0600 in a mode-0700 parent dir). CCXRAY_HOME overrides the location for tests. - signCookie(payload, K_session) / verifyCookie(raw, K_session): stateless HMAC-signed cookie. Verifier order is base64url decode → HMAC compare (constant-time) → JSON.parse → version + exp check. Tampered HMAC, tampered payload, expired exp, wrong key, malformed base64url, malformed JSON, and unsupported version all return null. - compareSecret(a, b): hashes both inputs to fixed-width digests before crypto.timingSafeEqual to avoid the throw-on-length-mismatch path; final length check guards against hash-prefix collisions. TDD: 27 new assertions across test/auth-hkdf.test.js (HKDF determinism, label separation, ephemeral-mode file creation with correct modes, AUTH_TOKEN-vs-ephemeral switching) and test/auth-cookie.test.js (roundtrip, tamper detection, expiry, key mismatch, malformed inputs, constant-time compare). Tests run with CCXRAY_HOME pointing at a per-run temp dir so they never touch the user's real ~/.ccxray. Full suite: 533/533 (506 prior + 27 new). Authoritative design: reason/260525-0055-ccxray-auth-design/candidate-AB.md Implementation deviations: reason/260525-0055-ccxray-auth-design/errata.md Co-Authored-By: Claude Opus 4.7 --- server/auth.js | 156 ++++++++++++++++++++++++++++++++++++--- test/auth-cookie.test.js | 119 +++++++++++++++++++++++++++++ test/auth-hkdf.test.js | 124 +++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+), 11 deletions(-) create mode 100644 test/auth-cookie.test.js create mode 100644 test/auth-hkdf.test.js diff --git a/server/auth.js b/server/auth.js index a93c77e..5ce316d 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,28 +1,152 @@ 'use strict'; /** - * Simple API key authentication middleware for cloud deployments. + * Auth primitives for the two-domain auth migration. * - * Enable by setting AUTH_TOKEN environment variable. - * When set, all requests must include either: - * - Header: Authorization: Bearer - * - Query param: ?token= + * Phase 1.1: pure crypto + root secret resolution. Module is exported but + * not yet wired into the request path (that lands in Phase 1.2). The + * existing authMiddleware below is preserved unchanged so current behavior + * is byte-identical until Phase 1.2 swaps the call site over. * - * Dashboard and SSE endpoints are also protected. - * The proxy endpoint (forwarding to Anthropic) uses the client's own API key - * for Anthropic auth, but still requires AUTH_TOKEN for access control. + * Authoritative design: reason/260525-0055-ccxray-auth-design/candidate-AB.md + * Implementation deviations: reason/260525-0055-ccxray-auth-design/errata.md */ +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// ─── Root secret resolution ────────────────────────────────────────── + +function getHubDir() { + return process.env.CCXRAY_HOME || path.join(os.homedir(), '.ccxray'); +} + +function ensureHubDir() { + const dir = getHubDir(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + // mkdirSync ignores mode on existing dirs; tighten explicitly. + try { fs.chmodSync(dir, 0o700); } catch {} + return dir; +} + +function readOrCreateEphemeralSecret() { + const dir = ensureHubDir(); + const secretPath = path.join(dir, 'local-secret'); + try { + const existing = fs.readFileSync(secretPath); + if (existing.length === 32) return existing; + // Wrong length — treat as corrupt and regenerate. + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + const fresh = crypto.randomBytes(32); + fs.writeFileSync(secretPath, fresh, { mode: 0o600 }); + // writeFileSync respects mode only on create; tighten explicitly in case + // the file already existed with looser perms. + try { fs.chmodSync(secretPath, 0o600); } catch {} + return fresh; +} + +function getRootSecret() { + const token = process.env.AUTH_TOKEN; + if (token) { + return crypto.createHash('sha256').update(token, 'utf8').digest(); + } + return readOrCreateEphemeralSecret(); +} + +// ─── HKDF label-separated derivation ───────────────────────────────── + +const LABELS = Object.freeze({ + K_upstream: 'ccxray/v1/upstream', + K_session: 'ccxray/v1/session-hmac', + K_bootstrap: 'ccxray/v1/bootstrap', +}); + +function hkdf(rootKey, label, len = 32) { + return Buffer.from(crypto.hkdfSync('sha256', rootKey, Buffer.alloc(0), Buffer.from(label, 'utf8'), len)); +} + +function deriveSecrets(rootKey) { + return { + K_upstream: hkdf(rootKey, LABELS.K_upstream), + K_session: hkdf(rootKey, LABELS.K_session), + K_bootstrap: hkdf(rootKey, LABELS.K_bootstrap), + }; +} + +// ─── Stateless HMAC session cookie ─────────────────────────────────── + +const COOKIE_VERSION = 1; + +function signCookie(payload, K_session) { + const json = JSON.stringify(payload); + const payloadBuf = Buffer.from(json, 'utf8'); + const hmac = crypto.createHmac('sha256', K_session).update(payloadBuf).digest(); + return `${payloadBuf.toString('base64url')}.${hmac.toString('base64url')}`; +} + +function verifyCookie(raw, K_session) { + if (typeof raw !== 'string' || raw.length === 0) return null; + const dot = raw.indexOf('.'); + if (dot <= 0 || dot === raw.length - 1) return null; + + const payloadB64 = raw.slice(0, dot); + const hmacB64 = raw.slice(dot + 1); + + let payloadBuf, providedHmac; + try { + payloadBuf = Buffer.from(payloadB64, 'base64url'); + providedHmac = Buffer.from(hmacB64, 'base64url'); + } catch { + return null; + } + // base64url decode is lenient — reject anything that round-trips to a + // different string (catches the '!!!.!!!' garbage-in case). + if (payloadBuf.toString('base64url') !== payloadB64) return null; + if (providedHmac.toString('base64url') !== hmacB64) return null; + if (providedHmac.length !== 32) return null; + + const expected = crypto.createHmac('sha256', K_session).update(payloadBuf).digest(); + if (!crypto.timingSafeEqual(providedHmac, expected)) return null; + + let payload; + try { + payload = JSON.parse(payloadBuf.toString('utf8')); + } catch { + return null; + } + if (!payload || typeof payload !== 'object') return null; + if (payload.v !== COOKIE_VERSION) return null; + if (typeof payload.exp !== 'number') return null; + if (payload.exp < Math.floor(Date.now() / 1000)) return null; + + return payload; +} + +// ─── Constant-time string compare ──────────────────────────────────── + +function compareSecret(provided, expected) { + if (typeof provided !== 'string' || typeof expected !== 'string') return false; + // Hash both sides to a fixed-width buffer so timingSafeEqual never throws + // on length mismatch and the comparison work is independent of input length. + const ph = crypto.createHash('sha256').update(provided, 'utf8').digest(); + const eh = crypto.createHash('sha256').update(expected, 'utf8').digest(); + return crypto.timingSafeEqual(ph, eh) && provided.length === expected.length; +} + +// ─── Legacy middleware (preserved verbatim until Phase 1.2) ────────── + const AUTH_TOKEN = process.env.AUTH_TOKEN || null; function authMiddleware(req, res) { if (!AUTH_TOKEN) return true; // no auth configured — allow all - // Check Authorization header const authHeader = req.headers['authorization'] || ''; if (authHeader === `Bearer ${AUTH_TOKEN}`) return true; - // Check query param const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); if (url.searchParams.get('token') === AUTH_TOKEN) return true; @@ -31,4 +155,14 @@ function authMiddleware(req, res) { return false; } -module.exports = { authMiddleware, AUTH_TOKEN }; +module.exports = { + // Phase 1.1 additions + deriveSecrets, + getRootSecret, + signCookie, + verifyCookie, + compareSecret, + // Legacy exports — unchanged until Phase 1.2 swaps callers over + authMiddleware, + AUTH_TOKEN, +}; diff --git a/test/auth-cookie.test.js b/test/auth-cookie.test.js new file mode 100644 index 0000000..0bd5090 --- /dev/null +++ b/test/auth-cookie.test.js @@ -0,0 +1,119 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('crypto'); + +const auth = require('../server/auth'); + +function freshKey() { + return crypto.randomBytes(32); +} + +function nowSec() { + return Math.floor(Date.now() / 1000); +} + +describe('signCookie + verifyCookie roundtrip', () => { + it('signs and verifies a well-formed payload', () => { + const key = freshKey(); + const payload = { v: 1, n: 'abc123', exp: nowSec() + 3600 }; + const raw = auth.signCookie(payload, key); + assert.equal(typeof raw, 'string'); + assert.ok(raw.includes('.'), 'expected payload.hmac format'); + const got = auth.verifyCookie(raw, key); + assert.deepEqual(got, payload); + }); + + it('survives non-ASCII fields in the payload', () => { + const key = freshKey(); + const payload = { v: 1, n: '隨機', exp: nowSec() + 60 }; + const raw = auth.signCookie(payload, key); + assert.deepEqual(auth.verifyCookie(raw, key), payload); + }); +}); + +describe('verifyCookie — rejection cases', () => { + it('returns null when the HMAC is tampered', () => { + const key = freshKey(); + const raw = auth.signCookie({ v: 1, n: 'a', exp: nowSec() + 60 }, key); + const tampered = raw.slice(0, -1) + (raw.slice(-1) === 'A' ? 'B' : 'A'); + assert.equal(auth.verifyCookie(tampered, key), null); + }); + + it('returns null when the payload is tampered', () => { + const key = freshKey(); + const raw = auth.signCookie({ v: 1, n: 'a', exp: nowSec() + 60 }, key); + const dot = raw.indexOf('.'); + const payloadB64 = raw.slice(0, dot); + const hmacB64 = raw.slice(dot + 1); + // Flip the first character of the base64url payload section + const flipped = (payloadB64[0] === 'A' ? 'B' : 'A') + payloadB64.slice(1); + assert.equal(auth.verifyCookie(`${flipped}.${hmacB64}`, key), null); + }); + + it('returns null when expired', () => { + const key = freshKey(); + const raw = auth.signCookie({ v: 1, n: 'a', exp: nowSec() - 10 }, key); + assert.equal(auth.verifyCookie(raw, key), null); + }); + + it('returns null for the wrong key', () => { + const k1 = freshKey(); + const k2 = freshKey(); + const raw = auth.signCookie({ v: 1, n: 'a', exp: nowSec() + 60 }, k1); + assert.equal(auth.verifyCookie(raw, k2), null); + }); + + it('returns null for a malformed cookie (no dot)', () => { + assert.equal(auth.verifyCookie('not-a-cookie', freshKey()), null); + }); + + it('returns null for an empty string', () => { + assert.equal(auth.verifyCookie('', freshKey()), null); + }); + + it('returns null for invalid base64url in payload', () => { + assert.equal(auth.verifyCookie('!!!.!!!', freshKey()), null); + }); + + it('returns null when payload JSON is malformed', () => { + const key = freshKey(); + const badPayload = Buffer.from('not json', 'utf8').toString('base64url'); + const hmac = crypto.createHmac('sha256', key) + .update(Buffer.from('not json', 'utf8')).digest().toString('base64url'); + assert.equal(auth.verifyCookie(`${badPayload}.${hmac}`, key), null); + }); + + it('returns null when payload version is unsupported', () => { + const key = freshKey(); + const raw = auth.signCookie({ v: 999, n: 'a', exp: nowSec() + 60 }, key); + assert.equal(auth.verifyCookie(raw, key), null); + }); +}); + +describe('compareSecret — constant-time correctness', () => { + it('returns true for identical strings', () => { + assert.equal(auth.compareSecret('hunter2', 'hunter2'), true); + }); + + it('returns false for different strings of the same length', () => { + assert.equal(auth.compareSecret('hunter2', 'hunter3'), false); + }); + + it('returns false for different lengths', () => { + assert.equal(auth.compareSecret('hunter2', 'hunter2-longer'), false); + }); + + it('handles empty inputs without throwing', () => { + assert.equal(auth.compareSecret('', ''), true); + assert.equal(auth.compareSecret('', 'x'), false); + assert.equal(auth.compareSecret('x', ''), false); + }); + + it('handles null/undefined inputs without throwing', () => { + assert.equal(auth.compareSecret(null, 'x'), false); + assert.equal(auth.compareSecret('x', null), false); + assert.equal(auth.compareSecret(undefined, undefined), false); + }); +}); diff --git a/test/auth-hkdf.test.js b/test/auth-hkdf.test.js new file mode 100644 index 0000000..8381a40 --- /dev/null +++ b/test/auth-hkdf.test.js @@ -0,0 +1,124 @@ +'use strict'; + +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); + +// Use a per-run temp CCXRAY_HOME so we never touch the user's real ~/.ccxray +// and tests can manipulate the local-secret file freely. +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-hkdf-test-')); +process.env.CCXRAY_HOME = TEST_HOME; + +// Lazy-require so we pick up the env override above. +const auth = require('../server/auth'); + +function clearTestHome() { + for (const name of fs.readdirSync(TEST_HOME)) { + fs.rmSync(path.join(TEST_HOME, name), { recursive: true, force: true }); + } +} + +after(() => { + fs.rmSync(TEST_HOME, { recursive: true, force: true }); +}); + +describe('deriveSecrets(rootKey) — HKDF label separation', () => { + it('returns three Buffer keys with the expected labels', () => { + const root = crypto.randomBytes(32); + const out = auth.deriveSecrets(root); + assert.ok(Buffer.isBuffer(out.K_upstream), 'K_upstream is a Buffer'); + assert.ok(Buffer.isBuffer(out.K_session), 'K_session is a Buffer'); + assert.ok(Buffer.isBuffer(out.K_bootstrap), 'K_bootstrap is a Buffer'); + assert.equal(out.K_upstream.length, 32); + assert.equal(out.K_session.length, 32); + assert.equal(out.K_bootstrap.length, 32); + }); + + it('is deterministic — same root produces identical secrets', () => { + const root = Buffer.from('a'.repeat(32)); + const a = auth.deriveSecrets(root); + const b = auth.deriveSecrets(root); + assert.deepEqual(a.K_upstream, b.K_upstream); + assert.deepEqual(a.K_session, b.K_session); + assert.deepEqual(a.K_bootstrap, b.K_bootstrap); + }); + + it('produces three pairwise-distinct keys (label separation)', () => { + const root = crypto.randomBytes(32); + const { K_upstream, K_session, K_bootstrap } = auth.deriveSecrets(root); + assert.notDeepEqual(K_upstream, K_session); + assert.notDeepEqual(K_session, K_bootstrap); + assert.notDeepEqual(K_upstream, K_bootstrap); + }); + + it('different roots produce different secrets', () => { + const a = auth.deriveSecrets(Buffer.alloc(32, 1)); + const b = auth.deriveSecrets(Buffer.alloc(32, 2)); + assert.notDeepEqual(a.K_upstream, b.K_upstream); + assert.notDeepEqual(a.K_session, b.K_session); + assert.notDeepEqual(a.K_bootstrap, b.K_bootstrap); + }); +}); + +describe('getRootSecret() — AUTH_TOKEN vs ephemeral mode', () => { + beforeEach(() => { + delete process.env.AUTH_TOKEN; + clearTestHome(); + }); + + it('with AUTH_TOKEN set: returns sha256(AUTH_TOKEN)', () => { + process.env.AUTH_TOKEN = 'hunter2'; + const got = auth.getRootSecret(); + const expected = crypto.createHash('sha256').update('hunter2', 'utf8').digest(); + assert.deepEqual(got, expected); + }); + + it('with AUTH_TOKEN set: ignores local-secret on disk', () => { + fs.mkdirSync(TEST_HOME, { recursive: true }); + fs.writeFileSync(path.join(TEST_HOME, 'local-secret'), Buffer.alloc(32, 9)); + process.env.AUTH_TOKEN = 'env-wins'; + const got = auth.getRootSecret(); + const expected = crypto.createHash('sha256').update('env-wins', 'utf8').digest(); + assert.deepEqual(got, expected); + }); + + it('with AUTH_TOKEN unset: creates ~/.ccxray/local-secret on first call', () => { + const secretPath = path.join(TEST_HOME, 'local-secret'); + assert.equal(fs.existsSync(secretPath), false, 'precondition: no file'); + const got = auth.getRootSecret(); + assert.equal(got.length, 32); + assert.equal(fs.existsSync(secretPath), true, 'file created'); + const onDisk = fs.readFileSync(secretPath); + assert.deepEqual(got, onDisk, 'returned key matches on-disk bytes'); + }); + + it('with AUTH_TOKEN unset: subsequent calls reuse the same secret', () => { + const first = auth.getRootSecret(); + const second = auth.getRootSecret(); + assert.deepEqual(first, second); + }); + + it('with AUTH_TOKEN unset: local-secret file is mode 0600', () => { + auth.getRootSecret(); + const stat = fs.statSync(path.join(TEST_HOME, 'local-secret')); + const mode = stat.mode & 0o777; + assert.equal(mode, 0o600, `expected mode 0600, got 0${mode.toString(8)}`); + }); + + it('with AUTH_TOKEN unset: parent dir is mode 0700', () => { + auth.getRootSecret(); + const stat = fs.statSync(TEST_HOME); + const mode = stat.mode & 0o777; + assert.equal(mode, 0o700, `expected mode 0700, got 0${mode.toString(8)}`); + }); + + it('switching from ephemeral to AUTH_TOKEN changes the derived secrets', () => { + const ephemeral = auth.getRootSecret(); + process.env.AUTH_TOKEN = 'now-with-token'; + const withToken = auth.getRootSecret(); + assert.notDeepEqual(ephemeral, withToken); + }); +}); From 6d4b4ebc4aaf4bac4dc49d3b82bc22dbd75863ae Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 25 May 2026 16:09:56 +0800 Subject: [PATCH 2/8] feat(auth): wire two-domain dispatcher (warn-only, behavior-preserving) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.2. Replaces the single authMiddleware() call in server/index.js with dispatch(req).verify(req, res). Behavior is byte-identical to the prior authMiddleware for every legacy credential combination — the new verifiers internally delegate to authMiddleware for the success/failure decision. The only added behavior is a response header on requests that used credential forms slated for removal in Phase 2. New in server/auth.js: - dispatch(req): pure path classifier. Returns { domain, verify } where domain is 'upstream' for any /v1/* path and 'dashboard' for everything else (including / and /_events). Splits the query string before comparing so /_api/entries?token= still classifies as dashboard. - verifyDashboard(req, res): delegates to authMiddleware. On success, attaches X-Ccxray-Deprecation: token-query when ?token= was used. Bearer remains the permanent CLI form on the dashboard (errata §1.1) and is NOT deprecated. - verifyUpstream(req, res): delegates to authMiddleware. On success, attaches X-Ccxray-Deprecation: bearer-on-upstream for Bearer auth on /v1/* and X-Ccxray-Deprecation: token-query for ?token=. - whichLegacyMechanism(req): re-derives which credential form succeeded so the header reflects what the caller actually sent. The deprecation header is set via setHeader (not writeHead), so the downstream handler's writeHead call cannot accidentally drop it and, critically, the deprecation code cannot influence status code or body — making this commit incapable of breaking a request that authMiddleware would have allowed. authMiddleware itself is unchanged. server/index.js: one-line swap from authMiddleware to dispatch().verify. Tests: - 23 new assertions in test/auth-dispatcher.test.js covering path classification, byte-identical behavior to authMiddleware, and deprecation-header presence by (domain, mechanism) combo. - The existing 5 cases in test/auth.test.js stay green untouched. - Full suite: 556/556 (was 533 pre-commit). Smoke test on port 5589 (separate from the user's running hub): - no auth → 401 - correct Bearer on /_api/entries → 200, no deprecation header - wrong Bearer → 401 - correct ?token= on /_api/entries → 200 + token-query header - correct Bearer on /v1/ping → forwarded (404 from Anthropic) + bearer-on-upstream header Authoritative design: reason/260525-0055-ccxray-auth-design/candidate-AB.md §2.1, §5.2 Implementation deviations: reason/260525-0055-ccxray-auth-design/errata.md Co-Authored-By: Claude Opus 4.7 --- server/auth.js | 93 +++++++++++++++++- server/index.js | 6 +- test/auth-dispatcher.test.js | 183 +++++++++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 test/auth-dispatcher.test.js diff --git a/server/auth.js b/server/auth.js index 5ce316d..011c2dc 100644 --- a/server/auth.js +++ b/server/auth.js @@ -137,7 +137,90 @@ function compareSecret(provided, expected) { return crypto.timingSafeEqual(ph, eh) && provided.length === expected.length; } -// ─── Legacy middleware (preserved verbatim until Phase 1.2) ────────── +// ─── Two-domain dispatcher (Phase 1.2: warn-only) ──────────────────── +// +// dispatch(req) classifies a request by path into upstream or dashboard +// and returns the matching verifier. The verifiers are byte-identical +// to authMiddleware on success/failure decisions — they internally +// delegate to it. The only new behavior is X-Ccxray-Deprecation +// response headers on requests that used credential forms slated for +// removal in Phase 2: +// - dashboard: ?token= → deprecation (Bearer stays permanent) +// - upstream: Bearer or ?token= → deprecation +// +// The headers are set via setHeader so they survive the downstream +// handler's writeHead call; setHeader can never affect status code +// or body, so this code is incapable of breaking a request that +// authMiddleware would have allowed. + +const UPSTREAM_PREFIXES = ['/v1/']; + +function getPathname(url) { + const q = url.indexOf('?'); + return q === -1 ? url : url.slice(0, q); +} + +function classifyDomain(req) { + const pathname = getPathname(req.url || ''); + for (const prefix of UPSTREAM_PREFIXES) { + if (pathname === prefix.slice(0, -1) || pathname.startsWith(prefix)) { + return 'upstream'; + } + } + return 'dashboard'; +} + +function whichLegacyMechanism(req) { + // Re-derive the same checks authMiddleware did, so we know which + // legacy form succeeded. Returns 'bearer' | 'token-query' | null. + const token = process.env.AUTH_TOKEN; + if (!token) return null; + const authHeader = req.headers['authorization'] || ''; + if (authHeader === `Bearer ${token}`) return 'bearer'; + try { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + if (url.searchParams.get('token') === token) return 'token-query'; + } catch {} + return null; +} + +function setDeprecation(res, value) { + if (typeof res.setHeader === 'function') { + res.setHeader('X-Ccxray-Deprecation', value); + } +} + +function verifyDashboard(req, res) { + const ok = authMiddleware(req, res); + if (!ok) return false; + // Bearer is permanent on the dashboard domain (errata §1.1). Only + // ?token= is flagged for removal here. + if (whichLegacyMechanism(req) === 'token-query') { + setDeprecation(res, 'token-query'); + } + return true; +} + +function verifyUpstream(req, res) { + const ok = authMiddleware(req, res); + if (!ok) return false; + const mech = whichLegacyMechanism(req); + if (mech === 'bearer') setDeprecation(res, 'bearer-on-upstream'); + else if (mech === 'token-query') setDeprecation(res, 'token-query'); + return true; +} + +function dispatch(req) { + const domain = classifyDomain(req); + return { + domain, + verify: domain === 'upstream' ? verifyUpstream : verifyDashboard, + }; +} + +// ─── Legacy middleware (call site swapped in Phase 1.2; kept exported +// so test/auth.test.js stays green and downstream code can still +// import it through the deprecation window) ─────────────────────── const AUTH_TOKEN = process.env.AUTH_TOKEN || null; @@ -162,7 +245,13 @@ module.exports = { signCookie, verifyCookie, compareSecret, - // Legacy exports — unchanged until Phase 1.2 swaps callers over + // Phase 1.2 additions + dispatch, + verifyDashboard, + verifyUpstream, + // Legacy exports — call site swapped to dispatch() in Phase 1.2, + // but authMiddleware stays exported so test/auth.test.js and any + // downstream importer continue to work through the deprecation window. authMiddleware, AUTH_TOKEN, }; diff --git a/server/index.js b/server/index.js index 6313059..9ada1b2 100755 --- a/server/index.js +++ b/server/index.js @@ -14,7 +14,7 @@ const { warmUp: warmUpCosts } = require('./cost-budget'); const { forwardRequest, setStatusLineEnabled, getStatusLineEnabled } = require('./forward'); const { readSettings } = require('./settings'); const { broadcastSessionStatus, broadcastPendingRequest } = require('./sse-broadcast'); -const { authMiddleware } = require('./auth'); +const { dispatch } = require('./auth'); const { extractAgentType, extractPromptAgentType, splitB2IntoBlocks } = require('./system-prompt'); const { findSharedPrefix } = require('./delta-helpers'); const providers = require('./providers'); @@ -188,8 +188,8 @@ const server = http.createServer((clientReq, clientRes) => { // Placed before auth: these are local IPC endpoints, not user-facing if (hub.handleHubRoutes(clientReq, clientRes)) return; - // ── Auth check (enabled via AUTH_TOKEN env var) ── - if (!authMiddleware(clientReq, clientRes)) return; + // ── Auth check (Phase 1.2 dispatcher; legacy-compatible) ── + if (!dispatch(clientReq).verify(clientReq, clientRes)) return; // ── Static files (HTML, CSS, JS) ── if (serveStatic(clientReq.url, clientRes)) return; diff --git a/test/auth-dispatcher.test.js b/test/auth-dispatcher.test.js new file mode 100644 index 0000000..6edee69 --- /dev/null +++ b/test/auth-dispatcher.test.js @@ -0,0 +1,183 @@ +'use strict'; + +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); + +// Match the existing test/auth.test.js style: re-require server/auth after +// each AUTH_TOKEN flip so the module's AUTH_TOKEN constant refreshes. +function loadAuthWith(token) { + if (token === null) delete process.env.AUTH_TOKEN; + else process.env.AUTH_TOKEN = token; + delete require.cache[require.resolve('../server/auth')]; + return require('../server/auth'); +} + +function mockReqRes(headers = {}, url = '/') { + const setHeaderCalls = {}; + const req = { headers, url }; + const res = { + statusCode: null, + body: null, + writeHeadCalled: false, + writeHeadHeaders: null, + setHeader(name, value) { setHeaderCalls[name] = value; }, + getHeader(name) { return setHeaderCalls[name]; }, + writeHead(code, h) { this.writeHeadCalled = true; this.statusCode = code; this.writeHeadHeaders = h || {}; }, + end(body) { this.body = body; }, + }; + return { req, res, setHeaderCalls }; +} + +let originalToken; +before(() => { originalToken = process.env.AUTH_TOKEN; }); +after(() => { + if (originalToken !== undefined) process.env.AUTH_TOKEN = originalToken; + else delete process.env.AUTH_TOKEN; + delete require.cache[require.resolve('../server/auth')]; +}); + +describe('dispatch(req) — path classification', () => { + const auth = loadAuthWith(null); + + it('classifies /v1/messages as upstream', () => { + assert.equal(auth.dispatch({ url: '/v1/messages', headers: {} }).domain, 'upstream'); + }); + + it('classifies /v1/responses (codex WS upgrade) as upstream', () => { + assert.equal(auth.dispatch({ url: '/v1/responses', headers: {} }).domain, 'upstream'); + }); + + it('classifies /v1/anything-else as upstream', () => { + assert.equal(auth.dispatch({ url: '/v1/foo/bar', headers: {} }).domain, 'upstream'); + }); + + it('classifies query strings on /v1/* as upstream (pathname split)', () => { + assert.equal(auth.dispatch({ url: '/v1/messages?x=1', headers: {} }).domain, 'upstream'); + }); + + it('classifies / as dashboard', () => { + assert.equal(auth.dispatch({ url: '/', headers: {} }).domain, 'dashboard'); + }); + + it('classifies /_api/entries as dashboard', () => { + assert.equal(auth.dispatch({ url: '/_api/entries', headers: {} }).domain, 'dashboard'); + }); + + it('classifies /_api/entries?token=x as dashboard (pathname split)', () => { + assert.equal(auth.dispatch({ url: '/_api/entries?token=x', headers: {} }).domain, 'dashboard'); + }); + + it('classifies /_events as dashboard', () => { + assert.equal(auth.dispatch({ url: '/_events', headers: {} }).domain, 'dashboard'); + }); + + it('classifies /style.css as dashboard', () => { + assert.equal(auth.dispatch({ url: '/style.css', headers: {} }).domain, 'dashboard'); + }); + + it('exposes a verify function for each domain', () => { + assert.equal(typeof auth.dispatch({ url: '/v1/m', headers: {} }).verify, 'function'); + assert.equal(typeof auth.dispatch({ url: '/_api/x', headers: {} }).verify, 'function'); + }); +}); + +describe('verifyDashboard — Phase 1.2 byte-identical to authMiddleware', () => { + beforeEach(() => {}); + + it('no AUTH_TOKEN: returns true without touching res', () => { + const auth = loadAuthWith(null); + const { req, res } = mockReqRes({}, '/_api/entries'); + assert.equal(auth.verifyDashboard(req, res), true); + assert.equal(res.writeHeadCalled, false); + }); + + it('AUTH_TOKEN set, no credentials: returns false and writes 401', () => { + const auth = loadAuthWith('sec1'); + const { req, res } = mockReqRes({}, '/_api/entries'); + assert.equal(auth.verifyDashboard(req, res), false); + assert.equal(res.statusCode, 401); + }); + + it('AUTH_TOKEN set, correct Bearer: returns true', () => { + const auth = loadAuthWith('sec1'); + const { req, res } = mockReqRes({ authorization: 'Bearer sec1', host: 'localhost' }, '/_api/entries'); + assert.equal(auth.verifyDashboard(req, res), true); + assert.equal(res.writeHeadCalled, false); + }); + + it('AUTH_TOKEN set, wrong Bearer: returns false and writes 401', () => { + const auth = loadAuthWith('sec1'); + const { req, res } = mockReqRes({ authorization: 'Bearer wrong', host: 'localhost' }, '/_api/entries'); + assert.equal(auth.verifyDashboard(req, res), false); + assert.equal(res.statusCode, 401); + }); + + it('AUTH_TOKEN set, correct ?token= query: returns true', () => { + const auth = loadAuthWith('sec1'); + const { req, res } = mockReqRes({ host: 'localhost' }, '/_api/entries?token=sec1'); + assert.equal(auth.verifyDashboard(req, res), true); + assert.equal(res.writeHeadCalled, false); + }); + + it('AUTH_TOKEN set, correct ?token= query: adds X-Ccxray-Deprecation header', () => { + const auth = loadAuthWith('sec1'); + const { req, res, setHeaderCalls } = mockReqRes({ host: 'localhost' }, '/_api/entries?token=sec1'); + auth.verifyDashboard(req, res); + assert.match(setHeaderCalls['X-Ccxray-Deprecation'] || '', /token-query/); + }); + + it('AUTH_TOKEN set, correct Bearer: does NOT add deprecation header (Bearer is permanent on dashboard)', () => { + const auth = loadAuthWith('sec1'); + const { req, res, setHeaderCalls } = mockReqRes({ authorization: 'Bearer sec1', host: 'localhost' }, '/_api/entries'); + auth.verifyDashboard(req, res); + assert.equal(setHeaderCalls['X-Ccxray-Deprecation'], undefined); + }); +}); + +describe('verifyUpstream — Phase 1.2 byte-identical to authMiddleware (with deprecation hint)', () => { + it('no AUTH_TOKEN: returns true without touching res', () => { + const auth = loadAuthWith(null); + const { req, res } = mockReqRes({}, '/v1/messages'); + assert.equal(auth.verifyUpstream(req, res), true); + assert.equal(res.writeHeadCalled, false); + }); + + it('AUTH_TOKEN set, no credentials: returns false and writes 401', () => { + const auth = loadAuthWith('sec1'); + const { req, res } = mockReqRes({}, '/v1/messages'); + assert.equal(auth.verifyUpstream(req, res), false); + assert.equal(res.statusCode, 401); + }); + + it('AUTH_TOKEN set, Bearer accepted on /v1/* (warn-only Phase 1): returns true + deprecation header', () => { + const auth = loadAuthWith('sec1'); + const { req, res, setHeaderCalls } = mockReqRes({ authorization: 'Bearer sec1', host: 'localhost' }, '/v1/messages'); + assert.equal(auth.verifyUpstream(req, res), true); + assert.match(setHeaderCalls['X-Ccxray-Deprecation'] || '', /bearer-on-upstream/); + }); + + it('AUTH_TOKEN set, ?token= accepted on /v1/* (warn-only Phase 1): returns true + deprecation header', () => { + const auth = loadAuthWith('sec1'); + const { req, res, setHeaderCalls } = mockReqRes({ host: 'localhost' }, '/v1/messages?token=sec1'); + assert.equal(auth.verifyUpstream(req, res), true); + assert.match(setHeaderCalls['X-Ccxray-Deprecation'] || '', /token-query/); + }); +}); + +describe('dispatch().verify — sanity: same instance routes to correct verifier', () => { + it('upstream path routes to verifyUpstream', () => { + const auth = loadAuthWith('sec1'); + const { req, res, setHeaderCalls } = mockReqRes({ authorization: 'Bearer sec1' }, '/v1/messages'); + const { verify } = auth.dispatch(req); + assert.equal(verify(req, res), true); + assert.match(setHeaderCalls['X-Ccxray-Deprecation'] || '', /bearer-on-upstream/); + }); + + it('dashboard path routes to verifyDashboard', () => { + const auth = loadAuthWith('sec1'); + const { req, res, setHeaderCalls } = mockReqRes({ authorization: 'Bearer sec1' }, '/_api/entries'); + const { verify } = auth.dispatch(req); + assert.equal(verify(req, res), true); + assert.equal(setHeaderCalls['X-Ccxray-Deprecation'], undefined); + }); +}); From 7b94469378fe42af6cab7319fc72c29518a792e9 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Mon, 25 May 2026 23:23:57 +0800 Subject: [PATCH 3/8] feat(auth): add /_auth/redeem and ccxray open bootstrap flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.3 of the auth migration. Introduces the user-visible bootstrap path: `ccxray open` mints a one-time URL like http://localhost:5577/#k=… that the browser redeems via POST /_auth/redeem to receive an HttpOnly session cookie. Cookie is HMAC-signed with K_session derived in 1.1. Per errata §1.1: the inline browser script CANNOT probe an HttpOnly cookie via document.cookie, so it uses GET /_auth/status as a server- side probe instead. Endpoints (server/routes/auth.js): - POST /_auth/redeem — consumes X-Ccxray-Bootstrap token, mints cookie. Single-use, 60s TTL on tokens. Gated by Sec-Fetch-Site: same-origin with Origin/Host fallback for older browsers and curl tests. - GET /_auth/status — returns 200 if the request would authenticate via cookie / Bearer / ?token= / ephemeral mode, 401 otherwise. Used by the inline browser bootstrap to decide whether to show a banner. Both endpoints run BEFORE dispatch().verify so they're reachable without prior authentication. server/hub.js: POST /_api/hub/bootstrap-token mints a token via auth.mintBootstrapToken(). Restricted to loopback peers (real peer-UID gate comes in Phase 2.3 with the Unix socket migration). server/auth.js: - mintBootstrapToken(): module-level Map, capped at 8 entries, 60s TTL, HMAC-K_bootstrap stored (never the raw token). - redeemBootstrap(req, res): verifies CSRF gate + token presence + replay protection, mints cookie with HttpOnly; SameSite=Strict; Path=/; Max-Age=86400 (24h per errata "最小開發" decision). - authStatus(req, res): light verify, 200/401 only. - verifyDashboard: now also accepts a valid cookie. Falls through to authMiddleware for legacy Bearer/?token= when cookie is absent or invalid — byte-identical Phase 1.2 behavior preserved on the non-cookie path. - Cached HKDF secrets via getSecrets() so we don't re-derive per request. server/index.js: - New `ccxray open` subcommand: looks up the hub via hub.readHubLock(), POSTs to /_api/hub/bootstrap-token, prints the one-time URL, and attempts to open it in the system browser (unless BROWSER=none/CI/ SSH_TTY). - /_auth/* routes mounted before the auth dispatcher. public/index.html: 30-line inline bootstrap script in , ahead of all other scripts: - If location.hash matches #k=…, scrub via history.replaceState, POST to /_auth/redeem, reload on 204. - Else fetch /_auth/status; on 401, surface a small red banner pointing to `ccxray open`. - With no AUTH_TOKEN set (default), /_auth/status returns 200 → script is a no-op. Zero user-visible change for users not in AUTH_TOKEN mode. Tests: - 16 new assertions in test/auth-bootstrap.test.js: mint+redeem happy path, replay rejected, missing token → 401, foreign Origin → 403, CSRF-gate fallback (Sec-Fetch absent + Origin match → allow), Set-Cookie attributes correct, /_auth/status branches, cookie path in verifyDashboard, fall-through to authMiddleware when cookie absent/invalid. - Full suite: 572/572 (was 556 pre-commit). Smoke test on port 5600 (separate from any running hub): - GET / returns inline bootstrap script in HTML body - /_auth/status no AUTH_TOKEN → 200 - /_auth/redeem no token → 401, foreign Origin → 403 - Mint via hub endpoint + redeem succeeds with 204 + Set-Cookie containing HttpOnly, SameSite=Strict, Max-Age=86400 - Replay same token → 401 (single-use enforced) - /_api/entries still 200 in no-AUTH_TOKEN mode - `ccxray open` prints http://localhost:5601/#k= as designed Authoritative design: reason/260525-0055-ccxray-auth-design/candidate-AB.md §2.1, §3.2, §3.3 Implementation deviations: reason/260525-0055-ccxray-auth-design/errata.md §1.1 Co-Authored-By: Claude Opus 4.7 --- public/index.html | 43 +++++ server/auth.js | 169 +++++++++++++++++++- server/hub.js | 20 +++ server/index.js | 62 ++++++++ server/routes/auth.js | 37 +++++ test/auth-bootstrap.test.js | 302 ++++++++++++++++++++++++++++++++++++ 6 files changed, 631 insertions(+), 2 deletions(-) create mode 100644 server/routes/auth.js create mode 100644 test/auth-bootstrap.test.js diff --git a/public/index.html b/public/index.html index f5ef295..767c6be 100644 --- a/public/index.html +++ b/public/index.html @@ -14,6 +14,49 @@ if (t === 'light') document.documentElement.setAttribute('data-theme', 'light'); })(); + diff --git a/server/auth.js b/server/auth.js index 011c2dc..749bb09 100644 --- a/server/auth.js +++ b/server/auth.js @@ -190,11 +190,171 @@ function setDeprecation(res, value) { } } +// ─── Phase 1.3: cookie path + bootstrap flow ───────────────────────── + +const ALLOWED_HOSTS = new Set(); // populated lazily from req.headers.host +const COOKIE_TTL_SECONDS = 24 * 60 * 60; // 24h per "最小開發" decision +const BOOTSTRAP_TTL_MS = 60 * 1000; +const BOOTSTRAP_MAX_PENDING = 8; + +// Module-level state. Cleared whenever the module is re-required (tests do +// this via delete require.cache). +const pendingBootstraps = new Map(); // hashHex → expireEpochMs +let _cachedSecrets = null; + +function getSecrets() { + if (_cachedSecrets) return _cachedSecrets; + _cachedSecrets = deriveSecrets(getRootSecret()); + return _cachedSecrets; +} + +function _hashBootstrap(tok) { + const { K_bootstrap } = getSecrets(); + return crypto.createHmac('sha256', K_bootstrap).update(tok, 'utf8').digest('hex'); +} + +function _gcBootstraps(now = Date.now()) { + for (const [k, exp] of pendingBootstraps) if (exp < now) pendingBootstraps.delete(k); +} + +function mintBootstrapToken() { + _gcBootstraps(); + // Cap the pending set so a runaway minter can't grow it unbounded. + while (pendingBootstraps.size >= BOOTSTRAP_MAX_PENDING) { + // Drop oldest by insertion order (Map preserves it). + const oldest = pendingBootstraps.keys().next().value; + pendingBootstraps.delete(oldest); + } + const tok = crypto.randomBytes(24).toString('base64url'); + pendingBootstraps.set(_hashBootstrap(tok), Date.now() + BOOTSTRAP_TTL_MS); + return tok; +} + +function _isAllowedHost(host) { + if (!host) return false; + // Phase 1.3 is permissive: any localhost/loopback host is allowed. Phase + // 2.2 will tighten this with an explicit allowlist + CCXRAY_PUBLIC_ORIGINS. + if (host.startsWith('localhost:') || host === 'localhost') return true; + if (host.startsWith('127.0.0.1:') || host === '127.0.0.1') return true; + if (host.startsWith('[::1]:') || host === '[::1]') return true; + return false; +} + +function _passesCsrfGate(req) { + const sfs = req.headers['sec-fetch-site']; + if (sfs !== undefined) { + return sfs === 'same-origin' || sfs === 'none'; + } + // Older browser / non-browser fallback: require Origin to match Host. + const origin = req.headers.origin; + if (!origin) return false; + let u; + try { u = new URL(origin); } catch { return false; } + return _isAllowedHost(u.host); +} + +function parseCookie(raw, name) { + if (typeof raw !== 'string') return null; + for (const part of raw.split(';')) { + const trimmed = part.trim(); + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + if (trimmed.slice(0, eq) === name) return trimmed.slice(eq + 1); + } + return null; +} + +function _readSessionCookie(req) { + return parseCookie(req.headers.cookie, 'ccxray_s'); +} + +function _signSessionCookie() { + const { K_session } = getSecrets(); + const payload = { + v: 1, + n: crypto.randomBytes(12).toString('base64url'), + exp: Math.floor(Date.now() / 1000) + COOKIE_TTL_SECONDS, + }; + return signCookie(payload, K_session); +} + +function _verifySessionCookieValue(value) { + if (!value) return null; + const { K_session } = getSecrets(); + return verifyCookie(value, K_session); +} + +function _send(res, code, body, contentType = 'application/json') { + res.writeHead(code, { 'Content-Type': contentType }); + res.end(body == null ? '' : (typeof body === 'string' ? body : JSON.stringify(body))); +} + +function redeemBootstrap(req, res) { + // Drain the body (we don't read it — it's required for POST and that's all). + let drained = false; + const finish = (code) => { + if (drained) return; + drained = true; + if (code === 204) { + const setCookie = `ccxray_s=${_signSessionCookie()}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_TTL_SECONDS}`; + res.setHeader('Set-Cookie', setCookie); + res.writeHead(204); + res.end(); + } else if (code === 401) { + _send(res, 401, { error: 'invalid_bootstrap' }); + } else if (code === 403) { + _send(res, 403, { error: 'csrf' }); + } + }; + + req.on('data', () => {}); + req.on('end', () => { + const tok = req.headers['x-ccxray-bootstrap']; + if (!tok) return finish(401); + if (!_passesCsrfGate(req)) return finish(403); + + _gcBootstraps(); + const hash = _hashBootstrap(tok); + if (!pendingBootstraps.has(hash)) return finish(401); + pendingBootstraps.delete(hash); // single-use + finish(204); + }); +} + +function _isDashboardAuthenticated(req) { + // 1. Cookie path — fastest if present and valid. + const cookieValue = _readSessionCookie(req); + if (cookieValue && _verifySessionCookieValue(cookieValue)) return true; + // 2. Bearer / ?token= path via legacy authMiddleware. + // We can't call authMiddleware directly because it writes 401 on miss; + // instead replicate its accept checks without the side effect. + if (!AUTH_TOKEN) return true; + const authHeader = req.headers['authorization'] || ''; + if (authHeader === `Bearer ${AUTH_TOKEN}`) return true; + try { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + if (url.searchParams.get('token') === AUTH_TOKEN) return true; + } catch {} + return false; +} + +function authStatus(req, res) { + if (_isDashboardAuthenticated(req)) { + _send(res, 200, { ok: true }); + } else { + _send(res, 401, { error: 'no_session' }); + } +} + function verifyDashboard(req, res) { + // Cookie path first — succeeds silently with no deprecation header. + const cookieValue = _readSessionCookie(req); + if (cookieValue && _verifySessionCookieValue(cookieValue)) return true; + + // Fall through to legacy authMiddleware (byte-identical Phase 1.2 behavior + // for callers that never used the cookie path). const ok = authMiddleware(req, res); if (!ok) return false; - // Bearer is permanent on the dashboard domain (errata §1.1). Only - // ?token= is flagged for removal here. if (whichLegacyMechanism(req) === 'token-query') { setDeprecation(res, 'token-query'); } @@ -249,6 +409,11 @@ module.exports = { dispatch, verifyDashboard, verifyUpstream, + // Phase 1.3 additions + mintBootstrapToken, + redeemBootstrap, + authStatus, + parseCookie, // Legacy exports — call site swapped to dispatch() in Phase 1.2, // but authMiddleware stays exported so test/auth.test.js and any // downstream importer continue to work through the deprecation window. diff --git a/server/hub.js b/server/hub.js index c38c222..bf987b5 100644 --- a/server/hub.js +++ b/server/hub.js @@ -367,6 +367,11 @@ function getHubStatus() { // ── Hub route handler (mounted in server) ─────────────────────────── +function _isLoopbackPeer(req) { + const addr = req.socket?.remoteAddress || ''; + return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'; +} + function handleHubRoutes(clientReq, clientRes) { const pathname = clientReq.url.split('?')[0]; @@ -376,6 +381,21 @@ function handleHubRoutes(clientReq, clientRes) { return true; } + // Phase 1.3: bootstrap-token. Restricted to loopback peers; the real + // peer-UID gate lands with Phase 2.3 when we move to a Unix socket. + if (pathname === '/_api/hub/bootstrap-token' && clientReq.method === 'POST') { + if (!_isLoopbackPeer(clientReq)) { + clientRes.writeHead(403, { 'Content-Type': 'application/json' }); + clientRes.end(JSON.stringify({ error: 'loopback_only' })); + return true; + } + const auth = require('./auth'); + const token = auth.mintBootstrapToken(); + clientRes.writeHead(200, { 'Content-Type': 'application/json' }); + clientRes.end(JSON.stringify({ token })); + return true; + } + if (pathname === '/_api/hub/status' && clientReq.method === 'GET') { clientRes.writeHead(200, { 'Content-Type': 'application/json' }); clientRes.end(JSON.stringify(getHubStatus())); diff --git a/server/index.js b/server/index.js index 9ada1b2..7e2420b 100755 --- a/server/index.js +++ b/server/index.js @@ -49,6 +49,7 @@ if (noBrowser) process.argv.splice(process.argv.indexOf('--no-browser'), 1); const cliCommand = process.argv[2]; const unknownCommand = cliCommand && cliCommand !== 'status' + && cliCommand !== 'open' && !cliCommand.startsWith('-') && !providers.isAgentProvider(cliCommand); if (unknownCommand) { @@ -74,6 +75,7 @@ const { handleSSERoute } = require('./routes/sse'); const { handleApiRoutes } = require('./routes/api'); const { handleInterceptRoutes } = require('./routes/intercept'); const { handleCostRoutes } = require('./routes/costs'); +const { handleAuthRoutes } = require('./routes/auth'); const hub = require('./hub'); // ── Web UI: Static files from public/ ──────────────────────────────── @@ -188,6 +190,12 @@ const server = http.createServer((clientReq, clientRes) => { // Placed before auth: these are local IPC endpoints, not user-facing if (hub.handleHubRoutes(clientReq, clientRes)) return; + // ── Auth bootstrap routes (Phase 1.3) ── + // /_auth/redeem and /_auth/status run BEFORE the auth gate: redeem is + // the entry point that creates a cookie, status answers "am I + // authenticated?" without itself enforcing auth. + if (handleAuthRoutes(clientReq, clientRes)) return; + // ── Auth check (Phase 1.2 dispatcher; legacy-compatible) ── if (!dispatch(clientReq).verify(clientReq, clientRes)) return; @@ -480,6 +488,60 @@ function spawnStandaloneAgent(port, command, args) { }); } +// ── "open" subcommand (Phase 1.3) ── +// Mints a one-time bootstrap URL via the running hub (or standalone server +// on the default port) and prints it. The user opens that URL in a browser; +// the inline script in index.html redeems the token and mints the session +// cookie. Token is 60s TTL, single-use, only ever appears here and in the +// browser's URL bar (the fragment never reaches a server log). +if (process.argv[2] === 'open') { + const lock = hub.readHubLock(); + const port = lock?.port || config.PORT; + const http = require('http'); + const body = JSON.stringify({}); + const reqOpts = { + hostname: 'localhost', + port, + path: '/_api/hub/bootstrap-token', + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + timeout: 3000, + }; + const req = http.request(reqOpts, res => { + let buf = ''; + res.on('data', c => { buf += c; }); + res.on('end', () => { + if (res.statusCode !== 200) { + console.error(`\x1b[31mFailed to mint bootstrap token: HTTP ${res.statusCode}\x1b[0m`); + process.exit(1); + } + let token; + try { token = JSON.parse(buf).token; } catch {} + if (!token) { + console.error('\x1b[31mHub did not return a token. Run "ccxray status" to check.\x1b[0m'); + process.exit(1); + } + const url = `http://localhost:${port}/#k=${token}`; + console.log(url); + console.log('\x1b[90mOpen this URL in your browser (one-time, valid 60 seconds).\x1b[0m'); + if (!process.env.BROWSER && process.env.BROWSER !== 'none' && !process.env.CI && !process.env.SSH_TTY) { + const { exec } = require('child_process'); + const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; + exec(`${cmd} ${JSON.stringify(url)}`); + } + process.exit(0); + }); + }); + req.on('error', err => { + console.error(`\x1b[31mCannot reach ccxray on port ${port}: ${err.message}\x1b[0m`); + console.error('\x1b[90mStart ccxray first (e.g. "ccxray claude") and try again.\x1b[0m'); + process.exit(1); + }); + req.on('timeout', () => { req.destroy(); }); + req.end(body); + return; // prevent falling through to startup +} + // ── "status" subcommand ── if (process.argv[2] === 'status') { const lock = hub.readHubLock(); diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..fc25b12 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,37 @@ +'use strict'; + +// Phase 1.3 — auth domain routes. +// +// POST /_auth/redeem → consume one-time bootstrap token, mint cookie. +// GET /_auth/status → server-side session probe (replaces the HttpOnly- +// incompatible document.cookie check; errata §1.1). +// +// Both endpoints live on the dashboard domain so dispatch() routes them +// through verifyDashboard. /_auth/redeem MUST run BEFORE verifyDashboard +// because it's the entry point that creates the cookie; we therefore +// invoke it directly from server/index.js before the auth gate. +// +// /_auth/status is exempt from authentication too: its whole job is to +// answer "am I authenticated?" without forcing a 401 elsewhere. The +// inline browser bootstrap polls it to decide whether to show the +// "Run `ccxray open`" message. + +const auth = require('../auth'); + +function handleAuthRoutes(req, res) { + const pathname = req.url.split('?')[0]; + + if (req.method === 'POST' && pathname === '/_auth/redeem') { + auth.redeemBootstrap(req, res); + return true; + } + + if (req.method === 'GET' && pathname === '/_auth/status') { + auth.authStatus(req, res); + return true; + } + + return false; +} + +module.exports = { handleAuthRoutes }; diff --git a/test/auth-bootstrap.test.js b/test/auth-bootstrap.test.js new file mode 100644 index 0000000..011907a --- /dev/null +++ b/test/auth-bootstrap.test.js @@ -0,0 +1,302 @@ +'use strict'; + +const { describe, it, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Per-run temp CCXRAY_HOME so getRootSecret writes its local-secret somewhere +// safe. AUTH_TOKEN stays unset so we're in ephemeral mode end-to-end. +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-bootstrap-test-')); +process.env.CCXRAY_HOME = TEST_HOME; +delete process.env.AUTH_TOKEN; + +function loadAuthFresh() { + delete require.cache[require.resolve('../server/auth')]; + return require('../server/auth'); +} + +after(() => { + fs.rmSync(TEST_HOME, { recursive: true, force: true }); +}); + +function mockReqRes(opts = {}) { + const headers = { host: 'localhost:5577', ...(opts.headers || {}) }; + const req = { method: opts.method || 'POST', url: opts.url || '/_auth/redeem', headers }; + const setHeaderCalls = {}; + let bodyBuf = ''; + // Provide chunk delivery for endpoints that read req.on('data') / .on('end'). + const dataListeners = []; + const endListeners = []; + req.on = (evt, fn) => { + if (evt === 'data') dataListeners.push(fn); + else if (evt === 'end') endListeners.push(fn); + return req; + }; + // Caller can invoke req._deliverBody(string) to feed the body chunk + end. + req._deliverBody = (s) => { + if (s) dataListeners.forEach(fn => fn(Buffer.from(s, 'utf8'))); + endListeners.forEach(fn => fn()); + }; + const res = { + statusCode: null, + body: null, + writeHeadCalled: false, + writeHeadHeaders: null, + setHeader(name, value) { setHeaderCalls[name.toLowerCase()] = value; }, + getHeader(name) { return setHeaderCalls[name.toLowerCase()]; }, + writeHead(code, h) { this.writeHeadCalled = true; this.statusCode = code; this.writeHeadHeaders = h || {}; }, + end(b) { this.body = b == null ? null : String(b); }, + }; + return { req, res, setHeaderCalls }; +} + +// Helper: mint a bootstrap token directly from the auth module (simulates the +// hub's bootstrap-token endpoint without spinning up the hub). +function mintBootstrap(auth) { + return auth.mintBootstrapToken(); +} + +describe('mintBootstrapToken — one-time pad', () => { + it('returns a URL-safe token string', () => { + const auth = loadAuthFresh(); + const tok = mintBootstrap(auth); + assert.equal(typeof tok, 'string'); + assert.ok(/^[A-Za-z0-9_-]{20,}$/.test(tok), `unexpected token shape: ${tok}`); + }); + + it('produces unique tokens across calls', () => { + const auth = loadAuthFresh(); + const a = mintBootstrap(auth); + const b = mintBootstrap(auth); + assert.notEqual(a, b); + }); +}); + +describe('/_auth/redeem — happy path', () => { + it('200 + Set-Cookie when token is valid and request is same-origin', () => { + const auth = loadAuthFresh(); + const tok = mintBootstrap(auth); + const { req, res, setHeaderCalls } = mockReqRes({ + method: 'POST', + url: '/_auth/redeem', + headers: { + 'x-ccxray-bootstrap': tok, + 'sec-fetch-site': 'same-origin', + origin: 'http://localhost:5577', + host: 'localhost:5577', + 'content-type': 'application/json', + }, + }); + auth.redeemBootstrap(req, res); + req._deliverBody('{}'); + assert.equal(res.statusCode, 204); + const setCookie = setHeaderCalls['set-cookie']; + assert.ok(setCookie, 'expected Set-Cookie header'); + assert.match(setCookie, /^ccxray_s=[^;]+;/); + assert.match(setCookie, /HttpOnly/); + assert.match(setCookie, /SameSite=Strict/); + assert.match(setCookie, /Path=\//); + }); +}); + +describe('/_auth/redeem — rejection cases', () => { + it('401 when no bootstrap token is sent', () => { + const auth = loadAuthFresh(); + const { req, res } = mockReqRes({ + headers: { 'sec-fetch-site': 'same-origin', origin: 'http://localhost:5577' }, + }); + auth.redeemBootstrap(req, res); + req._deliverBody('{}'); + assert.equal(res.statusCode, 401); + }); + + it('401 when the token is unknown', () => { + const auth = loadAuthFresh(); + mintBootstrap(auth); // mint one but use a different value + const { req, res } = mockReqRes({ + headers: { + 'x-ccxray-bootstrap': 'definitely-not-the-token', + 'sec-fetch-site': 'same-origin', + origin: 'http://localhost:5577', + }, + }); + auth.redeemBootstrap(req, res); + req._deliverBody('{}'); + assert.equal(res.statusCode, 401); + }); + + it('401 on replay — same token cannot redeem twice', () => { + const auth = loadAuthFresh(); + const tok = mintBootstrap(auth); + { + const { req, res } = mockReqRes({ + headers: { 'x-ccxray-bootstrap': tok, 'sec-fetch-site': 'same-origin', origin: 'http://localhost:5577' }, + }); + auth.redeemBootstrap(req, res); + req._deliverBody('{}'); + assert.equal(res.statusCode, 204, 'first redeem should succeed'); + } + { + const { req, res } = mockReqRes({ + headers: { 'x-ccxray-bootstrap': tok, 'sec-fetch-site': 'same-origin', origin: 'http://localhost:5577' }, + }); + auth.redeemBootstrap(req, res); + req._deliverBody('{}'); + assert.equal(res.statusCode, 401, 'second redeem should fail (replay)'); + } + }); + + it('403 when Sec-Fetch-Site is cross-site and Origin is missing', () => { + const auth = loadAuthFresh(); + const tok = mintBootstrap(auth); + const { req, res } = mockReqRes({ + headers: { 'x-ccxray-bootstrap': tok, 'sec-fetch-site': 'cross-site' }, + }); + auth.redeemBootstrap(req, res); + req._deliverBody('{}'); + assert.equal(res.statusCode, 403); + }); + + it('403 when Origin is on a foreign host', () => { + const auth = loadAuthFresh(); + const tok = mintBootstrap(auth); + const { req, res } = mockReqRes({ + headers: { + 'x-ccxray-bootstrap': tok, + origin: 'http://evil.example.com', + }, + }); + auth.redeemBootstrap(req, res); + req._deliverBody('{}'); + assert.equal(res.statusCode, 403); + }); + + it('accepts when Sec-Fetch is absent but Origin matches Host (older browsers)', () => { + const auth = loadAuthFresh(); + const tok = mintBootstrap(auth); + const { req, res } = mockReqRes({ + headers: { + 'x-ccxray-bootstrap': tok, + origin: 'http://localhost:5577', + host: 'localhost:5577', + }, + }); + auth.redeemBootstrap(req, res); + req._deliverBody('{}'); + assert.equal(res.statusCode, 204); + }); +}); + +describe('/_auth/status — probe endpoint for inline browser script', () => { + beforeEach(() => { delete process.env.AUTH_TOKEN; }); + + it('returns 200 when no AUTH_TOKEN configured (ephemeral mode allows)', () => { + const auth = loadAuthFresh(); + const { req, res } = mockReqRes({ method: 'GET', url: '/_auth/status' }); + auth.authStatus(req, res); + assert.equal(res.statusCode, 200); + }); + + it('returns 401 with AUTH_TOKEN configured and no credentials', () => { + process.env.AUTH_TOKEN = 'sec1'; + const auth = loadAuthFresh(); + const { req, res } = mockReqRes({ method: 'GET', url: '/_auth/status' }); + auth.authStatus(req, res); + assert.equal(res.statusCode, 401); + delete process.env.AUTH_TOKEN; + }); + + it('returns 200 with AUTH_TOKEN + correct Bearer', () => { + process.env.AUTH_TOKEN = 'sec1'; + const auth = loadAuthFresh(); + const { req, res } = mockReqRes({ + method: 'GET', + url: '/_auth/status', + headers: { authorization: 'Bearer sec1' }, + }); + auth.authStatus(req, res); + assert.equal(res.statusCode, 200); + delete process.env.AUTH_TOKEN; + }); + + it('returns 200 after successful bootstrap (cookie path)', () => { + process.env.AUTH_TOKEN = 'sec1'; + const auth = loadAuthFresh(); + const tok = mintBootstrap(auth); + // Redeem to get a cookie + const { req: r1, res: res1, setHeaderCalls: sh } = mockReqRes({ + headers: { + 'x-ccxray-bootstrap': tok, + 'sec-fetch-site': 'same-origin', + origin: 'http://localhost:5577', + }, + }); + auth.redeemBootstrap(r1, res1); + r1._deliverBody('{}'); + assert.equal(res1.statusCode, 204); + const setCookie = sh['set-cookie']; + const cookieValue = setCookie.match(/^ccxray_s=([^;]+);/)[1]; + + // Use cookie on status endpoint + const { req: r2, res: res2 } = mockReqRes({ + method: 'GET', + url: '/_auth/status', + headers: { cookie: `ccxray_s=${cookieValue}` }, + }); + auth.authStatus(r2, res2); + assert.equal(res2.statusCode, 200); + delete process.env.AUTH_TOKEN; + }); +}); + +describe('verifyDashboard — cookie path added in Phase 1.3', () => { + it('accepts a valid cookie when AUTH_TOKEN is set and no Bearer/?token=', () => { + process.env.AUTH_TOKEN = 'sec1'; + const auth = loadAuthFresh(); + const tok = mintBootstrap(auth); + // Mint a cookie via redeem + const { req: r1, res: res1, setHeaderCalls: sh } = mockReqRes({ + headers: { + 'x-ccxray-bootstrap': tok, + 'sec-fetch-site': 'same-origin', + origin: 'http://localhost:5577', + }, + }); + auth.redeemBootstrap(r1, res1); + r1._deliverBody('{}'); + const cookieValue = sh['set-cookie'].match(/^ccxray_s=([^;]+);/)[1]; + + const { req, res } = mockReqRes({ + method: 'GET', + url: '/_api/entries', + headers: { cookie: `ccxray_s=${cookieValue}` }, + }); + assert.equal(auth.verifyDashboard(req, res), true); + assert.equal(res.writeHeadCalled, false); + delete process.env.AUTH_TOKEN; + }); + + it('falls through to authMiddleware when cookie is absent', () => { + process.env.AUTH_TOKEN = 'sec1'; + const auth = loadAuthFresh(); + const { req, res } = mockReqRes({ method: 'GET', url: '/_api/entries', headers: {} }); + assert.equal(auth.verifyDashboard(req, res), false); + assert.equal(res.statusCode, 401); + delete process.env.AUTH_TOKEN; + }); + + it('falls through to authMiddleware when cookie is present but invalid', () => { + process.env.AUTH_TOKEN = 'sec1'; + const auth = loadAuthFresh(); + const { req, res } = mockReqRes({ + method: 'GET', + url: '/_api/entries', + headers: { cookie: 'ccxray_s=garbage.value' }, + }); + assert.equal(auth.verifyDashboard(req, res), false); + assert.equal(res.statusCode, 401); + delete process.env.AUTH_TOKEN; + }); +}); From 4b0d1420b8b91dfa4270d3031524d6ed2638e4f0 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Tue, 26 May 2026 00:47:16 +0800 Subject: [PATCH 4/8] feat(auth): launcher X-Ccxray-Auth header injection (warn-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude: injects via ANTHROPIC_CUSTOM_HEADERS env (SDK-documented extension). Codex API-key mode: injects via model_providers.ccxray={…http_headers…} + model_provider="ccxray" (spike-verified against Codex v0.133.0-alpha.1). Codex ChatGPT-OAuth: skips model_provider override to avoid breaking OAuth login — falls through to legacy openai_base_url path. If K_upstream derivation fails (unreadable CCXRAY_HOME), warns but does not abort — Phase 2.1 enforces. No existing behavior changes for any launch path. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/providers.js | 43 ++++++++-- test/auth-launcher.test.js | 164 +++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 test/auth-launcher.test.js diff --git a/server/providers.js b/server/providers.js index 334845f..1116b32 100644 --- a/server/providers.js +++ b/server/providers.js @@ -5,6 +5,17 @@ // ccxray proxy, so new launchers should be additive registry entries instead // of new command-specific branches in server/index.js. +function getUpstreamToken() { + try { + const auth = require('./auth'); + const secrets = auth.deriveSecrets(auth.getRootSecret()); + return secrets.K_upstream.toString('base64url'); + } catch (e) { + console.warn(`[ccxray] Could not derive X-Ccxray-Auth token: ${e.message}`); + return null; + } +} + const AGENT_PROVIDERS = Object.freeze({ claude: Object.freeze({ id: 'claude', @@ -13,11 +24,16 @@ const AGENT_PROVIDERS = Object.freeze({ upstream: 'anthropic', installHint: ' npm install -g @anthropic-ai/claude-code', createLaunch({ port, args, env }) { - return { - bin: 'claude', - args: [...args], - env: { ...env, ANTHROPIC_BASE_URL: `http://localhost:${port}` }, - }; + const launchEnv = { ...env, ANTHROPIC_BASE_URL: `http://localhost:${port}` }; + const token = getUpstreamToken(); + if (token) { + const authHeader = `X-Ccxray-Auth: ${token}`; + const existing = launchEnv.ANTHROPIC_CUSTOM_HEADERS; + launchEnv.ANTHROPIC_CUSTOM_HEADERS = existing + ? `${existing}, ${authHeader}` + : authHeader; + } + return { bin: 'claude', args: [...args], env: launchEnv }; }, }), @@ -29,6 +45,23 @@ const AGENT_PROVIDERS = Object.freeze({ installHint: ' npm install -g @openai/codex', createLaunch({ port, args, env }) { const proxyBaseUrl = `http://localhost:${port}/v1`; + const hasApiKey = Boolean(env.OPENAI_API_KEY); + + if (hasApiKey) { + const token = getUpstreamToken(); + if (token) { + const mpConfig = `model_providers.ccxray={name="ccxray", base_url="${proxyBaseUrl}", wire_api="responses", http_headers={"X-Ccxray-Auth"="${token}"}}`; + return { + bin: 'codex', + args: ['-c', mpConfig, '-c', 'model_provider="ccxray"', ...args], + env: { ...env }, + }; + } + // Token derivation failed — fall through to legacy path with warning + // (warning already emitted by getUpstreamToken) + } + + // ChatGPT-OAuth mode or token derivation failure: legacy path return { bin: 'codex', args: [ diff --git a/test/auth-launcher.test.js b/test/auth-launcher.test.js new file mode 100644 index 0000000..faa1e7d --- /dev/null +++ b/test/auth-launcher.test.js @@ -0,0 +1,164 @@ +'use strict'; + +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const crypto = require('crypto'); + +describe('auth launcher header injection (1.4a)', () => { + let tmpHome; + let originalEnv; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-auth-launcher-')); + originalEnv = { ...process.env }; + process.env.CCXRAY_HOME = tmpHome; + // Ensure auth module re-derives from fresh CCXRAY_HOME + delete require.cache[require.resolve('../server/auth')]; + delete require.cache[require.resolve('../server/providers')]; + }); + + afterEach(() => { + process.env = originalEnv; + delete require.cache[require.resolve('../server/auth')]; + delete require.cache[require.resolve('../server/providers')]; + }); + + function getKUpstreamBase64url() { + const auth = require('../server/auth'); + const secrets = auth.deriveSecrets(auth.getRootSecret()); + return secrets.K_upstream.toString('base64url'); + } + + describe('Claude launcher', () => { + it('injects X-Ccxray-Auth via ANTHROPIC_CUSTOM_HEADERS', () => { + const providers = require('../server/providers'); + const kUp = getKUpstreamBase64url(); + + const launch = providers.getAgentLaunch('claude', 5577, ['--continue'], { + PATH: '/usr/bin', + }); + + assert.equal( + launch.env.ANTHROPIC_CUSTOM_HEADERS, + `X-Ccxray-Auth: ${kUp}` + ); + // Still sets ANTHROPIC_BASE_URL + assert.equal(launch.env.ANTHROPIC_BASE_URL, 'http://localhost:5577'); + }); + + it('appends to existing ANTHROPIC_CUSTOM_HEADERS', () => { + const providers = require('../server/providers'); + const kUp = getKUpstreamBase64url(); + + const launch = providers.getAgentLaunch('claude', 5577, [], { + PATH: '/usr/bin', + ANTHROPIC_CUSTOM_HEADERS: 'X-Existing: foo', + }); + + assert.equal( + launch.env.ANTHROPIC_CUSTOM_HEADERS, + `X-Existing: foo, X-Ccxray-Auth: ${kUp}` + ); + }); + }); + + describe('Codex launcher — API-key mode (OPENAI_API_KEY set)', () => { + it('injects model_providers.ccxray with http_headers + model_provider override', () => { + const providers = require('../server/providers'); + const kUp = getKUpstreamBase64url(); + + const launch = providers.getAgentLaunch('codex', 5577, ['exec', 'hello'], { + PATH: '/usr/bin', + OPENAI_API_KEY: 'sk-test-key', + }); + + // Should have model_providers.ccxray config + const mpArg = launch.args.find(a => a.includes('model_providers.ccxray')); + assert.ok(mpArg, 'should have model_providers.ccxray arg'); + assert.match(mpArg, /base_url="http:\/\/localhost:5577\/v1"/); + assert.match(mpArg, /wire_api="responses"/); + assert.match(mpArg, new RegExp(`X-Ccxray-Auth.*${kUp.slice(0, 10)}`)); + + // Should have model_provider="ccxray" + const providerIdx = launch.args.indexOf('-c'); + const mpOverride = launch.args.find(a => a.includes('model_provider="ccxray"')); + assert.ok(mpOverride, 'should have model_provider="ccxray" arg'); + + // Should NOT have old-style openai_base_url / chatgpt_base_url + const hasOldStyle = launch.args.some(a => a.includes('openai_base_url')); + assert.equal(hasOldStyle, false, 'should not have openai_base_url in API-key mode'); + + // User args still pass through + assert.ok(launch.args.includes('exec')); + assert.ok(launch.args.includes('hello')); + }); + }); + + describe('Codex launcher — ChatGPT-OAuth mode (no OPENAI_API_KEY)', () => { + it('uses legacy openai_base_url + chatgpt_base_url, no model_provider override', () => { + const providers = require('../server/providers'); + + const launch = providers.getAgentLaunch('codex', 5577, ['exec', 'hello'], { + PATH: '/usr/bin', + // No OPENAI_API_KEY + }); + + // Should have old-style base_url configs + assert.ok( + launch.args.some(a => a.includes('openai_base_url="http://localhost:5577/v1"')), + 'should have openai_base_url' + ); + assert.ok( + launch.args.some(a => a.includes('chatgpt_base_url="http://localhost:5577/v1"')), + 'should have chatgpt_base_url' + ); + + // Should NOT have model_provider override + const mpOverride = launch.args.find(a => a.includes('model_provider=')); + assert.equal(mpOverride, undefined, 'should not have model_provider in OAuth mode'); + + // Should NOT have model_providers.ccxray + const mpConfig = launch.args.find(a => a.includes('model_providers.ccxray')); + assert.equal(mpConfig, undefined, 'should not have model_providers.ccxray in OAuth mode'); + }); + }); + + describe('graceful fallback when K_upstream derivation fails', () => { + it('warns but does not abort when getRootSecret throws', () => { + // Point CCXRAY_HOME at a read-only path so ensureHubDir() fails on mkdir + const readonlyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-ro-')); + const impossibleChild = path.join(readonlyDir, 'nope', 'deeper'); + process.env.CCXRAY_HOME = impossibleChild; + fs.chmodSync(readonlyDir, 0o444); + + delete require.cache[require.resolve('../server/auth')]; + delete require.cache[require.resolve('../server/providers')]; + + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + + try { + const providers = require('../server/providers'); + const launch = providers.getAgentLaunch('claude', 5577, [], { PATH: '/usr/bin' }); + + assert.ok(launch, 'launch should not be null'); + assert.equal(launch.bin, 'claude'); + assert.equal(launch.env.ANTHROPIC_BASE_URL, 'http://localhost:5577'); + assert.equal(launch.env.ANTHROPIC_CUSTOM_HEADERS, undefined); + assert.ok(warnings.length > 0, 'should have emitted a warning'); + assert.ok( + warnings.some(w => w.includes('X-Ccxray-Auth')), + 'warning should mention X-Ccxray-Auth' + ); + } finally { + console.warn = origWarn; + fs.chmodSync(readonlyDir, 0o755); + fs.rmSync(readonlyDir, { recursive: true }); + } + }); + }); +}); From aef3f86d1f216ac96cbae46fbd42ecb04091ed47 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Tue, 26 May 2026 00:48:04 +0800 Subject: [PATCH 5/8] feat(auth): WS upgrade warn-only auth gate + header stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classifyUpstreamAuth() classifies each WS upgrade as authed / chatgpt-oauth / warn. Phase 1 emits console.warn for unauthenticated upgrades but does NOT block — Phase 2.1 will enforce. ChatGPT-OAuth carve-out: presence of chatgpt-account-id + JWT-shaped Authorization skips the warning (these requests cannot carry X-Ccxray-Auth without breaking Codex OAuth login). buildWebSocketHeaders now strips X-Ccxray-Auth and X-Ccxray-Bootstrap before forwarding to upstream — ccxray-internal headers must not leak to OpenAI. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/ws-proxy.js | 24 ++++++++++++++ test/auth-ws.test.js | 78 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 test/auth-ws.test.js diff --git a/server/ws-proxy.js b/server/ws-proxy.js index 0bd7a6d..f322b90 100644 --- a/server/ws-proxy.js +++ b/server/ws-proxy.js @@ -92,6 +92,8 @@ function isAuthorized(req) { } } +const CCXRAY_INTERNAL_HEADERS = new Set(['x-ccxray-auth', 'x-ccxray-bootstrap']); + function buildWebSocketHeaders(clientHeaders, upstream) { const headers = {}; const connectionTokens = String(clientHeaders.connection || '') @@ -103,6 +105,7 @@ function buildWebSocketHeaders(clientHeaders, upstream) { const lower = name.toLowerCase(); if (HOP_BY_HOP_HEADERS.has(lower)) continue; if (WS_HANDSHAKE_HEADERS.has(lower)) continue; + if (CCXRAY_INTERNAL_HEADERS.has(lower)) continue; if (connectionTokens.includes(lower)) continue; if (lower === 'host') continue; headers[name] = value; @@ -327,6 +330,11 @@ function handleWebSocketUpgrade(req, socket, head) { return true; } + const authClass = classifyUpstreamAuth(req.headers); + if (authClass === 'warn') { + console.warn('[ccxray] WebSocket upgrade without X-Ccxray-Auth header (warn-only, Phase 2.1 will enforce)'); + } + const id = helpers.timestamp(); const ts = helpers.taipeiTime(); const startTime = Date.now(); @@ -498,10 +506,26 @@ async function drainWebSocketProxy() { await Promise.allSettled([...pendingEntries]); } +// JWT-shaped: "Bearer
.." where header is base64url JSON +function isJwtShaped(authHeader) { + if (!authHeader || typeof authHeader !== 'string') return false; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; + if (!token) return false; + const parts = token.split('.'); + return parts.length === 3 && parts[0].length > 10; +} + +function classifyUpstreamAuth(headers) { + if (headers['x-ccxray-auth']) return 'authed'; + if (headers['chatgpt-account-id'] && isJwtShaped(headers.authorization)) return 'chatgpt-oauth'; + return 'warn'; +} + module.exports = { handleWebSocketUpgrade, buildWebSocketHeaders, isOpenAIWebSocket, normalizeCloseCode, drainWebSocketProxy, + classifyUpstreamAuth, }; diff --git a/test/auth-ws.test.js b/test/auth-ws.test.js new file mode 100644 index 0000000..98a9069 --- /dev/null +++ b/test/auth-ws.test.js @@ -0,0 +1,78 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +// Unit-test the warn-only auth gate helper that ws-proxy.js uses to decide +// whether an upgrade request is missing X-Ccxray-Auth. The actual WebSocket +// upgrade is tested in websocket-proxy.test.js; here we test the classification +// logic in isolation. + +const wsProxy = require('../server/ws-proxy'); + +describe('WS header stripping (1.4c)', () => { + it('buildWebSocketHeaders strips X-Ccxray-Auth and X-Ccxray-Bootstrap from upstream', () => { + const { buildWebSocketHeaders } = wsProxy; + const clientHeaders = { + 'x-ccxray-auth': 'secret-token', + 'x-ccxray-bootstrap': 'bootstrap-token', + 'authorization': 'Bearer sk-test', + 'openai-beta': 'responses_websockets=v1', + 'host': 'localhost:5577', + }; + const upstream = { host: 'api.openai.com', port: 443 }; + const result = buildWebSocketHeaders(clientHeaders, upstream); + + assert.equal(result['x-ccxray-auth'], undefined); + assert.equal(result['x-ccxray-bootstrap'], undefined); + assert.equal(result['authorization'], 'Bearer sk-test'); + assert.equal(result['openai-beta'], 'responses_websockets=v1'); + assert.equal(result.host, 'api.openai.com'); + }); +}); + +describe('WS auth gate classification (1.4b)', () => { + describe('classifyUpstreamAuth', () => { + it('returns "authed" when X-Ccxray-Auth is present', () => { + const headers = { 'x-ccxray-auth': 'some-token-value' }; + assert.equal(wsProxy.classifyUpstreamAuth(headers), 'authed'); + }); + + it('returns "warn" when no X-Ccxray-Auth and no ChatGPT-OAuth markers', () => { + const headers = { 'openai-beta': 'responses_websockets=v1' }; + assert.equal(wsProxy.classifyUpstreamAuth(headers), 'warn'); + }); + + it('returns "chatgpt-oauth" for ChatGPT-OAuth carve-out (no X-Ccxray-Auth + chatgpt-account-id + JWT authorization)', () => { + const headers = { + 'chatgpt-account-id': 'acct-123', + authorization: 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.fake', + }; + assert.equal(wsProxy.classifyUpstreamAuth(headers), 'chatgpt-oauth'); + }); + + it('returns "warn" when chatgpt-account-id present but Authorization is not JWT-shaped', () => { + const headers = { + 'chatgpt-account-id': 'acct-123', + authorization: 'Bearer sk-proj-abc123', + }; + assert.equal(wsProxy.classifyUpstreamAuth(headers), 'warn'); + }); + + it('returns "warn" when JWT-shaped Authorization present but no chatgpt-account-id', () => { + const headers = { + authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.sig', + }; + assert.equal(wsProxy.classifyUpstreamAuth(headers), 'warn'); + }); + + it('returns "authed" when X-Ccxray-Auth is present even with ChatGPT-OAuth markers', () => { + const headers = { + 'x-ccxray-auth': 'token', + 'chatgpt-account-id': 'acct-123', + authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.sig', + }; + assert.equal(wsProxy.classifyUpstreamAuth(headers), 'authed'); + }); + }); +}); From 0357e09dd3910b4011e3852b2e53967b05aa5e47 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Tue, 26 May 2026 00:49:03 +0800 Subject: [PATCH 6/8] feat(auth): strip ccxray-internal headers from HTTP forward path buildForwardHeaders now removes X-Ccxray-Auth and X-Ccxray-Bootstrap before proxying to Anthropic/OpenAI. These are ccxray-internal auth headers and must not leak to upstream APIs. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/index.js b/server/index.js index 7e2420b..07a9a6d 100755 --- a/server/index.js +++ b/server/index.js @@ -129,6 +129,8 @@ const HOP_BY_HOP_HEADERS = new Set([ 'upgrade', ]); +const CCXRAY_INTERNAL_HEADERS = ['x-ccxray-auth', 'x-ccxray-bootstrap']; + function buildForwardHeaders(clientHeaders, upstream) { const fwdHeaders = { ...clientHeaders }; const connectionTokens = String(clientHeaders.connection || '') @@ -138,6 +140,7 @@ function buildForwardHeaders(clientHeaders, upstream) { for (const header of HOP_BY_HOP_HEADERS) delete fwdHeaders[header]; for (const header of connectionTokens) delete fwdHeaders[header]; + for (const header of CCXRAY_INTERNAL_HEADERS) delete fwdHeaders[header]; delete fwdHeaders.host; delete fwdHeaders['accept-encoding']; fwdHeaders.host = upstream.host; From 803d91a2bbe5def8ee45fbad4ec609b4fd4d96c6 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Tue, 26 May 2026 01:10:57 +0800 Subject: [PATCH 7/8] test(auth): add E2E tests for 1.4 header injection + stripping Covers: - HTTP forward strips X-Ccxray-Auth + X-Ccxray-Bootstrap before upstream - No auth header values leak to disk logs - WS upgrade without X-Ccxray-Auth warns but still succeeds - ChatGPT-OAuth carve-out (chatgpt-account-id + JWT) does not warn - WS upgrade strips ccxray-internal headers before upstream Co-Authored-By: Claude Opus 4.6 (1M context) --- test/auth-header-injection.e2e.test.js | 425 +++++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 test/auth-header-injection.e2e.test.js diff --git a/test/auth-header-injection.e2e.test.js b/test/auth-header-injection.e2e.test.js new file mode 100644 index 0000000..a1184fd --- /dev/null +++ b/test/auth-header-injection.e2e.test.js @@ -0,0 +1,425 @@ +'use strict'; + +const { describe, it, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); + +const SERVER_SCRIPT = path.join(__dirname, '..', 'server', 'index.js'); +const tmpDirs = []; + +async function findFreePort() { + return new Promise(resolve => { + const server = http.createServer(); + server.listen(0, () => { + const port = server.address().port; + server.close(() => resolve(port)); + }); + }); +} + +function waitForPort(port, timeoutMs = 8000) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const check = () => { + const req = http.get(`http://localhost:${port}/_api/health`, { timeout: 1000 }, res => { + res.resume(); + res.on('end', () => resolve()); + }); + req.on('error', () => { + if (Date.now() - start > timeoutMs) return reject(new Error('proxy did not start')); + setTimeout(check, 100); + }); + req.on('timeout', () => { + req.destroy(); + if (Date.now() - start > timeoutMs) return reject(new Error('proxy did not start')); + setTimeout(check, 100); + }); + }; + check(); + }); +} + +function killAndWait(child) { + return new Promise(resolve => { + if (!child || child.exitCode !== null) return resolve(); + child.on('exit', resolve); + child.kill('SIGTERM'); + setTimeout(() => { + try { child.kill('SIGKILL'); } catch {} + resolve(); + }, 3000); + }); +} + +function makeTmpHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-auth-e2e-')); + tmpDirs.push(home); + return home; +} + +function postJson(port, urlPath, body, headers = {}) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = http.request({ + hostname: 'localhost', port, path: urlPath, method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(data), + 'x-api-key': 'sk-fake', + 'anthropic-version': '2023-06-01', + ...headers, + }, + }, res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString() })); + }); + req.on('error', reject); + req.write(data); req.end(); + }); +} + +describe('Auth header injection E2E (1.4)', () => { + after(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); + }); + + it('strips X-Ccxray-Auth and X-Ccxray-Bootstrap from HTTP requests forwarded to upstream', async () => { + const upstreamPort = await findFreePort(); + const proxyPort = await findFreePort(); + const home = makeTmpHome(); + + const upstreamRequests = []; + const upstream = http.createServer((req, res) => { + upstreamRequests.push({ url: req.url, headers: { ...req.headers } }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + id: 'msg_fake', type: 'message', role: 'assistant', + model: 'claude-3-haiku-20240307', stop_reason: 'end_turn', stop_sequence: null, + content: [{ type: 'text', text: 'ok' }], + usage: { input_tokens: 1, output_tokens: 1 }, + })); + }); + await new Promise(resolve => upstream.listen(upstreamPort, '127.0.0.1', resolve)); + + let stderr = ''; + const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], { + env: { + ...process.env, + ANTHROPIC_TEST_HOST: '127.0.0.1', + ANTHROPIC_TEST_PORT: String(upstreamPort), + ANTHROPIC_TEST_PROTOCOL: 'http', + CCXRAY_HOME: home, + BROWSER: 'none', + RESTORE_DAYS: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', () => {}); + child.stderr.on('data', d => { stderr += d.toString(); }); + + try { + await waitForPort(proxyPort); + + const resp = await postJson(proxyPort, '/v1/messages', { + model: 'claude-3-haiku-20240307', + max_tokens: 8, + messages: [{ role: 'user', content: 'hello' }], + }, { + 'x-ccxray-auth': 'super-secret-token-123', + 'x-ccxray-bootstrap': 'bootstrap-secret-456', + }); + + assert.equal(resp.statusCode, 200, 'proxy should forward and respond 200'); + assert.equal(upstreamRequests.length, 1, 'upstream should receive exactly one request'); + + const fwdHeaders = upstreamRequests[0].headers; + assert.equal(fwdHeaders['x-ccxray-auth'], undefined, + 'X-Ccxray-Auth must NOT be forwarded to upstream'); + assert.equal(fwdHeaders['x-ccxray-bootstrap'], undefined, + 'X-Ccxray-Bootstrap must NOT be forwarded to upstream'); + assert.ok(fwdHeaders['x-api-key'], 'x-api-key should still be forwarded'); + assert.ok(fwdHeaders['anthropic-version'], 'anthropic-version should still be forwarded'); + } finally { + upstream.close(); + await killAndWait(child); + } + }); + + it('X-Ccxray-Auth does NOT appear in disk logs (_req.json)', async () => { + const upstreamPort = await findFreePort(); + const proxyPort = await findFreePort(); + const home = makeTmpHome(); + + const upstream = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + id: 'msg_fake', type: 'message', role: 'assistant', + model: 'claude-3-haiku-20240307', stop_reason: 'end_turn', stop_sequence: null, + content: [{ type: 'text', text: 'ok' }], + usage: { input_tokens: 1, output_tokens: 1 }, + })); + }); + await new Promise(resolve => upstream.listen(upstreamPort, '127.0.0.1', resolve)); + + const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], { + env: { + ...process.env, + ANTHROPIC_TEST_HOST: '127.0.0.1', + ANTHROPIC_TEST_PORT: String(upstreamPort), + ANTHROPIC_TEST_PROTOCOL: 'http', + CCXRAY_HOME: home, + BROWSER: 'none', + RESTORE_DAYS: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', () => {}); + child.stderr.on('data', () => {}); + + try { + await waitForPort(proxyPort); + + await postJson(proxyPort, '/v1/messages', { + model: 'claude-3-haiku-20240307', + max_tokens: 8, + messages: [{ role: 'user', content: 'hello' }], + }, { + 'x-ccxray-auth': 'LEAK-CHECK-TOKEN-789', + 'cookie': 'ccxray_s=sensitive-session-cookie', + }); + + // Wait for async disk write + await new Promise(r => setTimeout(r, 500)); + + const logsDir = path.join(home, 'logs'); + if (fs.existsSync(logsDir)) { + const files = fs.readdirSync(logsDir).filter(f => f.endsWith('.json')); + for (const f of files) { + const content = fs.readFileSync(path.join(logsDir, f), 'utf8'); + assert.ok(!content.includes('LEAK-CHECK-TOKEN-789'), + `X-Ccxray-Auth value leaked to ${f}`); + assert.ok(!content.includes('sensitive-session-cookie'), + `Cookie value leaked to ${f}`); + } + } + } finally { + upstream.close(); + await killAndWait(child); + } + }); + + it('WS upgrade without X-Ccxray-Auth emits warning but still succeeds (warn-only)', async () => { + const upstreamPort = await findFreePort(); + const proxyPort = await findFreePort(); + const home = makeTmpHome(); + + // Create a simple WebSocket echo upstream + const upstreamWss = new WebSocket.Server({ noServer: true }); + const upstreamHttp = http.createServer(); + upstreamHttp.on('upgrade', (req, socket, head) => { + upstreamWss.handleUpgrade(req, socket, head, ws => { + ws.on('message', data => ws.send(data)); + // Auto close after 1s to keep test short + setTimeout(() => ws.close(1000, 'done'), 500); + }); + }); + await new Promise(resolve => upstreamHttp.listen(upstreamPort, '127.0.0.1', resolve)); + + let stderr = ''; + const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], { + env: { + ...process.env, + OPENAI_TEST_HOST: '127.0.0.1', + OPENAI_TEST_PORT: String(upstreamPort), + OPENAI_TEST_PROTOCOL: 'http', + AUTH_TOKEN: 'ws-test-secret', + CCXRAY_HOME: home, + BROWSER: 'none', + RESTORE_DAYS: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', () => {}); + child.stderr.on('data', d => { stderr += d.toString(); }); + + try { + await waitForPort(proxyPort); + + // Connect without X-Ccxray-Auth — should warn but still upgrade + const ws = new WebSocket(`ws://localhost:${proxyPort}/v1/responses?token=ws-test-secret`, { + headers: { + 'openai-beta': 'responses_websockets=v1', + 'codex-session-id': 'test-session-123', + }, + }); + + const opened = await new Promise((resolve, reject) => { + ws.on('open', () => resolve(true)); + ws.on('error', reject); + setTimeout(() => reject(new Error('WS connect timeout')), 5000); + }); + assert.ok(opened, 'WS should connect successfully (warn-only, not blocking)'); + + // Wait for close + await new Promise(resolve => { + ws.on('close', resolve); + setTimeout(resolve, 2000); + }); + + // Allow stderr to flush + await new Promise(r => setTimeout(r, 300)); + + assert.ok( + stderr.includes('without X-Ccxray-Auth'), + 'Should have emitted warning about missing X-Ccxray-Auth in stderr' + ); + } finally { + upstreamHttp.close(); + await killAndWait(child); + } + }); + + it('WS upgrade with ChatGPT-OAuth markers does NOT warn', async () => { + const upstreamPort = await findFreePort(); + const proxyPort = await findFreePort(); + const home = makeTmpHome(); + + const upstreamWss = new WebSocket.Server({ noServer: true }); + const upstreamHttp = http.createServer(); + upstreamHttp.on('upgrade', (req, socket, head) => { + upstreamWss.handleUpgrade(req, socket, head, ws => { + setTimeout(() => ws.close(1000, 'done'), 500); + }); + }); + await new Promise(resolve => upstreamHttp.listen(upstreamPort, '127.0.0.1', resolve)); + + let stderr = ''; + const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], { + env: { + ...process.env, + OPENAI_TEST_HOST: '127.0.0.1', + OPENAI_TEST_PORT: String(upstreamPort), + OPENAI_TEST_PROTOCOL: 'http', + AUTH_TOKEN: 'ws-oauth-secret', + CCXRAY_HOME: home, + BROWSER: 'none', + RESTORE_DAYS: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', () => {}); + child.stderr.on('data', d => { stderr += d.toString(); }); + + try { + await waitForPort(proxyPort); + + // Connect with ChatGPT-OAuth markers — should NOT warn + const ws = new WebSocket(`ws://localhost:${proxyPort}/v1/responses?token=ws-oauth-secret`, { + headers: { + 'openai-beta': 'responses_websockets=v1', + 'chatgpt-account-id': 'acct-test-456', + 'authorization': 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.fakesig', + 'codex-session-id': 'test-session-oauth', + }, + }); + + await new Promise((resolve, reject) => { + ws.on('open', () => resolve(true)); + ws.on('error', reject); + setTimeout(() => reject(new Error('WS connect timeout')), 5000); + }); + + await new Promise(resolve => { + ws.on('close', resolve); + setTimeout(resolve, 2000); + }); + + await new Promise(r => setTimeout(r, 300)); + + assert.ok( + !stderr.includes('without X-Ccxray-Auth'), + 'Should NOT warn for ChatGPT-OAuth path (chatgpt-account-id + JWT present)' + ); + } finally { + upstreamHttp.close(); + await killAndWait(child); + } + }); + + it('WS upgrade strips X-Ccxray-Auth before forwarding to upstream', async () => { + const upstreamPort = await findFreePort(); + const proxyPort = await findFreePort(); + const home = makeTmpHome(); + + const receivedUpgradeHeaders = []; + const upstreamWss = new WebSocket.Server({ noServer: true }); + const upstreamHttp = http.createServer(); + upstreamHttp.on('upgrade', (req, socket, head) => { + receivedUpgradeHeaders.push({ ...req.headers }); + upstreamWss.handleUpgrade(req, socket, head, ws => { + setTimeout(() => ws.close(1000, 'done'), 500); + }); + }); + await new Promise(resolve => upstreamHttp.listen(upstreamPort, '127.0.0.1', resolve)); + + const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], { + env: { + ...process.env, + OPENAI_TEST_HOST: '127.0.0.1', + OPENAI_TEST_PORT: String(upstreamPort), + OPENAI_TEST_PROTOCOL: 'http', + AUTH_TOKEN: 'ws-strip-secret', + CCXRAY_HOME: home, + BROWSER: 'none', + RESTORE_DAYS: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', () => {}); + child.stderr.on('data', () => {}); + + try { + await waitForPort(proxyPort); + + const ws = new WebSocket(`ws://localhost:${proxyPort}/v1/responses?token=ws-strip-secret`, { + headers: { + 'openai-beta': 'responses_websockets=v1', + 'x-ccxray-auth': 'must-not-reach-upstream', + 'x-ccxray-bootstrap': 'also-must-not-reach', + 'authorization': 'Bearer sk-real-key', + 'codex-session-id': 'test-session-strip', + }, + }); + + await new Promise((resolve, reject) => { + ws.on('open', () => resolve(true)); + ws.on('error', reject); + setTimeout(() => reject(new Error('WS connect timeout')), 5000); + }); + + await new Promise(resolve => { + ws.on('close', resolve); + setTimeout(resolve, 2000); + }); + + assert.equal(receivedUpgradeHeaders.length, 1, 'upstream should receive one upgrade'); + const h = receivedUpgradeHeaders[0]; + assert.equal(h['x-ccxray-auth'], undefined, + 'X-Ccxray-Auth must NOT reach upstream via WS'); + assert.equal(h['x-ccxray-bootstrap'], undefined, + 'X-Ccxray-Bootstrap must NOT reach upstream via WS'); + assert.equal(h['authorization'], 'Bearer sk-real-key', + 'Authorization header should be preserved'); + assert.ok(h['openai-beta'], 'openai-beta should be preserved'); + } finally { + upstreamHttp.close(); + await killAndWait(child); + } + }); +}); From 00524927e669c3124fe076598d9fb2ea61a64a42 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Tue, 26 May 2026 11:09:14 +0800 Subject: [PATCH 8/8] =?UTF-8?q?docs(auth):=20reorder=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20Unix=20socket=20before=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit depandabot audit found that the proposed dual-loopback check (localAddress + remoteAddress) replicates a known-broken pattern (OpenClaw GHSA-xc7w-v5x6-cc87). Reorder Phase 2 so Unix socket hub IPC lands first, closing the multi-UID attack surface before enforcement flips on. See docs/depandabot/2026-05-26-phase-2-auth-adjustments.md for the full audit artifact (terminal state: REFRAME). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-05-26-phase-2-auth-adjustments.md | 82 +++++++++++++++++++ .../260525-0055-ccxray-auth-design/errata.md | 21 +++++ 2 files changed, 103 insertions(+) create mode 100644 docs/depandabot/2026-05-26-phase-2-auth-adjustments.md diff --git a/docs/depandabot/2026-05-26-phase-2-auth-adjustments.md b/docs/depandabot/2026-05-26-phase-2-auth-adjustments.md new file mode 100644 index 0000000..8bd7eee --- /dev/null +++ b/docs/depandabot/2026-05-26-phase-2-auth-adjustments.md @@ -0,0 +1,82 @@ +# depandabot audit — Phase 2 Auth Adjustments + +## §1 Current State + +1. **Phase 1 complete on `feat/auth-phase-1`** (commits `cbd083e`→`803d91a`, 589 tests green). Launcher injects `X-Ccxray-Auth`, WS gate classifies + warns, internal headers stripped before upstream. Pushed, not yet merged to main. (`server/providers.js:15-79`, `server/ws-proxy.js:489-520`) + +2. **`verifyUpstream` exists in warn-only mode** (`server/auth.js:364-371`). It calls legacy `authMiddleware` under the hood and adds deprecation headers. Flipping to enforce = changing the fallback from `authMiddleware(req, res)` to a 401 rejection when `X-Ccxray-Auth` is absent. + +3. **Hub IPC is 100% HTTP-over-TCP** (`server/hub.js`, 533 LOC). Routes: `/_api/hub/register`, `/_api/hub/unregister`, `/_api/hub/bootstrap-token`, `/_api/hub/status`, `/_api/health`. Client discovery reads `hub.json` for `{port, pid}` and makes HTTP calls. No Unix socket code exists yet. + +4. **The spec explicitly supports "optional remote deployment"** behind a TLS terminator (Tailscale Serve, Caddy, nginx). The `CCXRAY_PUBLIC_ORIGINS` env var is designed for this. (`reason/260525-0055-ccxray-auth-design/task.md:13`, `candidate-A.md:370`) + +5. **`_isLoopbackPeer(req)` already exists** in `server/hub.js:370-373` — checks `req.socket.remoteAddress` only. Used to gate `/_api/hub/bootstrap-token`. + +## §2 Intended Goal + +Should we proceed with the three Phase 2 adjustments I proposed: +(a) adding dual-direction loopback check (`localAddress` + `remoteAddress`) to `isLoopbackChatGPTCodex`, +(b) splitting 2.3 into sub-commits 2.3a/2.3b/2.3c, +(c) stating that the verifyUpstream flip is "just a flag"? + +## §3 Current Plan + +1. **2.1**: Flip `verifyUpstream` from warn→reject for requests without `X-Ccxray-Auth`. Add `isLoopbackChatGPTCodex(req)` helper checking `(a) req.socket.remoteAddress is loopback, (b) req.socket.localAddress is loopback, (c) Authorization is JWT-shaped, (d) chatgpt-account-id present`. The dual-loopback check prevents the reverse-proxy bypass. +2. **2.2**: Dashboard enforcement + ephemeral mode default. Straightforward. +3. **2.3a**: Create `hub.sock` Unix socket listener + framed IPC protocol (register/unregister/health/bootstrap-token) running parallel to existing HTTP. +4. **2.3b**: Client code prefers socket over HTTP when `hub.json` reports `sockPath`. +5. **2.3c**: HTTP `/_hub/*` routes return 410 Gone. + +## §4 Missing Directional Confirmations + +1. **[assumption]** `req.socket.localAddress` reliably reflects the actual bind address even when Node's HTTP server listens on `0.0.0.0`. (If it reflects the wildcard, the check is useless.) +2. **[risk]** The dual-loopback check (`localAddress` + `remoteAddress`) is the **exact same pattern** that OpenClaw (GHSA-xc7w-v5x6-cc87) found to be bypassable behind a reverse proxy. When a TLS terminator connects to ccxray over loopback, BOTH `localAddress` and `remoteAddress` are `127.0.0.1` — the attacker's traffic arrives looking fully loopback. +3. **[unknown]** Whether splitting 2.3 into 3 sub-commits is necessary vs the existing hub.js complexity. The file is 533 LOC — is it really that coupled? +4. **[assumption]** The verifyUpstream flip is "just a flag change" — but the ChatGPT-OAuth carve-out adds a new code path that needs its own tests and can introduce regressions. +5. **[risk]** `isLoopbackChatGPTCodex` creates a permanent authentication exemption that no amount of header checking can secure against a same-host reverse proxy. The spec explicitly documents remote deployment as supported. + +## §5 Evidence & Arguments + +1. **[OpenClaw GHSA-xc7w-v5x6-cc87](https://github.com/openclaw/openclaw/security/advisories/GHSA-xc7w-v5x6-cc87)** — Real-world CVE where loopback `remoteAddress` trust was bypassed by a same-host reverse proxy (Tailscale Serve, nginx). The fix was to **remove loopback-based auth bypass entirely** and always require the shared secret. → §4.2, §4.5 + +2. **[Node.js net documentation](https://nodejs.org/api/net.html)** — Confirms `socket.localAddress` reflects the specific interface the connection arrived on (not the wildcard). When server listens on `0.0.0.0` and client connects to `127.0.0.1`, localAddress is `127.0.0.1`. → §4.1 + +3. **[Express behind proxies](https://expressjs.com/en/guide/behind-proxies/)** — Documents that behind a reverse proxy, `req.socket.remoteAddress` shows the proxy's address (loopback for same-host proxies). This is the architectural reason loopback checks fail as security gates when proxied. → §4.2 + +4. **[Unix Domain Sockets in Node (Krun.Pro, Apr 2026)](https://medium.com/@krun_dev/unix-domain-sockets-in-node-749b3b7319e5)** — Confirms that UDS with filesystem permissions (`0600`) is the standard Node.js approach for same-machine IPC access control. No need for complex peer-UID detection. → §3.3-§3.5 + +5. **[Dissent: errata §1.3 already acknowledges the residual risk]** — The errata explicitly states the ChatGPT-OAuth carve-out has residual "cost amplification / log pollution" risk from other-UID local attackers, and calls the mitigation primitive "bind ccxray to a Unix socket." This means the spec designers already accepted that loopback-check is imperfect — but they accepted it as a Phase 1 tradeoff, not a permanent architecture. The dual-loopback "improvement" I proposed doesn't actually improve the security property; it just adds code. → §4.5, §3.1 + +## §6 Second Opinion + +### Round 1 — Reviewer verdict: OBJECT + +| ID | Severity | Category | Summary | +|----|----------|----------|---------| +| O1 | high | conceptual | Dual-loopback check is security theater; OpenClaw CVE is direct evidence. Behind a same-host reverse proxy both addresses are 127.0.0.1. Either require secret on every request or use Unix sockets. | +| O2 | medium | conceptual | "Just a flag" understates the ChatGPT-OAuth carve-out work — a permanent exemption with 4 conjunctive conditions needs its own test matrix. | +| O3 | low | implementation | Splitting 2.3 into 3 sub-commits for 533 LOC is unnecessary ceremony. | + +### Claude's response: + +- **O1 (conceptual): ACCEPT → REFRAME.** The dual-loopback check I proposed adds zero real security. The OpenClaw CVE proves the pattern is broken. The errata already identified the real fix (Unix socket). My proposal was adding code without adding defense. + +- **O2 (conceptual): ACCEPT → REFRAME.** The ChatGPT-OAuth carve-out is not a flag flip — it's a new authentication exemption that needs comprehensive testing. Calling it "just a flag" would lead to under-scoping. + +- **O3 (implementation): ACCEPT → AMEND.** 2.3 will be a single commit. + +### Terminal state + +Two conceptual objections accepted → loop terminates immediately. + +**REFRAME** + +### What needs to change before Phase 2 proceeds: + +1. **Drop the dual-loopback proposal entirely.** `isLoopbackChatGPTCodex` should NOT use `localAddress`/`remoteAddress` as security gates. The ChatGPT-OAuth carve-out in 2.1 must be redesigned: + - Option A: Accept the ChatGPT-OAuth path is unauthenticated-by-design until Unix socket binding (2.3) closes the multi-UID hole. Keep `classifyUpstreamAuth` returning `chatgpt-oauth` and exempt it from 401, but document it as an accepted residual risk, not a security check. + - Option B: Reorder Phase 2 so 2.3 (Unix socket) lands BEFORE 2.1 (enforcement). Once ccxray binds to a Unix socket, there is no multi-UID attack surface and the ChatGPT-OAuth carve-out doesn't need IP checks at all. + +2. **Scope the ChatGPT-OAuth carve-out as a real feature with its own test plan**, not as a footnote on the verifyUpstream flip. Write the test matrix before writing the code. + +3. **2.3 is a single commit**, not three. diff --git a/reason/260525-0055-ccxray-auth-design/errata.md b/reason/260525-0055-ccxray-auth-design/errata.md index e5e0101..78fa9b4 100644 --- a/reason/260525-0055-ccxray-auth-design/errata.md +++ b/reason/260525-0055-ccxray-auth-design/errata.md @@ -139,3 +139,24 @@ This is a credential-leak surface in Codex itself, not in ccxray. It is unrelate | Docs | `ccxray_session` (in `overview.md`) | Standardize on `ccxray_s` | No commit is added or removed. The total surface remains 8 commits across Phases 1–3. + +--- + +## 5. Phase 2 reorder (depandabot audit, 2026-05-26) + +**Trigger:** depandabot audit (`docs/depandabot/2026-05-26-phase-2-auth-adjustments.md`) found that the proposed `isLoopbackChatGPTCodex` dual-loopback check (`localAddress` + `remoteAddress`) replicates a known-broken pattern (OpenClaw GHSA-xc7w-v5x6-cc87). Behind a same-host reverse proxy both addresses are `127.0.0.1` — the check adds code without adding security. + +**Decision:** Reorder Phase 2 so Unix socket hub IPC (originally 2.3) lands **before** upstream enforcement (originally 2.1). Once ccxray binds to a Unix socket, the multi-UID attack surface is closed by filesystem permissions and the ChatGPT-OAuth carve-out doesn't need IP-based checks at all. + +**Revised Phase 2 order:** + +| New # | Old # | Content | +|-------|-------|---------| +| 2.1 | 2.3 | Unix socket hub IPC + HTTP `/_hub/*` → 410 (single commit, not split) | +| 2.2 | 2.1 | Upstream domain enforcement — `verifyUpstream` flips from warn to reject. ChatGPT-OAuth carve-out scoped as a standalone feature with its own test matrix, not a footnote. No loopback IP checks. | +| 2.3 | 2.2 | Dashboard enforcement + ephemeral mode default | + +**What was dropped:** +- Dual-direction loopback check (`localAddress` + `remoteAddress`) in `isLoopbackChatGPTCodex` — security theater per OpenClaw CVE precedent. +- Splitting the Unix socket commit into 3 sub-commits — unnecessary for 533 LOC. +- Framing the enforce flip as "just a flag" — the ChatGPT-OAuth carve-out is a real auth exemption needing proper test coverage.