diff --git a/src/commands/bar/bar-server-probe.ts b/src/commands/bar/bar-server-probe.ts index 036d85f0..a8f02b17 100644 --- a/src/commands/bar/bar-server-probe.ts +++ b/src/commands/bar/bar-server-probe.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; export interface DashboardInfo { port: number; @@ -62,10 +63,20 @@ export function resolveBarPort(ccsDir: string): number | null { * Each probe speaks raw HTTP/1.1 over a socket and resolves on the status line, * which lets discovery distinguish a live-but-auth-protected server (401/403) * from a healthy one (200) without depending on a higher-level HTTP client. + * + * Token authentication: the probe does NOT send the token in the request. + * The real CCS Bar server reads the token from the 0600 file and includes it + * unconditionally in the x-ccs-bar-token response header. The probe then checks + * that the echoed value matches the locally-read token. A rogue loopback process + * that has not read the 0600 file cannot produce the correct value, so a 200 + * without a matching token header is rejected. Sending the token in the request + * would defeat this — any process could echo what it received. */ export async function defaultFindRunningServer(ccsDir: string): Promise { ensureLoopbackNoProxy(); + const token = getOrCreateBarAuthToken(ccsDir); + async function probe(url: string): Promise<{ ok: boolean; authRequired: boolean }> { const net = await import('net'); const parsed = new URL(url); @@ -73,32 +84,64 @@ export async function defaultFindRunningServer(ccsDir: string): Promise { - let buffer = ''; + let rawResponse = ''; let settled = false; - const finish = (statusCode = 0) => { + const finish = (statusCode = 0, headerSection = '') => { if (settled) return; settled = true; - // Tear down the socket the moment the status line is known. The summary + // Tear down the socket the moment we have enough to decide. The summary // endpoint only needs the status code for liveness, so a non-CCS // loopback service that streams forever cannot block discovery from // returning a higher-priority hit. socket.destroy(); const authRequired = statusCode === 401 || statusCode === 403; - resolve({ ok: statusCode === 200 || authRequired, authRequired }); + if (authRequired) { + resolve({ ok: true, authRequired: true }); + return; + } + if (statusCode === 200) { + // Accept only when the server includes the correct token in the + // response without having received it in the request. Only the real + // CCS Bar process (which owns the 0600 file) can produce this value. + const echoMatch = headerSection.match( + new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i') + ); + const echoedToken = echoMatch ? echoMatch[1].trim() : ''; + resolve({ ok: echoedToken === token, authRequired: false }); + return; + } + resolve({ ok: false, authRequired: false }); }; const socket = net.connect({ host, port }, () => { + // Do NOT include the token in the request — sending the secret to the + // party being authenticated lets any reflector trivially pass the check. socket.write( `GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nConnection: close\r\n\r\n` ); }); socket.setTimeout(1500, () => finish()); socket.on('data', (chunk) => { - buffer += chunk.toString('utf8'); - const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); - if (match) finish(Number(match[1])); + rawResponse += chunk.toString('utf8'); + const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); + if (statusMatch) { + const code = Number(statusMatch[1]); + // For non-200 we can finish on the status line alone. + if (code !== 200) { + finish(code, rawResponse); + return; + } + // For 200 we need the headers section to extract the token. + if (rawResponse.includes('\r\n\r\n')) { + finish(code, rawResponse.split('\r\n\r\n')[0]); + } + } }); socket.on('error', () => finish()); - socket.on('end', () => finish()); + socket.on('end', () => { + const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); + if (statusMatch) finish(Number(statusMatch[1]), rawResponse); + else finish(); + }); }); } diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index a8b12556..783fe6c2 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -21,6 +21,7 @@ import * as os from 'os'; import * as path from 'path'; import type { ChildProcess } from 'child_process'; import { getCcsDir } from '../../config/config-loader-facade'; +import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; import { getBarDir, getBarJsonPath, @@ -139,24 +140,35 @@ function isAuthRequiredStatus(statusCode: number): boolean { async function defaultWaitForServerLive(baseUrl: string): Promise { const net = await import('net'); + const token = getOrCreateBarAuthToken(); const INTERVAL_MS = 250; const TIMEOUT_MS = 10_000; const deadline = Date.now() + TIMEOUT_MS; - async function probe(): Promise { + async function probe(): Promise<{ statusCode: number | null; tokenMatched: boolean }> { const url = new URL(`${baseUrl}/api/bar/summary`); return new Promise((resolve) => { - let buffer = ''; + let rawResponse = ''; let settled = false; - const finish = (statusCode: number | null = null) => { + const finish = (statusCode: number | null = null, headerSection = '') => { if (settled) return; settled = true; socket.destroy(); - resolve(statusCode); + if (statusCode === 200) { + const echoMatch = headerSection.match( + new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i') + ); + const echoedToken = echoMatch ? echoMatch[1].trim() : ''; + resolve({ statusCode, tokenMatched: echoedToken === token }); + return; + } + resolve({ statusCode, tokenMatched: false }); }; const socket = net.connect( { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) }, () => { + // Do NOT include the token in the request — sending the secret to the + // party being authenticated lets any reflector trivially pass the check. socket.write( `GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n` ); @@ -164,19 +176,32 @@ async function defaultWaitForServerLive(baseUrl: string): Promise { ); socket.setTimeout(1500, () => finish()); socket.on('data', (chunk) => { - buffer += chunk.toString('utf8'); - const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); - if (match) finish(Number(match[1])); + rawResponse += chunk.toString('utf8'); + const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); + if (statusMatch) { + const code = Number(statusMatch[1]); + if (code !== 200) { + finish(code, rawResponse); + return; + } + if (rawResponse.includes('\r\n\r\n')) { + finish(code, rawResponse.split('\r\n\r\n')[0]); + } + } }); socket.on('error', () => finish()); - socket.on('end', () => finish()); + socket.on('end', () => { + const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); + if (statusMatch) finish(Number(statusMatch[1]), rawResponse); + else finish(); + }); }); } while (Date.now() < deadline) { - const statusCode = await probe(); + const { statusCode, tokenMatched } = await probe(); - if (statusCode === 200) return; + if (statusCode === 200 && tokenMatched) return; if (statusCode !== null && isAuthRequiredStatus(statusCode)) { throw new BarServerAuthRequiredError(baseUrl, statusCode); } diff --git a/src/utils/bar-auth-token.ts b/src/utils/bar-auth-token.ts new file mode 100644 index 00000000..bd8da5ca --- /dev/null +++ b/src/utils/bar-auth-token.ts @@ -0,0 +1,33 @@ +import crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../config/config-loader-facade'; + +export const BAR_AUTH_TOKEN_HEADER = 'x-ccs-bar-token'; +const TOKEN_BYTE_LENGTH = 32; + +export function getBarAuthTokenPath(ccsDir = getCcsDir()): string { + return path.join(ccsDir, 'bar', '.auth-token'); +} + +function isValidToken(token: string): boolean { + return /^[a-f0-9]{64}$/i.test(token); +} + +export function getOrCreateBarAuthToken(ccsDir = getCcsDir()): string { + const tokenPath = getBarAuthTokenPath(ccsDir); + + try { + const token = fs.readFileSync(tokenPath, 'utf8').trim(); + if (isValidToken(token)) { + return token; + } + } catch { + // Missing or unreadable tokens are regenerated below. + } + + const token = crypto.randomBytes(TOKEN_BYTE_LENGTH).toString('hex'); + fs.mkdirSync(path.dirname(tokenPath), { recursive: true }); + fs.writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 }); + return token; +} diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 41e99b5e..79d39161 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -7,6 +7,7 @@ import { Router } from 'express'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; // Import domain routers import profileRoutes from './profile-routes'; @@ -68,6 +69,11 @@ apiRoutes.use((req, res, next) => { // Exact segment match so a future sibling like '/barbaz' isn't accidentally gated. if (req.path === '/bar' || req.path.startsWith('/bar/')) { if (requireLocalAccessWhenAuthDisabled(req, res, BAR_LOCAL_ACCESS_ERROR)) { + // Echo the token unconditionally so the probe can verify it without + // having sent the secret in the request. Only the real CCS Bar process + // (which owns the 0600 file) can produce this value — a rogue loopback + // process that hasn't read the file cannot replicate it. + res.setHeader(BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken()); next(); } return; diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index 1451a617..e7e03f37 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../../src/utils/bar-auth-token'; // --------------------------------------------------------------------------- // Helpers @@ -1745,9 +1746,18 @@ describe('defaultFindRunningServer (GH-1500)', () => { it('detects a real HTTP server responding 200 on /api/bar/summary', async () => { const http = await import('http'); - // Start an ephemeral server that responds 200 to /api/bar/summary. + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + // Start an ephemeral server that responds 200 to /api/bar/summary with the shared token. + // The server echoes the token unconditionally (reading it from the file), mirroring + // production behavior: only a process that owns the 0600 file can produce the value. const server = http.createServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); + const token = getOrCreateBarAuthToken(ccsDir); + res.writeHead(200, { + 'Content-Type': 'application/json', + [BAR_AUTH_TOKEN_HEADER]: token, + }); res.end('{}'); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); @@ -1755,8 +1765,6 @@ describe('defaultFindRunningServer (GH-1500)', () => { const livePort = addr.port; // Seed bar.json with the live port so it is checked first. - const ccsDir = path.join(tempHome, '.ccs'); - fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( path.join(ccsDir, 'bar.json'), JSON.stringify({ @@ -1788,6 +1796,51 @@ describe('defaultFindRunningServer (GH-1500)', () => { expect(result?.baseUrl).toBe(`http://127.0.0.1:${livePort}`); }); + it('rejects a spoofed 200 response without the CCS Bar auth token', async () => { + const http = await import('http'); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"not":"ccs"}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const livePort = (server.address() as { port: number }).port; + + fs.writeFileSync( + path.join(ccsDir, 'bar.json'), + JSON.stringify({ + port: livePort, + baseUrl: `http://127.0.0.1:${livePort}`, + authMode: 'loopback', + }) + ); + + moduleSeq++; + const mod = await import( + `../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}` + ); + const { defaultFindRunningServer } = mod as { + defaultFindRunningServer: ( + ccsDir: string + ) => Promise<{ port: number; baseUrl: string } | null>; + }; + + let result: { port: number; baseUrl: string } | null = null; + try { + result = await defaultFindRunningServer(ccsDir); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + + if (result !== null) { + expect(result.port).not.toBe(livePort); + } else { + expect(result).toBeNull(); + } + }); + it('returns null when no server is listening on the seeded port (port outside default candidates)', async () => { const net = await import('net'); @@ -1883,11 +1936,43 @@ describe('defaultFindRunningServer (GH-1500)', () => { return; } + // Some runners route bracketed IPv6 HTTP through an environment proxy even + // though raw ::1 sockets work. Skip in that environment; the production + // probe still supports ::1 where direct loopback HTTP is available. + const { request } = await import('undici'); + const httpProbe = http.createServer((_req, res) => { + res.writeHead(204); + res.end(); + }); + await new Promise((resolve) => httpProbe.listen(0, '::1', resolve)); + const httpProbePort = (httpProbe.address() as { port: number }).port; + try { + const { statusCode, body } = await request(`http://[::1]:${httpProbePort}/`, { + headersTimeout: 1500, + bodyTimeout: 1500, + }); + await body.text(); + if (statusCode !== 204) { + console.log('[i] Skipping IPv6 loopback test: ::1 HTTP is not direct on this runner'); + return; + } + } finally { + await new Promise((resolve) => httpProbe.close(() => resolve())); + } + + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + // Start an ephemeral HTTP server bound exclusively to ::1. // This simulates `ccs config` starting the web-server with host 'localhost' // on macOS, where 'localhost' resolves to ::1. + // The server echoes the token unconditionally (from the file), mirroring production. const server = http.createServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); + const token = getOrCreateBarAuthToken(ccsDir); + res.writeHead(200, { + 'Content-Type': 'application/json', + [BAR_AUTH_TOKEN_HEADER]: token, + }); res.end('{}'); }); await new Promise((resolve) => server.listen(0, '::1', resolve)); @@ -1895,8 +1980,6 @@ describe('defaultFindRunningServer (GH-1500)', () => { const livePort = addr.port; // Seed bar.json with the live port so it is checked first. - const ccsDir = path.join(tempHome, '.ccs'); - fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( path.join(ccsDir, 'bar.json'), JSON.stringify({ port: livePort, baseUrl: `http://[::1]:${livePort}`, authMode: 'loopback' }) @@ -1935,9 +2018,17 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', () it('returns bar.json port even when a lower-priority port responds faster', async () => { const http = await import('http'); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + // Lower-priority server (default port candidate): responds immediately with 200. + // Echoes token unconditionally (from file), mirroring production behavior. const fastServer = http.createServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); + const token = getOrCreateBarAuthToken(ccsDir); + res.writeHead(200, { + 'Content-Type': 'application/json', + [BAR_AUTH_TOKEN_HEADER]: token, + }); res.end('{}'); }); await new Promise((resolve) => fastServer.listen(0, '127.0.0.1', resolve)); @@ -1946,8 +2037,12 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', () // Higher-priority server (bar.json port): adds ~300 ms artificial delay, // but still responds 200 within the 1500 ms timeout. const slowServer = http.createServer((_req, res) => { + const token = getOrCreateBarAuthToken(ccsDir); setTimeout(() => { - res.writeHead(200, { 'Content-Type': 'application/json' }); + res.writeHead(200, { + 'Content-Type': 'application/json', + [BAR_AUTH_TOKEN_HEADER]: token, + }); res.end('{}'); }, 300); }); @@ -1955,8 +2050,6 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', () const slowPort = (slowServer.address() as { port: number }).port; // Seed bar.json with the slower/higher-priority port. - const ccsDir = path.join(tempHome, '.ccs'); - fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( path.join(ccsDir, 'bar.json'), JSON.stringify({ @@ -2942,9 +3035,19 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { // The probe speaks raw HTTP/1.1 over a `net` socket and resolves on the // status line, so mock `net.connect` rather than a higher-level client. - // The high-priority port (41235) answers HTTP 200 immediately; the + // The high-priority port (41235) answers HTTP 200 with the auth token in the + // response headers (mirroring the real server, which reads from the 0600 file + // and includes it unconditionally — NOT echoed from the request); the // lower-priority port (3000) connects but never sends a status line, // emulating a non-CCS service that streams forever. + // + // The token file is written by getOrCreateBarAuthToken when ccsDir is set up + // in beforeEach, so we read it here the same way the mock server would. + const { getOrCreateBarAuthToken: getToken } = await import( + `../../../src/utils/bar-auth-token?test=${Date.now()}-mock` + ); + const expectedToken = getToken(ccsDir); + mock.module('net', () => ({ connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => { // net.connect is called synchronously for every probe target, so the @@ -2974,8 +3077,12 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { onConnect(); if (opts.port === 41235) { const data = listeners.data ?? []; + // The mock server includes the token unconditionally in the response + // (read from the 0600 file, not echoed from the request) — this is + // exactly what the production CCS Bar server does, and is the property + // that prevents a rogue reflector from passing the check. for (const cb of data) { - cb(Buffer.from('HTTP/1.1 200 OK\r\n\r\n', 'utf8')); + cb(Buffer.from(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${expectedToken}\r\n\r\n`, 'utf8')); } } // Port 3000 never emits a status line: simulate an endlessly