fix(bar): use nonce-bound probe auth (#1623)

This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-30 13:10:56 -04:00
1 parent 138ead0e1e
commit 0180b62453
5 files changed
+133 -66

No files matched your search

+18 -15
View File
@@ -8,7 +8,13 @@
import * as fs from 'fs';
import * as path from 'path';
import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token';
import {
BAR_AUTH_NONCE_HEADER,
BAR_AUTH_TOKEN_HEADER,
createBarAuthNonce,
isMatchingBarAuthProof,
getOrCreateBarAuthToken,
} from '../../utils/bar-auth-token';
const PROBE_TIMEOUT_MS = 1500;
const MAX_PROBE_RESPONSE_BYTES = 8192;
@@ -49,12 +55,10 @@ export function resolveBarPort(ccsDir: string): number | null {
* 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.
* Instead, it sends a fresh nonce. The real CCS Bar server reads the token from
* the 0600 file and returns HMAC(token, nonce) in the x-ccs-bar-token response
* header. The probe verifies the nonce-bound proof, so a captured proof cannot
* be replayed for a future probe.
*/
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
const token = getOrCreateBarAuthToken(ccsDir);
@@ -64,6 +68,7 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
const parsed = new URL(url);
const port = Number(parsed.port);
const host = parsed.hostname.replace(/^\[|\]$/g, '');
const nonce = createBarAuthNonce();
return new Promise((resolve) => {
let rawResponse = '';
@@ -85,23 +90,21 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
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.
// Accept only when the server includes a correct nonce-bound proof.
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 });
const proof = echoMatch ? echoMatch[1].trim() : '';
resolve({ ok: isMatchingBarAuthProof(token, nonce, proof), 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.
// Do NOT include the token in the request; only send a fresh nonce so
// the server can prove it knows the token without disclosing it.
socket.write(
`GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nConnection: close\r\n\r\n`
`GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\n${BAR_AUTH_NONCE_HEADER}: ${nonce}\r\nConnection: close\r\n\r\n`
);
});
socket.setTimeout(PROBE_TIMEOUT_MS, () => finish());
+13 -6
View File
@@ -21,7 +21,13 @@ 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 {
BAR_AUTH_NONCE_HEADER,
BAR_AUTH_TOKEN_HEADER,
createBarAuthNonce,
isMatchingBarAuthProof,
getOrCreateBarAuthToken,
} from '../../utils/bar-auth-token';
import { getBarDir, getBarJsonPath, getLaunchJsonPath, getServeLogPath } from './bar-paths';
import type { LaunchJson } from './bar-paths';
import { createBarLaunchDescriptor } from './launch-descriptor';
@@ -152,6 +158,7 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
async function probe(): Promise<{ statusCode: number | null; tokenMatched: boolean }> {
const url = new URL(`${baseUrl}/api/bar/summary`);
const nonce = createBarAuthNonce();
return new Promise((resolve) => {
let rawResponse = '';
let settled = false;
@@ -166,8 +173,8 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
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 });
const proof = echoMatch ? echoMatch[1].trim() : '';
resolve({ statusCode, tokenMatched: isMatchingBarAuthProof(token, nonce, proof) });
return;
}
resolve({ statusCode, tokenMatched: false });
@@ -175,10 +182,10 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
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.
// Do NOT include the token in the request; only send a fresh nonce so
// the server can prove it knows the token without disclosing it.
socket.write(
`GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n`
`GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\n${BAR_AUTH_NONCE_HEADER}: ${nonce}\r\nConnection: close\r\n\r\n`
);
}
);
+22
View File
@@ -4,7 +4,29 @@ import * as path from 'path';
import { getCcsDir } from '../config/config-loader-facade';
export const BAR_AUTH_TOKEN_HEADER = 'x-ccs-bar-token';
export const BAR_AUTH_NONCE_HEADER = 'x-ccs-bar-nonce';
const TOKEN_BYTE_LENGTH = 32;
const NONCE_MIN_LENGTH = 16;
export function createBarAuthNonce(): string {
return crypto.randomBytes(TOKEN_BYTE_LENGTH).toString('hex');
}
export function isValidBarAuthNonce(nonce: string): boolean {
return /^[a-f0-9]+$/i.test(nonce) && nonce.length >= NONCE_MIN_LENGTH && nonce.length <= 128;
}
export function createBarAuthProof(token: string, nonce: string): string {
return crypto.createHmac('sha256', token).update(nonce).digest('hex');
}
export function isMatchingBarAuthProof(token: string, nonce: string, proof: string): boolean {
if (!isValidBarAuthNonce(nonce) || !/^[a-f0-9]{64}$/i.test(proof)) {
return false;
}
const expected = createBarAuthProof(token, nonce);
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(proof, 'hex'));
}
export function getBarAuthTokenPath(ccsDir = getCcsDir()): string {
return path.join(ccsDir, 'bar', '.auth-token');
+14 -6
View File
@@ -7,7 +7,13 @@
import { Router } from 'express';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token';
import {
BAR_AUTH_NONCE_HEADER,
BAR_AUTH_TOKEN_HEADER,
createBarAuthProof,
getOrCreateBarAuthToken,
isValidBarAuthNonce,
} from '../../utils/bar-auth-token';
// Import domain routers
import profileRoutes from './profile-routes';
@@ -69,11 +75,13 @@ 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());
// Authenticate liveness probes with a nonce-bound HMAC so normal Bar
// responses never disclose the persistent file token, and captured probe
// proofs cannot be replayed for a future probe.
const nonce = req.header(BAR_AUTH_NONCE_HEADER)?.trim() ?? '';
if (isValidBarAuthNonce(nonce)) {
res.setHeader(BAR_AUTH_TOKEN_HEADER, createBarAuthProof(getOrCreateBarAuthToken(), nonce));
}
next();
}
return;
+66 -39
View File
@@ -13,7 +13,12 @@ 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';
import {
BAR_AUTH_NONCE_HEADER,
BAR_AUTH_TOKEN_HEADER,
createBarAuthProof,
getOrCreateBarAuthToken,
} from '../../../src/utils/bar-auth-token';
// ---------------------------------------------------------------------------
// Helpers
@@ -1752,14 +1757,12 @@ describe('defaultFindRunningServer (GH-1500)', () => {
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) => {
const server = http.createServer((req, res) => {
const token = getOrCreateBarAuthToken(ccsDir);
const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? '');
res.writeHead(200, {
'Content-Type': 'application/json',
[BAR_AUTH_TOKEN_HEADER]: token,
[BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce),
});
res.end('{}');
});
@@ -1970,11 +1973,12 @@ describe('defaultFindRunningServer (GH-1500)', () => {
// 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) => {
const server = http.createServer((req, res) => {
const token = getOrCreateBarAuthToken(ccsDir);
const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? '');
res.writeHead(200, {
'Content-Type': 'application/json',
[BAR_AUTH_TOKEN_HEADER]: token,
[BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce),
});
res.end('{}');
});
@@ -2026,11 +2030,12 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', ()
// Lower-priority server (default port candidate): responds immediately with 200.
// Echoes token unconditionally (from file), mirroring production behavior.
const fastServer = http.createServer((_req, res) => {
const fastServer = http.createServer((req, res) => {
const token = getOrCreateBarAuthToken(ccsDir);
const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? '');
res.writeHead(200, {
'Content-Type': 'application/json',
[BAR_AUTH_TOKEN_HEADER]: token,
[BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce),
});
res.end('{}');
});
@@ -2039,12 +2044,13 @@ 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 slowServer = http.createServer((req, res) => {
const token = getOrCreateBarAuthToken(ccsDir);
const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? '');
setTimeout(() => {
res.writeHead(200, {
'Content-Type': 'application/json',
[BAR_AUTH_TOKEN_HEADER]: token,
[BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce),
});
res.end('{}');
}, 300);
@@ -3152,7 +3158,17 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
setTimeout(_ms: number, _cb: () => void) {
return socket;
},
write() {
write(data: string) {
const nonce =
data.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? '';
for (const cb of listeners.data ?? []) {
cb(
Buffer.from(
`HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(token, nonce)}\r\n\r\n`,
'utf8'
)
);
}
return true;
},
destroy() {
@@ -3161,9 +3177,6 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
};
setImmediate(() => {
onConnect();
for (const cb of listeners.data ?? []) {
cb(Buffer.from(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${token}\r\n\r\n`, 'utf8'));
}
});
return socket;
},
@@ -3205,7 +3218,7 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected
// fully synchronous and immune to OS socket state. The invariant is
// behaviour-coupled: removing the token check causes both tests to fail.
function buildNetMock(responseHeaders: string) {
function buildNetMock(responseHeaders: string | ((request: string) => string)) {
// Returns a `net` mock whose connect() immediately delivers the response,
// then emits 'end'.
return {
@@ -3219,7 +3232,12 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected
setTimeout(_ms: number, _cb: () => void) {
return socket;
},
write() {
write(data: string) {
const response =
typeof responseHeaders === 'function' ? responseHeaders(data) : responseHeaders;
for (const cb of listeners.data ?? []) {
cb(Buffer.from(response, 'utf8'));
}
return true;
},
destroy() {
@@ -3228,9 +3246,6 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected
};
setImmediate(() => {
onConnect();
for (const cb of listeners.data ?? []) {
cb(Buffer.from(responseHeaders, 'utf8'));
}
for (const cb of listeners.end ?? []) {
cb();
}
@@ -3294,11 +3309,15 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected
const { getOrCreateBarAuthToken: getToken } = await import(
`../../../src/utils/bar-auth-token?test=${Date.now()}-legit-net`
);
const realToken = getToken(ccsDir);
const realToken = getToken();
// Mock net: every probe gets 200 with the CORRECT token.
// Mock net: every probe gets 200 with the CORRECT nonce-bound proof.
mock.module('net', () =>
buildNetMock(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${realToken}\r\n\r\n`)
buildNetMock((request) => {
const nonce =
request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? '';
return `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(realToken, nonce)}\r\n\r\n`;
})
);
moduleSeq++;
@@ -3363,7 +3382,19 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
setTimeout() {
return socket;
},
write() {
write(data: string) {
if (opts.port === 41235) {
const nonce =
data.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? '';
for (const cb of listeners.data ?? []) {
cb(
Buffer.from(
`HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(expectedToken, nonce)}\r\n\r\n`,
'utf8'
)
);
}
}
return true;
},
destroy() {
@@ -3375,18 +3406,6 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
// Fire the connect callback asynchronously, mirroring net.connect.
setImmediate(() => {
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\nx-ccs-bar-token: ${expectedToken}\r\n\r\n`, 'utf8')
);
}
}
// Port 3000 never emits a status line: simulate an endlessly
// streaming service that must not block the higher-priority hit.
// Any other port stays silent and is settled by the 1.5s timeout,
@@ -3439,6 +3458,7 @@ describe('bar raw socket probes: absolute deadline for malformed streaming peers
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
let interval: ReturnType<typeof setInterval> | undefined;
let request = '';
const socket = {
on(event: string, cb: (arg?: unknown) => void) {
(listeners[event] ??= []).push(cb);
@@ -3447,7 +3467,8 @@ describe('bar raw socket probes: absolute deadline for malformed streaming peers
setTimeout() {
return socket;
},
write() {
write(data: string) {
request = data;
return true;
},
destroy() {
@@ -3466,9 +3487,15 @@ describe('bar raw socket probes: absolute deadline for malformed streaming peers
return;
}
if (opts.port === 3000) {
const nonce =
request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ??
'';
for (const cb of listeners.data ?? []) {
cb(
Buffer.from(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${expectedToken}\r\n\r\n`, 'utf8')
Buffer.from(
`HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(expectedToken, nonce)}\r\n\r\n`,
'utf8'
)
);
}
}