mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 16:15:13 +00:00
fix(bar): authenticate reused local server probes (#1546)
Closes a spoofed-loopback-200 bar.json poisoning vector with a per-user 0600 capability token. The real bar server echoes its file-derived token on /bar/* responses; the launch probe verifies the echoed token against the locally-read file (fail-closed) before reusing a server, so a rogue loopback process that cannot read the token file is rejected.
This commit is contained in:
1 parent
2c812efafa
commit
f25a1015ed
5 files changed
+245
-31
No files matched your search
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token';
|
||||||
|
|
||||||
export interface DashboardInfo {
|
export interface DashboardInfo {
|
||||||
port: number;
|
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,
|
* 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)
|
* 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.
|
* 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<DashboardInfo | null> {
|
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
|
||||||
ensureLoopbackNoProxy();
|
ensureLoopbackNoProxy();
|
||||||
|
|
||||||
|
const token = getOrCreateBarAuthToken(ccsDir);
|
||||||
|
|
||||||
async function probe(url: string): Promise<{ ok: boolean; authRequired: boolean }> {
|
async function probe(url: string): Promise<{ ok: boolean; authRequired: boolean }> {
|
||||||
const net = await import('net');
|
const net = await import('net');
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
@@ -73,32 +84,64 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
|
|||||||
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let buffer = '';
|
let rawResponse = '';
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (statusCode = 0) => {
|
const finish = (statusCode = 0, headerSection = '') => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
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
|
// endpoint only needs the status code for liveness, so a non-CCS
|
||||||
// loopback service that streams forever cannot block discovery from
|
// loopback service that streams forever cannot block discovery from
|
||||||
// returning a higher-priority hit.
|
// returning a higher-priority hit.
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
const authRequired = statusCode === 401 || statusCode === 403;
|
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 }, () => {
|
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(
|
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\nConnection: close\r\n\r\n`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
socket.setTimeout(1500, () => finish());
|
socket.setTimeout(1500, () => finish());
|
||||||
socket.on('data', (chunk) => {
|
socket.on('data', (chunk) => {
|
||||||
buffer += chunk.toString('utf8');
|
rawResponse += chunk.toString('utf8');
|
||||||
const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||||
if (match) finish(Number(match[1]));
|
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('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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { ChildProcess } from 'child_process';
|
import type { ChildProcess } from 'child_process';
|
||||||
import { getCcsDir } from '../../config/config-loader-facade';
|
import { getCcsDir } from '../../config/config-loader-facade';
|
||||||
|
import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token';
|
||||||
import {
|
import {
|
||||||
getBarDir,
|
getBarDir,
|
||||||
getBarJsonPath,
|
getBarJsonPath,
|
||||||
@@ -139,24 +140,35 @@ function isAuthRequiredStatus(statusCode: number): boolean {
|
|||||||
|
|
||||||
async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
||||||
const net = await import('net');
|
const net = await import('net');
|
||||||
|
const token = getOrCreateBarAuthToken();
|
||||||
const INTERVAL_MS = 250;
|
const INTERVAL_MS = 250;
|
||||||
const TIMEOUT_MS = 10_000;
|
const TIMEOUT_MS = 10_000;
|
||||||
const deadline = Date.now() + TIMEOUT_MS;
|
const deadline = Date.now() + TIMEOUT_MS;
|
||||||
|
|
||||||
async function probe(): Promise<number | null> {
|
async function probe(): Promise<{ statusCode: number | null; tokenMatched: boolean }> {
|
||||||
const url = new URL(`${baseUrl}/api/bar/summary`);
|
const url = new URL(`${baseUrl}/api/bar/summary`);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let buffer = '';
|
let rawResponse = '';
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (statusCode: number | null = null) => {
|
const finish = (statusCode: number | null = null, headerSection = '') => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
socket.destroy();
|
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(
|
const socket = net.connect(
|
||||||
{ host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) },
|
{ 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(
|
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\nConnection: close\r\n\r\n`
|
||||||
);
|
);
|
||||||
@@ -164,19 +176,32 @@ async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
socket.setTimeout(1500, () => finish());
|
socket.setTimeout(1500, () => finish());
|
||||||
socket.on('data', (chunk) => {
|
socket.on('data', (chunk) => {
|
||||||
buffer += chunk.toString('utf8');
|
rawResponse += chunk.toString('utf8');
|
||||||
const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||||
if (match) finish(Number(match[1]));
|
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('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) {
|
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)) {
|
if (statusCode !== null && isAuthRequiredStatus(statusCode)) {
|
||||||
throw new BarServerAuthRequiredError(baseUrl, statusCode);
|
throw new BarServerAuthRequiredError(baseUrl, statusCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||||
|
import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token';
|
||||||
|
|
||||||
// Import domain routers
|
// Import domain routers
|
||||||
import profileRoutes from './profile-routes';
|
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.
|
// Exact segment match so a future sibling like '/barbaz' isn't accidentally gated.
|
||||||
if (req.path === '/bar' || req.path.startsWith('/bar/')) {
|
if (req.path === '/bar' || req.path.startsWith('/bar/')) {
|
||||||
if (requireLocalAccessWhenAuthDisabled(req, res, BAR_LOCAL_ACCESS_ERROR)) {
|
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();
|
next();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../../src/utils/bar-auth-token';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -1745,9 +1746,18 @@ describe('defaultFindRunningServer (GH-1500)', () => {
|
|||||||
it('detects a real HTTP server responding 200 on /api/bar/summary', async () => {
|
it('detects a real HTTP server responding 200 on /api/bar/summary', async () => {
|
||||||
const http = await import('http');
|
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) => {
|
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('{}');
|
res.end('{}');
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
@@ -1755,8 +1765,6 @@ describe('defaultFindRunningServer (GH-1500)', () => {
|
|||||||
const livePort = addr.port;
|
const livePort = addr.port;
|
||||||
|
|
||||||
// Seed bar.json with the live port so it is checked first.
|
// 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(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'bar.json'),
|
path.join(ccsDir, 'bar.json'),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -1788,6 +1796,51 @@ describe('defaultFindRunningServer (GH-1500)', () => {
|
|||||||
expect(result?.baseUrl).toBe(`http://127.0.0.1:${livePort}`);
|
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<void>((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<void>((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 () => {
|
it('returns null when no server is listening on the seeded port (port outside default candidates)', async () => {
|
||||||
const net = await import('net');
|
const net = await import('net');
|
||||||
|
|
||||||
@@ -1883,11 +1936,43 @@ describe('defaultFindRunningServer (GH-1500)', () => {
|
|||||||
return;
|
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<void>((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<void>((resolve) => httpProbe.close(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
// Start an ephemeral HTTP server bound exclusively to ::1.
|
// Start an ephemeral HTTP server bound exclusively to ::1.
|
||||||
// This simulates `ccs config` starting the web-server with host 'localhost'
|
// This simulates `ccs config` starting the web-server with host 'localhost'
|
||||||
// on macOS, where 'localhost' resolves to ::1.
|
// 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) => {
|
||||||
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('{}');
|
res.end('{}');
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => server.listen(0, '::1', resolve));
|
await new Promise<void>((resolve) => server.listen(0, '::1', resolve));
|
||||||
@@ -1895,8 +1980,6 @@ describe('defaultFindRunningServer (GH-1500)', () => {
|
|||||||
const livePort = addr.port;
|
const livePort = addr.port;
|
||||||
|
|
||||||
// Seed bar.json with the live port so it is checked first.
|
// 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(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'bar.json'),
|
path.join(ccsDir, 'bar.json'),
|
||||||
JSON.stringify({ port: livePort, baseUrl: `http://[::1]:${livePort}`, authMode: 'loopback' })
|
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 () => {
|
it('returns bar.json port even when a lower-priority port responds faster', async () => {
|
||||||
const http = await import('http');
|
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.
|
// 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) => {
|
||||||
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('{}');
|
res.end('{}');
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => fastServer.listen(0, '127.0.0.1', resolve));
|
await new Promise<void>((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,
|
// Higher-priority server (bar.json port): adds ~300 ms artificial delay,
|
||||||
// but still responds 200 within the 1500 ms timeout.
|
// 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);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
[BAR_AUTH_TOKEN_HEADER]: token,
|
||||||
|
});
|
||||||
res.end('{}');
|
res.end('{}');
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
@@ -1955,8 +2050,6 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', ()
|
|||||||
const slowPort = (slowServer.address() as { port: number }).port;
|
const slowPort = (slowServer.address() as { port: number }).port;
|
||||||
|
|
||||||
// Seed bar.json with the slower/higher-priority port.
|
// Seed bar.json with the slower/higher-priority port.
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'bar.json'),
|
path.join(ccsDir, 'bar.json'),
|
||||||
JSON.stringify({
|
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
|
// 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.
|
// 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,
|
// lower-priority port (3000) connects but never sends a status line,
|
||||||
// emulating a non-CCS service that streams forever.
|
// 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', () => ({
|
mock.module('net', () => ({
|
||||||
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
||||||
// net.connect is called synchronously for every probe target, so the
|
// net.connect is called synchronously for every probe target, so the
|
||||||
@@ -2974,8 +3077,12 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
|
|||||||
onConnect();
|
onConnect();
|
||||||
if (opts.port === 41235) {
|
if (opts.port === 41235) {
|
||||||
const data = listeners.data ?? [];
|
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) {
|
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
|
// Port 3000 never emits a status line: simulate an endlessly
|
||||||
|
|||||||
Reference in new issue
Block a user