From 0a107af014f56a3d85869ca92ef01294292a4988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 15 May 2026 02:49:40 -0400 Subject: [PATCH] fix(security): omit agent token fingerprints --- src/main/agent-auth.ts | 37 +++++++++++++++++++++--------- src/main/api-server/server-core.ts | 25 +++++++++++++++++--- tests/unit/agent-auth.test.ts | 37 ++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/src/main/agent-auth.ts b/src/main/agent-auth.ts index 4bf5b7d9..333e50a8 100644 --- a/src/main/agent-auth.ts +++ b/src/main/agent-auth.ts @@ -8,14 +8,30 @@ const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours const SIGNING_KEY_BYTES = 32; // Once-per-process warning when a malformed token shape is seen. Helps -// diagnose schema breaks after upgrades — without leaking the full token. +// diagnose schema breaks after upgrades. let loggedMalformedTokenWarning = false; -function warnMalformedTokenOnce(token: string, reason: string) { + +interface TokenValidationLogContext { + runId?: string | null; + companyId?: string | null; + requestId?: string | null; +} + +function safeAuthLogField(value: string | null | undefined): string { + return typeof value === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(value) ? value : "unknown"; +} + +function warnMalformedTokenOnce(reason: string, context: TokenValidationLogContext | undefined) { if (loggedMalformedTokenWarning) return; loggedMalformedTokenWarning = true; - const fingerprint = token.slice(0, 8); - console.warn(`[agent-auth] token shape invalid (${reason}); first8="${fingerprint}..."`); + console.warn( + "[agent-auth] token shape invalid: " + + `reason=${safeAuthLogField(reason)} ` + + `runId=${safeAuthLogField(context?.runId)} ` + + `companyId=${safeAuthLogField(context?.companyId)} ` + + `requestId=${safeAuthLogField(context?.requestId)}`, + ); } interface TokenPayload { @@ -36,7 +52,7 @@ function sign(payload: TokenPayload): string { return `${header}.${body}.${signature}`; } -function verify(token: string): TokenPayload | null { +function verify(token: string, context?: TokenValidationLogContext): TokenPayload | null { const key = getSigningKey(); const parts = token.split("."); if (parts.length !== 3) return null; @@ -56,9 +72,8 @@ function verify(token: string): TokenPayload | null { const payload = JSON.parse(Buffer.from(body, "base64url").toString()) as TokenPayload; if (Date.now() > payload.exp) return null; return payload; - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - warnMalformedTokenOnce(token, reason); + } catch { + warnMalformedTokenOnce("payload_json_parse_failed", context); return null; } } @@ -97,8 +112,8 @@ export function createAgentToken(agentId: string, companyId: string, runId: stri return sign({ agentId, companyId, runId, iat: now, exp: now + TOKEN_TTL_MS }); } -export function validateAgentToken(token: string): TokenPayload | null { - return verify(token); +export function validateAgentToken(token: string, context?: TokenValidationLogContext): TokenPayload | null { + return verify(token, context); } -export type { TokenPayload }; +export type { TokenPayload, TokenValidationLogContext }; diff --git a/src/main/api-server/server-core.ts b/src/main/api-server/server-core.ts index c966b568..fe4584ab 100644 --- a/src/main/api-server/server-core.ts +++ b/src/main/api-server/server-core.ts @@ -37,6 +37,21 @@ function getJsonContentTypeError(req: IncomingMessage): string | null { return null; } +function firstHeaderValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) return value[0] ?? null; + return value ?? null; +} + +function extractCompanyIdFromUrl(rawUrl: string | undefined): string | null { + try { + const url = new URL(rawUrl ?? "/", "http://127.0.0.1"); + const match = /^\/api\/companies\/([^/]+)/.exec(url.pathname); + return match ? decodeURIComponent(match[1]) : null; + } catch { + return null; + } +} + /** * Extract a required route parameter or throw a 400-level error. * The HTTP router guarantees all named params are present when a route @@ -230,12 +245,16 @@ export class AgentApiServer { if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) { return { ok: false, status: 401, error: "Missing or invalid Authorization header" }; } - const token = validateAgentToken(authHeader.slice(7)); + const runHeader = req.headers["x-agent-company-run-id"]; + const claimedRunId = firstHeaderValue(runHeader); + const token = validateAgentToken(authHeader.slice(7), { + runId: claimedRunId, + companyId: extractCompanyIdFromUrl(req.url), + requestId: firstHeaderValue(req.headers["x-request-id"]), + }); if (!token) { return { ok: false, status: 401, error: "Invalid or expired token" }; } - const runHeader = req.headers["x-agent-company-run-id"]; - const claimedRunId = Array.isArray(runHeader) ? runHeader[0] : runHeader; if (typeof claimedRunId !== "string" || claimedRunId.trim() !== token.runId) { return { ok: false, status: 401, error: "Missing or mismatched X-Agent-Company-Run-Id header" }; } diff --git a/tests/unit/agent-auth.test.ts b/tests/unit/agent-auth.test.ts index c186fe97..238c24a8 100644 --- a/tests/unit/agent-auth.test.ts +++ b/tests/unit/agent-auth.test.ts @@ -1,7 +1,8 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createHmac } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createAgentToken, initializeAgentTokenSigningKey, @@ -18,9 +19,20 @@ async function createTempDir() { } afterEach(async () => { + vi.restoreAllMocks(); await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); +async function createSignedRawPayloadToken(profileDir: string, rawPayload: string): Promise { + const key = await readFile(join(profileDir, "agent-api-signing.key")); + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(rawPayload).toString("base64url"); + const signature = createHmac("sha256", key) + .update(`${header}.${body}`) + .digest("base64url"); + return `${header}.${body}.${signature}`; +} + describe("agent token signing key", () => { it("keeps existing run tokens valid across profile reinitialization", async () => { const profileDir = await createTempDir(); @@ -42,4 +54,25 @@ describe("agent token signing key", () => { expect(() => initializeAgentTokenSigningKey(profileDir)).toThrow(FatalError); }); + + it("logs malformed signed token context without token fingerprints", async () => { + const profileDir = await createTempDir(); + initializeAgentTokenSigningKey(profileDir); + const rawPayload = "{malformed-token-body"; + const token = await createSignedRawPayloadToken(profileDir, rawPayload); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(validateAgentToken(token, { + runId: "run-1", + companyId: "company-1", + requestId: "req-1", + })).toBeNull(); + + expect(warn).toHaveBeenCalledWith( + "[agent-auth] token shape invalid: reason=payload_json_parse_failed runId=run-1 companyId=company-1 requestId=req-1", + ); + const logLine = String(warn.mock.calls[0]?.[0] ?? ""); + expect(logLine).not.toContain(rawPayload); + expect(logLine).not.toContain(token.slice(0, 8)); + }); });