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/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/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. diff --git a/server/auth.js b/server/auth.js index a93c77e..749bb09 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,28 +1,395 @@ '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; +} + +// ─── 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); + } +} + +// ─── 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; + 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; 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 +398,25 @@ function authMiddleware(req, res) { return false; } -module.exports = { authMiddleware, AUTH_TOKEN }; +module.exports = { + // Phase 1.1 additions + deriveSecrets, + getRootSecret, + signCookie, + verifyCookie, + compareSecret, + // Phase 1.2 additions + 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. + authMiddleware, + AUTH_TOKEN, +}; 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 6313059..07a9a6d 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'); @@ -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/ ──────────────────────────────── @@ -127,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 || '') @@ -136,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; @@ -188,8 +193,14 @@ 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 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; // ── Static files (HTML, CSS, JS) ── if (serveStatic(clientReq.url, clientRes)) return; @@ -480,6 +491,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/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/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/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-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; + }); +}); 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-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); + }); +}); 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); + } + }); +}); 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); + }); +}); 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 }); + } + }); + }); +}); 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'); + }); + }); +});