fix(mosaic): close two CWE-345 identity bypasses in claudex preflight (re-review #3, #790)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Addresses PR #793 re-review #3 (F1/F3 already confirmed closed). Both fixes TDD red-first on this branch (7 tests red against the prior head, then green). F2a (BLOCKER) — verifyListenerIdentity no longer accepts a listener by BASENAME. The old code ran `if (basename(exePath) === CLAUDEX_PROXY_BINARY) return 'ok'` unconditionally, so a same-uid process running `/tmp/claude-code-proxy` (right name, wrong path) was trusted. On a shared-uid host that is the squatter vector, not hypothetical — uid is NOT a trust boundary there, the executable path is. Now: when the expected proxy path resolves we require an EXACT canonical-path match (both sides canonicalized via realpath for symlinks); there is NO basename fallback. If the expected path can't be resolved, or either path can't be canonicalized, we fail closed (`unknown`). New tests: wrong-path/right-basename → wrong-exe; unresolved expected → unknown; uncanonicalizable → unknown; and a symlink-resolution case that canonically matches → ok. F2b (BLOCKER) — runProxyPreflight now verifies listener identity too. Previously `ok` was `bin.present && auth==='valid' && live` where `live` is /healthz-2xx only, so a squatter answering 2xx yielded `ok:true` and any consumer of the report (the phase-2 launch path) would route Claude traffic to it. Added `verifyListener` to PreflightDeps + a `listenerVerdict` field; `ok` now requires a trusted verdict; a live-but-unverified responder emits a non-sensitive problem (port + verdict only — no listener command line or token). Identity is not checked when the port is dead (no listener to trust). New tests: each non-ok verdict fails preflight and leaks no token material; dead proxy skips verify. Gates green: typecheck, lint, format:check; full mosaic suite 1115 passing; claudex-proxy.ts coverage 90.57% stmts/lines, 89.06% branch (>= 85%). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -376,13 +376,17 @@ describe('installSystemdUnit', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('runProxyPreflight', () => {
|
describe('runProxyPreflight', () => {
|
||||||
it('is ok when binary present, auth valid, and proxy live', async () => {
|
const trustedListener = () => 'ok' as const;
|
||||||
|
|
||||||
|
it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => {
|
||||||
const report = await runProxyPreflight({
|
const report = await runProxyPreflight({
|
||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
checkAuth: () => ({ state: 'valid' }),
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
probe: async () => true,
|
probe: async () => true,
|
||||||
|
verifyListener: trustedListener,
|
||||||
});
|
});
|
||||||
expect(report.ok).toBe(true);
|
expect(report.ok).toBe(true);
|
||||||
|
expect(report.listenerVerdict).toBe('ok');
|
||||||
expect(report.problems).toEqual([]);
|
expect(report.problems).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -391,6 +395,7 @@ describe('runProxyPreflight', () => {
|
|||||||
checkBinary: () => ({ present: false, path: null }),
|
checkBinary: () => ({ present: false, path: null }),
|
||||||
checkAuth: () => ({ state: 'valid' }),
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
probe: async () => true,
|
probe: async () => true,
|
||||||
|
verifyListener: trustedListener,
|
||||||
});
|
});
|
||||||
expect(report.ok).toBe(false);
|
expect(report.ok).toBe(false);
|
||||||
expect(report.problems.some((p) => /binary/i.test(p))).toBe(true);
|
expect(report.problems.some((p) => /binary/i.test(p))).toBe(true);
|
||||||
@@ -401,6 +406,7 @@ describe('runProxyPreflight', () => {
|
|||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
checkAuth: () => ({ state: 'expired' }),
|
checkAuth: () => ({ state: 'expired' }),
|
||||||
probe: async () => true,
|
probe: async () => true,
|
||||||
|
verifyListener: trustedListener,
|
||||||
});
|
});
|
||||||
expect(report.ok).toBe(false);
|
expect(report.ok).toBe(false);
|
||||||
expect(report.needsReauth).toBe(true);
|
expect(report.needsReauth).toBe(true);
|
||||||
@@ -412,6 +418,7 @@ describe('runProxyPreflight', () => {
|
|||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
checkAuth: () => ({ state: 'valid' }),
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
probe: async () => false,
|
probe: async () => false,
|
||||||
|
verifyListener: trustedListener,
|
||||||
});
|
});
|
||||||
expect(report.ok).toBe(false);
|
expect(report.ok).toBe(false);
|
||||||
expect(report.live).toBe(false);
|
expect(report.live).toBe(false);
|
||||||
@@ -422,17 +429,51 @@ describe('runProxyPreflight', () => {
|
|||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
checkAuth: () => ({ state: 'unknown' }),
|
checkAuth: () => ({ state: 'unknown' }),
|
||||||
probe: async () => true,
|
probe: async () => true,
|
||||||
|
verifyListener: trustedListener,
|
||||||
});
|
});
|
||||||
expect(report.ok).toBe(false);
|
expect(report.ok).toBe(false);
|
||||||
expect(report.needsReauth).toBe(false);
|
expect(report.needsReauth).toBe(false);
|
||||||
expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true);
|
expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does NOT pass preflight when the live responder fails identity verification (F2b)', async () => {
|
||||||
|
// A squatter answering /healthz-2xx must not yield ok:true just because the
|
||||||
|
// binary is installed and OAuth is valid — the identity gate holds here too.
|
||||||
|
for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) {
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
|
probe: async () => true,
|
||||||
|
verifyListener: () => verdict,
|
||||||
|
});
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
expect(report.live).toBe(true);
|
||||||
|
expect(report.listenerVerdict).toBe(verdict);
|
||||||
|
expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true);
|
||||||
|
// The identity problem is non-sensitive: port + verdict only, no token.
|
||||||
|
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => {
|
||||||
|
const verifyListener = vi.fn(() => 'ok' as const);
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
|
probe: async () => false,
|
||||||
|
verifyListener,
|
||||||
|
});
|
||||||
|
expect(verifyListener).not.toHaveBeenCalled();
|
||||||
|
expect(report.listenerVerdict).toBe('unknown');
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not leak token material for any auth state', async () => {
|
it('does not leak token material for any auth state', async () => {
|
||||||
const report = await runProxyPreflight({
|
const report = await runProxyPreflight({
|
||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
checkAuth: () => ({ state: 'expired' }),
|
checkAuth: () => ({ state: 'expired' }),
|
||||||
probe: async () => false,
|
probe: async () => false,
|
||||||
|
verifyListener: trustedListener,
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
||||||
});
|
});
|
||||||
@@ -537,23 +578,68 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE
|
|||||||
uid: 1000,
|
uid: 1000,
|
||||||
exePath: '/home/me/.local/bin/claude-code-proxy',
|
exePath: '/home/me/.local/bin/claude-code-proxy',
|
||||||
};
|
};
|
||||||
|
// Identity canonicalize for tests: fake paths don't exist on disk, so we map
|
||||||
|
// each path to itself and exercise symlink resolution explicitly where needed.
|
||||||
|
const idc = (p: string) => p;
|
||||||
|
|
||||||
it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => {
|
it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => {
|
||||||
const verdict = verifyListenerIdentity({
|
const verdict = verifyListenerIdentity({
|
||||||
identify: () => me,
|
identify: () => me,
|
||||||
currentUid: () => 1000,
|
currentUid: () => 1000,
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
});
|
});
|
||||||
expect(verdict).toBe('ok');
|
expect(verdict).toBe('ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts by basename when the expected path is unknown but the exe is claude-code-proxy', () => {
|
it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => {
|
||||||
|
// The listener exe and our resolved binary reach the same real file via
|
||||||
|
// different symlink paths — a canonical comparison must accept it.
|
||||||
|
const canon: Record<string, string> = {
|
||||||
|
'/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy',
|
||||||
|
'/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy',
|
||||||
|
};
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => ({ ...me, exePath: '/var/run/proxy.link' }),
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: (p) => canon[p] ?? null,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => {
|
||||||
|
// The squatter vector on a shared-uid host: right basename, wrong path. The
|
||||||
|
// basename must NEVER be a trust signal when an expected exact path resolved.
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }),
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('wrong-exe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => {
|
||||||
|
// No expected path → we cannot assert identity → refuse to trust (no basename
|
||||||
|
// acceptance). Previously this returned `ok` by basename; that was a bypass.
|
||||||
const verdict = verifyListenerIdentity({
|
const verdict = verifyListenerIdentity({
|
||||||
identify: () => me,
|
identify: () => me,
|
||||||
currentUid: () => 1000,
|
currentUid: () => 1000,
|
||||||
expectedExe: () => null,
|
expectedExe: () => null,
|
||||||
|
canonicalize: idc,
|
||||||
});
|
});
|
||||||
expect(verdict).toBe('ok');
|
expect(verdict).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => {
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => me,
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: () => null, // e.g. binary deleted out from under the listener
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => {
|
it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => {
|
||||||
@@ -561,6 +647,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE
|
|||||||
identify: () => ({ ...me, uid: 0 }),
|
identify: () => ({ ...me, uid: 0 }),
|
||||||
currentUid: () => 1000,
|
currentUid: () => 1000,
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
});
|
});
|
||||||
expect(verdict).toBe('foreign-user');
|
expect(verdict).toBe('foreign-user');
|
||||||
});
|
});
|
||||||
@@ -570,6 +657,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE
|
|||||||
identify: () => ({ ...me, exePath: '/usr/bin/nc' }),
|
identify: () => ({ ...me, exePath: '/usr/bin/nc' }),
|
||||||
currentUid: () => 1000,
|
currentUid: () => 1000,
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
});
|
});
|
||||||
expect(verdict).toBe('wrong-exe');
|
expect(verdict).toBe('wrong-exe');
|
||||||
});
|
});
|
||||||
@@ -579,6 +667,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE
|
|||||||
identify: () => null,
|
identify: () => null,
|
||||||
currentUid: () => 1000,
|
currentUid: () => 1000,
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
});
|
});
|
||||||
expect(verdict).toBe('unknown');
|
expect(verdict).toBe('unknown');
|
||||||
});
|
});
|
||||||
@@ -588,6 +677,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE
|
|||||||
identify: () => me,
|
identify: () => me,
|
||||||
currentUid: () => -1,
|
currentUid: () => -1,
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
});
|
});
|
||||||
expect(verdict).toBe('unknown');
|
expect(verdict).toBe('unknown');
|
||||||
});
|
});
|
||||||
@@ -597,6 +687,7 @@ describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE
|
|||||||
identify: () => ({ ...me, exePath: null }),
|
identify: () => ({ ...me, exePath: null }),
|
||||||
currentUid: () => 1000,
|
currentUid: () => 1000,
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
});
|
});
|
||||||
expect(verdict).toBe('unknown');
|
expect(verdict).toBe('unknown');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||||
import { mkdirSync, readFileSync, readlinkSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { basename, dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
// ─── Endpoint / command constants (spec table) ──────────────────────────────
|
// ─── Endpoint / command constants (spec table) ──────────────────────────────
|
||||||
|
|
||||||
@@ -256,6 +256,17 @@ export interface VerifyListenerDeps {
|
|||||||
currentUid?: () => number;
|
currentUid?: () => number;
|
||||||
/** The expected proxy executable path (null when it can't be resolved). */
|
/** The expected proxy executable path (null when it can't be resolved). */
|
||||||
expectedExe?: () => string | null;
|
expectedExe?: () => string | null;
|
||||||
|
/** Canonicalize a path (resolve symlinks); null when it can't be resolved. */
|
||||||
|
canonicalize?: (p: string) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a path through symlinks to its canonical form; null on any failure. */
|
||||||
|
function defaultCanonicalize(p: string): string | null {
|
||||||
|
try {
|
||||||
|
return realpathSync(p);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -292,16 +303,25 @@ function defaultIdentifyListener(port: number = CLAUDEX_PROXY_PORT): ListenerIde
|
|||||||
* Verify that the process owning :18765 is genuinely OUR proxy before trusting
|
* Verify that the process owning :18765 is genuinely OUR proxy before trusting
|
||||||
* it. The proxy binds loopback with NO client authentication, so on a shared
|
* it. The proxy binds loopback with NO client authentication, so on a shared
|
||||||
* host any local process could squat the port and a liveness 2xx alone does not
|
* host any local process could squat the port and a liveness 2xx alone does not
|
||||||
* prove identity (CWE-345). We check the OS-level identity of the listener — it
|
* prove identity (CWE-345). We FAIL CLOSED (`unknown`) whenever identity cannot
|
||||||
* must be owned by the current uid and be the expected proxy executable — and
|
* be established. This needs no upstream shared-secret/unix-socket support from
|
||||||
* FAIL CLOSED (`unknown`) whenever identity cannot be established. This needs no
|
* `claude-code-proxy`.
|
||||||
* upstream shared-secret/unix-socket support from `claude-code-proxy`.
|
*
|
||||||
|
* The executable path is the trust boundary that matters: on a shared-uid host
|
||||||
|
* (every agent session runs as the same operator) same-uid is NOT sufficient, so
|
||||||
|
* we require an EXACT canonical-path match against our resolved proxy binary and
|
||||||
|
* canonicalize both sides for symlinks. There is deliberately NO basename
|
||||||
|
* fallback — a same-uid process running `/tmp/claude-code-proxy` (right name,
|
||||||
|
* wrong path) must never be trusted. If our own binary path can't be resolved,
|
||||||
|
* or either path can't be canonicalized, we fail closed rather than downgrade to
|
||||||
|
* a weaker check.
|
||||||
*/
|
*/
|
||||||
export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict {
|
export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict {
|
||||||
const identify = deps.identify ?? (() => defaultIdentifyListener());
|
const identify = deps.identify ?? (() => defaultIdentifyListener());
|
||||||
const currentUid =
|
const currentUid =
|
||||||
deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1));
|
deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1));
|
||||||
const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path);
|
const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path);
|
||||||
|
const canonicalize = deps.canonicalize ?? defaultCanonicalize;
|
||||||
|
|
||||||
const id = identify();
|
const id = identify();
|
||||||
if (!id) return 'unknown'; // can't see the listener → don't trust it
|
if (!id) return 'unknown'; // can't see the listener → don't trust it
|
||||||
@@ -311,9 +331,11 @@ export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerV
|
|||||||
if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed
|
if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed
|
||||||
|
|
||||||
const expected = expectedExe();
|
const expected = expectedExe();
|
||||||
if (expected && id.exePath === expected) return 'ok';
|
if (!expected) return 'unknown'; // can't resolve our own binary → fail closed
|
||||||
if (basename(id.exePath) === CLAUDEX_PROXY_BINARY) return 'ok';
|
const expectedReal = canonicalize(expected);
|
||||||
return 'wrong-exe';
|
const actualReal = canonicalize(id.exePath);
|
||||||
|
if (!expectedReal || !actualReal) return 'unknown'; // uncanonicalizable → fail closed
|
||||||
|
return actualReal === expectedReal ? 'ok' : 'wrong-exe';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── systemd user unit ───────────────────────────────────────────────────────
|
// ─── systemd user unit ───────────────────────────────────────────────────────
|
||||||
@@ -421,6 +443,8 @@ export interface PreflightReport {
|
|||||||
binaryPath: string | null;
|
binaryPath: string | null;
|
||||||
auth: AuthStatus;
|
auth: AuthStatus;
|
||||||
live: boolean;
|
live: boolean;
|
||||||
|
/** OS-level identity verdict for the :18765 listener (`unknown` when dead). */
|
||||||
|
listenerVerdict: ListenerVerdict;
|
||||||
needsReauth: boolean;
|
needsReauth: boolean;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
problems: string[];
|
problems: string[];
|
||||||
@@ -430,20 +454,32 @@ export interface PreflightDeps {
|
|||||||
checkBinary?: () => { present: boolean; path: string | null };
|
checkBinary?: () => { present: boolean; path: string | null };
|
||||||
checkAuth?: () => AuthStatus;
|
checkAuth?: () => AuthStatus;
|
||||||
probe?: () => Promise<boolean>;
|
probe?: () => Promise<boolean>;
|
||||||
|
verifyListener?: () => ListenerVerdict;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose the three preflight checks into a single structured report. `ok` is
|
* Compose the preflight checks into a single structured report. `ok` is true
|
||||||
* true only when the binary is present, OAuth is valid, and the proxy responds.
|
* only when the binary is present, OAuth is valid, the proxy responds, AND the
|
||||||
|
* responding listener's OS-level identity verifies as our proxy.
|
||||||
|
*
|
||||||
|
* The identity gate lives here too, not only in {@link ensureProxyRunning}: any
|
||||||
|
* consumer of this report (notably the phase-2 launch path) would otherwise
|
||||||
|
* treat a `/healthz`-2xx squatter as healthy and route Claude traffic to it
|
||||||
|
* (CWE-345). A liveness 2xx is necessary but not sufficient — a live responder
|
||||||
|
* that fails identity fails the preflight.
|
||||||
*/
|
*/
|
||||||
export async function runProxyPreflight(deps: PreflightDeps = {}): Promise<PreflightReport> {
|
export async function runProxyPreflight(deps: PreflightDeps = {}): Promise<PreflightReport> {
|
||||||
const checkBinary = deps.checkBinary ?? (() => checkProxyBinary());
|
const checkBinary = deps.checkBinary ?? (() => checkProxyBinary());
|
||||||
const checkAuth = deps.checkAuth ?? (() => checkAuthStatus());
|
const checkAuth = deps.checkAuth ?? (() => checkAuthStatus());
|
||||||
const probe = deps.probe ?? (() => probeLiveness());
|
const probe = deps.probe ?? (() => probeLiveness());
|
||||||
|
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
|
||||||
|
|
||||||
const bin = checkBinary();
|
const bin = checkBinary();
|
||||||
const auth = checkAuth();
|
const auth = checkAuth();
|
||||||
const live = await probe();
|
const live = await probe();
|
||||||
|
// Only meaningful when something is actually responding; a dead port has no
|
||||||
|
// listener identity to establish.
|
||||||
|
const listenerVerdict: ListenerVerdict = live ? verifyListener() : 'unknown';
|
||||||
|
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
if (!bin.present) {
|
if (!bin.present) {
|
||||||
@@ -461,14 +497,21 @@ export async function runProxyPreflight(deps: PreflightDeps = {}): Promise<Prefl
|
|||||||
}
|
}
|
||||||
if (!live) {
|
if (!live) {
|
||||||
problems.push(`No proxy responding on ${CLAUDEX_PROXY_URL}.`);
|
problems.push(`No proxy responding on ${CLAUDEX_PROXY_URL}.`);
|
||||||
|
} else if (listenerVerdict !== 'ok') {
|
||||||
|
// Non-sensitive: names the port and the verdict only — never any listener
|
||||||
|
// command line, token, or other process detail.
|
||||||
|
problems.push(
|
||||||
|
`A process is listening on ${CLAUDEX_PROXY_URL} but its identity could not be verified as ${CLAUDEX_PROXY_BINARY} (${listenerVerdict}). Refusing to trust it.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = bin.present && auth.state === 'valid' && live;
|
const ok = bin.present && auth.state === 'valid' && live && listenerVerdict === 'ok';
|
||||||
return {
|
return {
|
||||||
binaryPresent: bin.present,
|
binaryPresent: bin.present,
|
||||||
binaryPath: bin.path,
|
binaryPath: bin.path,
|
||||||
auth,
|
auth,
|
||||||
live,
|
live,
|
||||||
|
listenerVerdict,
|
||||||
needsReauth,
|
needsReauth,
|
||||||
ok,
|
ok,
|
||||||
problems,
|
problems,
|
||||||
|
|||||||
Reference in New Issue
Block a user