Files
HealthLog/src/app/api/internal/deploy-webhook/route.ts
T
Marc-André Bombeck 20812b54f5 fix(webhooks): rate-limit every verb that checks the shared secret
The Withings entrypoints, the Telegram bot webhook and the Coolify deploy
hook limited POST and left the verification verbs unlimited. GET and HEAD
compare the same secret and answer 200 against 401, which is an oracle for
guessing it and a free load channel besides; timingSafeEqual protects the
timing channel, not the status code. The project's rule is that the
per-source limit runs before secret verification, and it was being applied
per handler rather than per file.

Each verification verb now runs the same limit its POST sibling used, on the
same bucket. Telegram and the deploy hook grow the small local helper the
Withings routes already share. HEAD re-shapes the limiter's JSON envelope
into a bodyless 429 and keeps the rate-limit headers.

The new guard walks every webhook route from the filesystem, resolves which
local helpers reach a comparison and which reach the limiter, and fails when
an exported verb reaches a comparison without the limiter in front of it. It
asserts a non-zero match count so an empty match set cannot read as green.
WHOOP already passed: it exports POST only.
2026-09-03 01:42:56 +02:00

321 lines
12 KiB
TypeScript

import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getClientIp, safeJson } from "@/lib/api-response";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { prisma } from "@/lib/db";
/**
* Deploy-status webhook (v1.4.15).
*
* Coolify (Settings → Notifications → Webhook) calls this endpoint after
* every deploy attempt. We verify the request via a shared secret in the
* `X-Deploy-Webhook-Secret` header (timing-safe compare against
* `process.env.DEPLOY_WEBHOOK_SECRET`), audit-log the event, and on
* failure page the admin via Telegram if the channel is configured.
*
* Why a *separate* webhook for status when Coolify already has a deploy
* trigger URL: the trigger is one-shot ("queue a deploy"); the status
* comes back asynchronously when the container is healthy / fails to
* start. Without this surface we lose visibility once the deploy is
* fired — exactly the gap that produced v1.4.6..v1.4.14's manual
* verification recipe (the maintainer had to SSH and check digests).
*
* Payload shape: Coolify sends a free-form JSON object whose exact
* fields drift between beta releases. We treat it as `Record<string,
* unknown>` and only extract a small set of well-known keys
* (`status`, `application_name`, `application_uuid`, `deployment_uuid`,
* `error`) defensively, falling back to "unknown" when missing. The
* payload itself is stored in the audit-log `details` field so a
* future Coolify upgrade that adds richer metadata becomes
* automatically available without code change.
*/
interface CoolifyDeployPayload {
status?: unknown;
application_name?: unknown;
application_uuid?: unknown;
deployment_uuid?: unknown;
error?: unknown;
[key: string]: unknown;
}
interface NormalizedEvent {
outcome: "success" | "failure" | "unknown";
applicationName: string;
applicationUuid: string | null;
deploymentUuid: string | null;
error: string | null;
raw: CoolifyDeployPayload;
}
function asStringOr<T extends string | null>(
value: unknown,
fallback: T,
): string | T {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function classifyOutcome(status: unknown): NormalizedEvent["outcome"] {
if (typeof status !== "string") return "unknown";
const s = status.toLowerCase();
// Coolify uses "success" / "finished" for happy path and
// "failed" / "error" / "stopped" for unhappy. Anything else is
// logged but doesn't trigger a Telegram page (avoids alert fatigue
// on intermediate states like "queued" or "in_progress").
if (s === "success" || s === "finished" || s === "succeeded")
return "success";
if (s === "failed" || s === "error" || s === "stopped" || s === "failure") {
return "failure";
}
return "unknown";
}
function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
return {
outcome: classifyOutcome(payload.status),
applicationName: asStringOr(payload.application_name, "unknown"),
applicationUuid: asStringOr(payload.application_uuid, null),
deploymentUuid: asStringOr(payload.deployment_uuid, null),
error: asStringOr(payload.error, null),
raw: payload,
};
}
/**
* Replay protection (v1.16.4). A captured webhook request carries a
* static shared secret, so without a freshness check it can be replayed
* indefinitely — re-paging admins with stale failure alerts and
* polluting the audit log with forged deploy outcomes.
*
* The sender attaches `X-Deploy-Webhook-Timestamp` (unix seconds or
* ISO-8601). Requests whose timestamp falls outside a ±5-minute window
* are rejected. Two modes:
*
* - Default (tolerant): a request WITHOUT the header passes — Coolify's
* stock notification sender does not attach one, and breaking the
* live integration would cost more than the residual replay window.
* A header that IS present but stale/invalid is always rejected, so
* a replayed capture of a timestamped request never lands.
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: the header becomes
* mandatory. For operators whose sender (or reverse proxy) can
* attach the timestamp; closes the replay window completely.
*
* See docs/ops/deploy.md ("Deploy-status webhook") for the operator
* guidance.
*/
const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000;
function parseTimestamp(raw: string): number | null {
// Unix seconds (10 digits covers 2001-2286) or milliseconds.
if (/^\d{10}$/.test(raw)) return Number(raw) * 1000;
if (/^\d{13}$/.test(raw)) return Number(raw);
const iso = Date.parse(raw);
return Number.isFinite(iso) ? iso : null;
}
function checkTimestampFreshness(
request: NextRequest,
now: number = Date.now(),
): { ok: boolean; reason?: string } {
const raw = request.headers.get("x-deploy-webhook-timestamp");
const required =
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1";
if (raw === null || raw === "") {
if (required) return { ok: false, reason: "timestamp_missing" };
return { ok: true };
}
const ts = parseTimestamp(raw);
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
return { ok: false, reason: "timestamp_outside_window" };
}
return { ok: true };
}
function hasValidSecret(request: NextRequest): boolean {
const expected = process.env.DEPLOY_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("DEPLOY_WEBHOOK_SECRET not configured");
return false;
}
const received = request.headers.get("x-deploy-webhook-secret");
if (!received) return false;
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Reuse the `maybeAlertAdmins()` pattern from
* `src/lib/integrations/status.ts` — fan out a SYSTEM_ALERT through the
* existing dispatcher to every user whose role is ADMIN. The dispatcher
* silently no-ops when no Telegram channel is configured, so this is
* safe even on a first-deploy where the maintainer hasn't wired the bot yet.
*/
async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
const admins = await prisma.user.findMany({
where: { role: "ADMIN" },
select: { id: true },
});
if (admins.length === 0) {
getEvent()?.addWarning(
"No admin user configured to alert about deploy failure",
);
return;
}
// v1.4.27 F21 — each admin sees the alert in their own User.locale.
// The body is composed inside `dispatchLocalisedNotification` from
// translation keys; the static "Error:" / "Deployment:" / "Logs:"
// labels are emitted as a single param-substituted body key so all
// six locale bundles can render them natively. The deployment-uuid
// and error lines fall back to an empty string when missing so the
// surrounding template still composes cleanly.
for (const admin of admins) {
await dispatchLocalisedNotification({
userId: admin.id,
titleKey: "notifications.admin.deployFailedTitle",
messageKey: "notifications.admin.deployFailedBody",
params: {
application: event.applicationName,
error: event.error ?? "",
deployment: event.deploymentUuid ?? "",
// Optional: operators can point this at their deployment
// dashboard (e.g. the Coolify UI). Falls back to an empty
// string so the body template still composes cleanly.
logsUrl: process.env.DEPLOY_LOGS_URL ?? "",
},
metadata: {
source: "deploy-webhook",
applicationName: event.applicationName,
applicationUuid: event.applicationUuid,
deploymentUuid: event.deploymentUuid,
},
});
}
}
/**
* Rate-limit by client IP. 60 requests / minute is comfortably above
* Coolify's per-event burst (typically 1-2 per deploy) but well below a
* hostile actor flooding to fish for a working secret.
*
* Both verbs share the bucket, and both run it before the comparison: the
* reachability GET checks the same secret and answers 200 against 401, so
* leaving it unlimited would hand back the free guessing channel the POST
* limit exists to close.
*/
async function applyWebhookRateLimit(
request: NextRequest,
): Promise<NextResponse | null> {
const ip = getClientIp(request);
const rl = await checkRateLimit(
`deploy-webhook:${ip ?? "unknown"}`,
60,
60_000,
);
if (rl.allowed) return null;
return NextResponse.json(
{ status: "rate_limited" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "deploy.webhook" } });
const limited = await applyWebhookRateLimit(request);
if (limited) return limited;
const ip = getClientIp(request);
if (!hasValidSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
// Freshness AFTER the secret check: an unauthenticated caller learns
// nothing about the timestamp policy, and a replayed-but-stale request
// from a captured legitimate call is rejected here.
const freshness = checkTimestampFreshness(request);
if (!freshness.ok) {
annotate({
meta: { deploy_webhook_replay_reason: freshness.reason ?? "unknown" },
});
return NextResponse.json({ status: "stale" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
const { data: payload, error: jsonError } =
await safeJson<CoolifyDeployPayload>(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const event = normalizePayload(payload);
annotate({
meta: {
deploy_outcome: event.outcome,
application_name: event.applicationName,
application_uuid: event.applicationUuid,
deployment_uuid: event.deploymentUuid,
},
});
// One audit-log row per webhook call. Action namespace mirrors the
// existing `system.*` lane (`system.error`, `system.cleanup.*`) for
// ops-style events that aren't tied to a single user.
const action =
event.outcome === "success"
? "system.deploy.success"
: event.outcome === "failure"
? "system.deploy.failure"
: "system.deploy.unknown";
await auditLog(action, {
ipAddress: ip ?? null,
details: {
applicationName: event.applicationName,
applicationUuid: event.applicationUuid,
deploymentUuid: event.deploymentUuid,
error: event.error,
// Preserve the full Coolify payload so we can adapt to schema
// drift without code change.
raw: event.raw,
},
});
if (event.outcome === "failure") {
await notifyAdminsOfFailure(event);
}
return NextResponse.json(
{ status: "ok", outcome: event.outcome },
{ status: 200 },
);
});
/**
* Coolify (and most webhook UIs) ping `GET` for a reachability check
* before saving the configuration. We respond 200 if the secret is
* valid, 401 otherwise — same shape as the Withings webhook.
*/
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "deploy.webhook.verify" } });
const limited = await applyWebhookRateLimit(request);
if (limited) return limited;
if (!hasValidSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
return NextResponse.json({ status: "ok" }, { status: 200 });
});