mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-06 08:13:49 +00:00
fix(bar): use nonce-bound probe auth (#1623)
This commit is contained in:
1 parent
138ead0e1e
commit
0180b62453
5 files changed
+133
-66
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user