diff --git a/packages/webhook-triggers/src/ingress-routes.ts b/packages/webhook-triggers/src/ingress-routes.ts index c89f57f03..81a2a7290 100644 --- a/packages/webhook-triggers/src/ingress-routes.ts +++ b/packages/webhook-triggers/src/ingress-routes.ts @@ -3,10 +3,11 @@ // off a workflow run. This is THE trust boundary — no session cookie, // no tenant membership, nothing about the caller is trusted except // what the HMAC signature over the raw body proves. Every failure -// mode here is loud and specific in the log, but deliberately generic -// (`invalid signature`, `not found`) in the HTTP response, so a probe -// against a wrong triggerId cannot distinguish "no such trigger" from -// "wrong secret" from an unlisted host. +// mode here is loud and specific in the log, but every one of unknown +// trigger, disabled trigger, and bad/missing signature returns the +// SAME generic 401 `unauthorized` response, so a probe against a +// wrong triggerId cannot distinguish "no such trigger" from "disabled" +// from "wrong secret". import { Hono } from "hono"; import type { Env } from "hono"; import { getLogger } from "@intx/log"; @@ -22,6 +23,9 @@ const ErrorEnvelope = (code: string, message: string) => ({ error: { code, message }, }); +const unauthorizedResponse = () => + ErrorEnvelope("unauthorized", "invalid or missing signature"); + export type CreateWebhookIngressRoutesDeps = { store: WebhookTriggerStore; /** @@ -56,14 +60,14 @@ export function createWebhookIngressRoutes( log.info("Webhook delivery for unknown trigger {triggerId}", { triggerId, }); - return c.json(ErrorEnvelope("not_found", "trigger not found"), 404); + return c.json(unauthorizedResponse(), 401); } if (!trigger.enabled) { log.info("Webhook delivery for disabled trigger {triggerId}", { triggerId, }); - return c.json(ErrorEnvelope("forbidden", "trigger is disabled"), 403); + return c.json(unauthorizedResponse(), 401); } const rawBody = await c.req.text(); @@ -75,10 +79,7 @@ export function createWebhookIngressRoutes( triggerId, }, ); - return c.json( - ErrorEnvelope("unauthorized", "invalid or missing signature"), - 401, - ); + return c.json(unauthorizedResponse(), 401); } let payload: unknown; diff --git a/packages/webhook-triggers/test/ingress-routes.test.ts b/packages/webhook-triggers/test/ingress-routes.test.ts index 7a80cc935..f89e7e96f 100644 --- a/packages/webhook-triggers/test/ingress-routes.test.ts +++ b/packages/webhook-triggers/test/ingress-routes.test.ts @@ -2,7 +2,9 @@ // verification (valid/invalid/missing), unknown/disabled-trigger // handling, and payload parsing — with a fake `launch` seam so no // database or folded-run launch machinery is involved. This is the -// trust-boundary route: no session, no tenant middleware. +// trust-boundary route: no session, no tenant middleware. Unknown +// trigger, disabled trigger, and bad signature must all come back as +// the same generic 401 so a probe can't tell them apart. import { describe, expect, test } from "bun:test"; import { createWebhookIngressRoutes } from "../src/ingress-routes"; import { signPayload, WEBHOOK_SIGNATURE_HEADER } from "../src/signature"; @@ -65,7 +67,11 @@ describe("POST /:triggerId", () => { expect(trigger?.lastFiredAt).not.toBeNull(); }); - test("rejects a missing signature header", async () => { + const expectedUnauthorizedBody = { + error: { code: "unauthorized", message: "invalid or missing signature" }, + }; + + test("rejects a missing signature header with the generic unauthorized response", async () => { const { app, store } = buildApp(); await seedTrigger(store); @@ -76,9 +82,10 @@ describe("POST /:triggerId", () => { }); expect(response.status).toBe(401); + expect(await response.json()).toEqual(expectedUnauthorizedBody); }); - test("rejects a signature computed with the wrong secret", async () => { + test("rejects a signature computed with the wrong secret with the generic unauthorized response", async () => { const { app, store } = buildApp(); await seedTrigger(store); @@ -93,18 +100,20 @@ describe("POST /:triggerId", () => { }); expect(response.status).toBe(401); + expect(await response.json()).toEqual(expectedUnauthorizedBody); }); - test("404s for an unknown trigger id without leaking whether it ever existed", async () => { + test("responds to an unknown trigger id with the same generic unauthorized response, not a 404", async () => { const { app } = buildApp(); const response = await app.request("/no-such-trigger", { method: "POST", body: "{}", }); - expect(response.status).toBe(404); + expect(response.status).toBe(401); + expect(await response.json()).toEqual(expectedUnauthorizedBody); }); - test("403s for a disabled trigger even with a valid signature", async () => { + test("responds to a disabled trigger with the same generic unauthorized response even with a valid signature", async () => { const { app, store } = buildApp(); await seedTrigger(store, { enabled: false }); @@ -115,7 +124,8 @@ describe("POST /:triggerId", () => { body, }); - expect(response.status).toBe(403); + expect(response.status).toBe(401); + expect(await response.json()).toEqual(expectedUnauthorizedBody); }); test("400s on a validly signed but non-JSON body", async () => {