Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions packages/webhook-triggers/src/ingress-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
/**
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down
24 changes: 17 additions & 7 deletions packages/webhook-triggers/test/ingress-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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 });

Expand All @@ -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 () => {
Expand Down
Loading