diff --git a/.env.production.example b/.env.production.example index 353335f6f..f74d78f11 100644 --- a/.env.production.example +++ b/.env.production.example @@ -295,10 +295,10 @@ API_TOKEN_HMAC_KEY="" # Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in # deploy-failure notifications. When unset the notification omits the link. # DEPLOY_LOGS_URL="" -# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp -# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook -# call. Leave unset for Coolify's stock sender (it attaches no -# timestamp); see docs/ops/deploy.md. +# Optional replay protection: accept only signed webhook calls +# (X-Deploy-Webhook-Signature, an HMAC of the timestamp and body under the +# secret, with a timestamp within +/- 5 minutes). Leave unset for Coolify's +# stock sender, which can only send the secret header; see docs/ops/deploy.md. # DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false" diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 0a1f8ff7c..9b44add5e 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -506,29 +506,36 @@ low-friction default. ## Deploy-status webhook: replay protection -`POST /api/internal/deploy-webhook` authenticates via the static -`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally -enforces a freshness window: a request carrying an -`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds, -or ISO-8601) is rejected with `401 {"status":"stale"}` when the -timestamp lies more than **5 minutes** from server time — so a captured -request cannot be replayed later to re-page admins or forge deploy -outcomes in the audit log. +`POST /api/internal/deploy-webhook` accepts two kinds of request. -Two modes: +- **Secret header.** `X-Deploy-Webhook-Secret` carries the shared secret. + This is what Coolify's stock notification sender can do. It does not + protect against a replay: anyone who captures one request holds the + secret and can send any request they like. An `X-Deploy-Webhook-Timestamp` + beside it is still checked (a stale or unparseable one is refused with + `401 {"status":"stale"}`), but it is covered by nothing, so treat it as a + staleness hint. Versions before 1.46 described it as replay protection. +- **Signature.** `X-Deploy-Webhook-Signature: sha256=` is the + HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the + `X-Deploy-Webhook-Timestamp` value, a dot, and the exact request body. The + timestamp (unix seconds, unix milliseconds or ISO-8601) must lie within + **5 minutes** of server time. A signed request sends no secret, and a + captured one is useless once the window has passed. -- **Default (tolerant).** Requests _without_ the header pass — Coolify's - stock notification sender does not attach one. A header that is - present but stale or unparseable is always rejected. -- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app - environment to make the header mandatory, closing the replay window - completely. Use this when the sender (or a reverse proxy in front of - the app) can attach the timestamp, e.g.: +Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` to accept signed requests only. +Use it when the sender, or a small relay in front of the app, can sign: - ```sh - curl -X POST https:///api/internal/deploy-webhook \ - -H "X-Deploy-Webhook-Secret: $SECRET" \ - -H "X-Deploy-Webhook-Timestamp: $(date +%s)" \ - -H "Content-Type: application/json" \ - -d '{"status":"success"}' - ``` +```sh +TS=$(date +%s) +BODY='{"status":"success"}' +SIG=$(printf '%s.%s' "$TS" "$BODY" \ + | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //') +curl -X POST https:///api/internal/deploy-webhook \ + -H "X-Deploy-Webhook-Timestamp: $TS" \ + -H "X-Deploy-Webhook-Signature: sha256=$SIG" \ + -H "Content-Type: application/json" \ + -d "$BODY" +``` + +Before 1.46 the strict setting accepted the secret header plus a fresh +timestamp. Such a sender now gets 401 in strict mode and has to sign. diff --git a/src/__tests__/baselines/route-safe-json.json b/src/__tests__/baselines/route-safe-json.json index ec52c3e59..08e21e93b 100644 --- a/src/__tests__/baselines/route-safe-json.json +++ b/src/__tests__/baselines/route-safe-json.json @@ -1,7 +1,7 @@ { "$comment": "safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by src/__tests__/route-body-parse-ratchet.test.ts.", - "count": 61, - "total": 61, + "count": 60, + "total": 60, "entries": { "app/api/admin/ai-settings/route.ts": 1, "app/api/admin/drain-per-sample-cumulative/route.ts": 1, @@ -37,7 +37,6 @@ "app/api/insights/generate/route.ts": 1, "app/api/insights/layout/route.ts": 1, "app/api/integrations/healthkit/route.ts": 1, - "app/api/internal/deploy-webhook/route.ts": 1, "app/api/labs/ocr/extract/route.ts": 1, "app/api/measurements/batch/route.ts": 1, "app/api/measurements/by-external-ids/route.ts": 1, diff --git a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts index c6dca41e1..86c18de62 100644 --- a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts +++ b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts @@ -436,3 +436,71 @@ describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () = expect(dispatchLocalisedNotification).not.toHaveBeenCalled(); }); }); + +/** + * The secret header cannot stop a replay: a captured request carries the + * secret, and the timestamp beside it was covered by nothing. A signed + * request carries no secret and binds its timestamp to its body. + */ +describe("POST /api/internal/deploy-webhook (signed requests)", () => { + const ORIGINAL_REQUIRE = process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP; + afterEach(() => { + if (ORIGINAL_REQUIRE === undefined) { + delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP; + } else { + process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = ORIGINAL_REQUIRE; + } + }); + + async function signed( + body: string, + opts: { ts?: string; sign?: string; secret?: string } = {}, + ): Promise { + const { createHmac } = await import("node:crypto"); + const ts = opts.ts ?? String(Math.floor(Date.now() / 1000)); + const mac = createHmac("sha256", opts.secret ?? "test-secret") + .update(`${ts}.${opts.sign ?? body}`) + .digest("hex"); + return new NextRequest("https://app.example/api/internal/deploy-webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-deploy-webhook-timestamp": ts, + "x-deploy-webhook-signature": `sha256=${mac}`, + }, + body, + }); + } + + it("accepts a signed request without the secret header", async () => { + const res = await POST(await signed('{"status":"success"}')); + expect(res.status).toBe(200); + expect(auditLog).toHaveBeenCalledOnce(); + }); + + it("refuses a signature over another body, an old timestamp, or another key", async () => { + for (const req of [ + await signed('{"status":"failed"}', { sign: '{"status":"success"}' }), + await signed('{"status":"success"}', { + ts: String(Math.floor(Date.now() / 1000) - 600), + }), + await signed('{"status":"success"}', { secret: "other-secret" }), + ]) { + expect((await POST(req)).status).toBe(401); + } + expect(auditLog).not.toHaveBeenCalled(); + }); + + it("in strict mode accepts only a signed request, not the bare secret with a fresh timestamp", async () => { + process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true"; + const unsigned = jsonRequest( + { status: "success" }, + { + "x-deploy-webhook-secret": "test-secret", + "x-deploy-webhook-timestamp": String(Math.floor(Date.now() / 1000)), + }, + ); + expect((await POST(unsigned)).status).toBe(401); + expect((await POST(await signed('{"status":"success"}'))).status).toBe(200); + }); +}); diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts index 982512c69..9c09600e8 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -1,11 +1,16 @@ import { envOr } from "@/lib/env"; -import { timingSafeEqual } from "node:crypto"; +import { createHmac, 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 { + apiError, + getClientIp, + isJsonContentType, + readBodyText, +} from "@/lib/api-response"; import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; import { prisma } from "@/lib/db"; @@ -87,23 +92,27 @@ function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent { } /** - * 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. + * Freshness and signature. * - * 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: + * The secret header alone cannot stop a replay: whoever holds a captured + * request holds the secret it carries, and can send a fresh request of any + * content. A timestamp beside it changed nothing, because the timestamp was + * covered by nothing and could be rewritten with the rest. Earlier versions + * called it replay protection; it was a staleness hint. * - * - 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. + * A sender that can compute a signature authenticates without sending the + * secret at all: `X-Deploy-Webhook-Signature: sha256=` is the + * HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the timestamp header's + * value, a dot, and the raw body, and the timestamp must lie within five + * minutes. A capture of such a request is useless after the window and + * reveals no secret. + * + * - Default: a signed request is verified as above; an unsigned one + * authenticates with the secret header, as Coolify's stock sender does, + * and a timestamp it carries must still be fresh. + * - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: only a signed request is + * accepted. The name predates the signature; requiring a timestamp + * without one would not make a replay any harder. * * See docs/ops/deploy.md ("Deploy-status webhook") for the operator * guidance. @@ -123,13 +132,7 @@ function checkTimestampFreshness( 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 }; - } + if (raw === null || raw === "") return { ok: true }; const ts = parseTimestamp(raw); if (ts === null) return { ok: false, reason: "timestamp_invalid" }; if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) { @@ -138,6 +141,44 @@ function checkTimestampFreshness( return { ok: true }; } +function strictMode(): boolean { + return ( + process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" || + process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1" + ); +} + +/** + * A signed request: the HMAC of `timestamp.body` under the shared secret, + * with a fresh timestamp. Null when the request carries no signature. + */ +function verifySignature( + request: NextRequest, + rawBody: string, + now: number = Date.now(), +): { ok: boolean; reason?: string } | null { + const header = request.headers.get("x-deploy-webhook-signature"); + if (header === null) return null; + const expected = process.env.DEPLOY_WEBHOOK_SECRET; + if (!expected) return { ok: false, reason: "secret_unset" }; + const timestamp = request.headers.get("x-deploy-webhook-timestamp"); + if (!timestamp) return { ok: false, reason: "timestamp_missing" }; + const ts = parseTimestamp(timestamp); + if (ts === null) return { ok: false, reason: "timestamp_invalid" }; + if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) { + return { ok: false, reason: "timestamp_outside_window" }; + } + const mac = createHmac("sha256", expected) + .update(`${timestamp}.${rawBody}`, "utf8") + .digest("hex"); + const a = Buffer.from(`sha256=${mac}`, "utf8"); + const b = Buffer.from(header.trim(), "utf8"); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + return { ok: false, reason: "signature_mismatch" }; + } + return { ok: true }; +} + function hasValidSecret(request: NextRequest): boolean { const expected = process.env.DEPLOY_WEBHOOK_SECRET; if (!expected) { @@ -238,27 +279,45 @@ export const POST = apiHandler(async (request: NextRequest) => { const ip = getClientIp(request); - if (!hasValidSecret(request)) { - return NextResponse.json({ status: "unauthorized" }, { status: 401 }); + if (!isJsonContentType(request.headers.get("content-type"))) { + return apiError("Content-Type must be application/json", 415); + } + // The raw body, because a signature covers the exact bytes sent. + const read = await readBodyText(request, 64 * 1024).catch(() => null); + if (!read) return apiError("Invalid request body", 400); + if (read.tooLarge) return apiError("Request body exceeds 65536 bytes", 413); + + const signed = verifySignature(request, read.text); + if (signed) { + if (!signed.ok) { + annotate({ meta: { deploy_webhook_auth_reason: signed.reason } }); + return NextResponse.json({ status: "unauthorized" }, { status: 401 }); + } + getEvent()?.setAuth({ auth_method: "webhook_secret" }); + annotate({ meta: { deploy_webhook_auth: "signature" } }); + } else { + if (strictMode() || !hasValidSecret(request)) { + return NextResponse.json({ status: "unauthorized" }, { status: 401 }); + } + // Freshness AFTER the secret check: an unauthenticated caller learns + // nothing about the timestamp policy. + 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" }); } - // 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 }); + let payload: CoolifyDeployPayload; + try { + payload = JSON.parse(read.text) as CoolifyDeployPayload; + } catch { + return apiError("Invalid JSON body", 400); } - getEvent()?.setAuth({ auth_method: "webhook_secret" }); - - const { data: payload, error: jsonError } = - await safeJson(request, { maxBytes: 64 * 1024 }); - if (jsonError) return jsonError; - const event = normalizePayload(payload); annotate({