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
26 changes: 26 additions & 0 deletions apps/server/src/scheduledTasks/webhookRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,32 @@ describe("webhook route", () => {
}
});

it("refuses HEAD probes without triggering the hook", async () => {
let calls = 0;
const { handler, dispose } = handlerFor(() => {
calls += 1;
return Effect.succeed({
_tag: "accepted",
deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"),
outcome: "accepted",
});
});
try {
const response = await handler(
new Request("http://env.local/api/hooks/id/tok", { method: "HEAD" }),
);
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET, POST, PUT, PATCH");
expect(response.headers.get("x-t3-hook-outcome")).toBe("method_not_allowed");
expect(calls).toBe(0);
const get = await handler(new Request("http://env.local/api/hooks/id/tok"));
expect(get.status).toBe(202);
expect(calls).toBe(1);
} finally {
await dispose();
}
});

it("hides service failures and defects behind a fixed 500", async () => {
const failures = [
Effect.fail(new ScheduledTaskError({ message: "database locked" })),
Expand Down
18 changes: 16 additions & 2 deletions apps/server/src/scheduledTasks/webhookRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ export const WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
// The relay records this outcome header.
const WEBHOOK_OUTCOME_HEADER = "x-t3-hook-outcome";

const json = (status: number, body: Record<string, string>, outcome: string) =>
HttpServerResponse.jsonUnsafe(body, { status, headers: { [WEBHOOK_OUTCOME_HEADER]: outcome } });
const json = (
status: number,
body: Record<string, string>,
outcome: string,
headers?: Record<string, string>,
) =>
HttpServerResponse.jsonUnsafe(body, {
status,
headers: { ...headers, [WEBHOOK_OUTCOME_HEADER]: outcome },
});

export const layer = HttpApiBuilder.group(
EnvironmentHttpApi,
Expand All @@ -41,6 +49,12 @@ export const layer = HttpApiBuilder.group(
readonly params: { readonly hookId: string; readonly token: string };
readonly request: HttpServerRequest.HttpServerRequest;
}) {
// The router answers HEAD with the GET route; link checkers probe with HEAD.
if (request.method === "HEAD") {
return json(405, { error: "method_not_allowed" }, "method_not_allowed", {
allow: "GET, POST, PUT, PATCH",
});
}
const contentLength = Number(request.headers["content-length"] ?? "0");
if (!Number.isFinite(contentLength) || contentLength > WEBHOOK_MAX_BODY_BYTES) {
return json(413, { error: "body_too_large" }, "body_too_large");
Expand Down
Loading