From fa66e49fb98ab5a13a332a6e9aa062056833d702 Mon Sep 17 00:00:00 2001 From: memosr Date: Mon, 17 Aug 2026 05:45:49 +0300 Subject: [PATCH] fix(harness): compare the /ingest bearer token in constant time Every other boot-token check uses timingSafeEqualString: createBootTokenMiddleware guards the whole /api surface, and the WS upgrade path guards events-ws. Its doc comment states the reason, and it even balances the length-mismatch branch so that case does not resolve faster. POST /ingest was the one path left on a plain !== against the same secret. The server binds to 127.0.0.1, but that is precisely the threat model here: the harness runs on a machine where a coding agent executes arbitrary commands, so a local process that can reach /ingest but does not hold the token could recover it byte by byte and then use it against all of /api. Use the existing helper. Also cover the missing-header case, which was untested. --- .changeset/tough-guests-drive.md | 5 +++++ packages/harness/src/server/ingest.test.ts | 6 ++++++ packages/harness/src/server/ingest.ts | 3 ++- 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .changeset/tough-guests-drive.md diff --git a/.changeset/tough-guests-drive.md b/.changeset/tough-guests-drive.md new file mode 100644 index 000000000..9489feeb1 --- /dev/null +++ b/.changeset/tough-guests-drive.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Compare the /ingest bearer token in constant time, matching the /api and WS upgrade paths. diff --git a/packages/harness/src/server/ingest.test.ts b/packages/harness/src/server/ingest.test.ts index f3314b49b..3c15e62c5 100644 --- a/packages/harness/src/server/ingest.test.ts +++ b/packages/harness/src/server/ingest.test.ts @@ -88,6 +88,12 @@ describe("createIngestRouter", () => { expect(stored).toHaveLength(0); }); + it("rejects requests with no Authorization header at all", async () => { + const res = await postIngest(baseUrl, { hookEvent: "SessionStart" }, ""); + expect(res.status).toBe(401); + expect(stored).toHaveLength(0); + }); + it("responds 200 immediately and processes asynchronously", async () => { const res = await postIngest(baseUrl, { hookEvent: "UserPromptSubmit", diff --git a/packages/harness/src/server/ingest.ts b/packages/harness/src/server/ingest.ts index 4e82e1f6a..104130cc9 100644 --- a/packages/harness/src/server/ingest.ts +++ b/packages/harness/src/server/ingest.ts @@ -13,6 +13,7 @@ import express, { type Router } from "express"; import type { AnalyticsEvent, HarnessKind } from "../shared/types.js"; import type { NormalizeContext } from "../core/collector/normalizer.js"; import { createSeqCounter, type SeqCounter } from "../core/collector/seq.js"; +import { timingSafeEqualString } from "./auth.js"; export interface IngestSessionContext { harness: HarnessKind; @@ -162,7 +163,7 @@ export function createIngestRouter(deps: IngestDeps): Router { router.post("/ingest", (req, res) => { const token = bearerToken(req.headers.authorization); - if (token !== deps.ingestToken) { + if (!timingSafeEqualString(token ?? "", deps.ingestToken)) { res.status(401).json({ ok: false }); return; }