diff --git a/.env.example b/.env.example index 462459b44..c0efd6658 100644 --- a/.env.example +++ b/.env.example @@ -376,8 +376,12 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # Configure the SAME value in Coolify's Notifications → Webhook # custom-headers panel. See docs/ops/deploy.md. # DEPLOY_WEBHOOK_SECRET="" -# "true" accepts signed requests only; leave unset for Coolify's stock sender. +# "true" also requires a fresh X-Deploy-Webhook-Timestamp on a secret-header +# request; leave unset for Coolify's stock sender, which sends none. # DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="" +# "true" accepts signed requests only (the setting that stops a replay); +# leave unset for Coolify's stock sender. +# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE="" # ----------------------------------------------------------------------------- diff --git a/.env.production.example b/.env.production.example index 4c88afe04..dc6d226c1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -295,11 +295,17 @@ 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: require an X-Deploy-Webhook-Timestamp within +/- 5 minutes on a +# request that authenticates with the secret header. Keeps a stale capture +# out, not a fresh forgery by someone who saw the secret. Leave unset for +# Coolify's stock sender, which sends no timestamp. +# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false" # 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" +# secret, with a timestamp within +/- 5 minutes); the secret header alone is +# refused. Leave unset for Coolify's stock sender, which can only send the +# secret header; see docs/ops/deploy.md. +# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE="false" # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index cbf7e5aab..0a6ecc172 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -311,14 +311,18 @@ x-healthlog-env: &healthlog_env # X-Deploy-Webhook-Secret header (what Coolify's stock sender can do; no # replay protection) or with X-Deploy-Webhook-Signature: sha256=." under the secret>, the timestamp - # within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true accepts signed - # requests only. DEPLOY_LOGS_URL is the deployment-dashboard URL (e.g. the - # Coolify UI) linked in deploy-failure notifications. Listed here so - # operator values in .env reach the container (the `environment:` block is - # a whitelist). See docs/ops/deploy.md. + # within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true also requires a + # fresh X-Deploy-Webhook-Timestamp on a secret-header request; + # DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true accepts signed requests only, the + # one setting that stops a replay. DEPLOY_LOGS_URL is the + # deployment-dashboard URL (e.g. the Coolify UI) linked in deploy-failure + # notifications. Listed here so operator values in .env reach the + # container (the `environment:` block is a whitelist). See + # docs/ops/deploy.md. DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}" DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}" DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}" + DEPLOY_WEBHOOK_REQUIRE_SIGNATURE: "${DEPLOY_WEBHOOK_REQUIRE_SIGNATURE:-}" # TLS leaf SPKI-change alarm baseline (optional). The native client # pins the served TLS leaf; this is the known-good pin set the monitor # alarms against on a rotation. Comma-separated for the dual-pin diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 65593cd4b..c841da4fe 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -540,8 +540,17 @@ low-friction default. **5 minutes** of server time. A signed request sends no secret, and a captured one is useless once the window has passed. -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: +Two settings tighten this: + +- `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` requires a fresh + `X-Deploy-Webhook-Timestamp` on a request that uses the secret header; one + without it is refused with `401 {"status":"stale"}`. This is what the + setting has always meant. It keeps an old capture out, but not someone who + saw the secret and sends a new request with a new timestamp. +- `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only; the + secret header alone is refused with 401. This is the one that stops a + replay. Use it when the sender, or a small relay in front of the app, can + sign: ```sh TS=$(date +%s) @@ -555,5 +564,5 @@ curl -X POST https:///api/internal/deploy-webhook \ -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. +Both may be set; a signed request satisfies both. An operator who set the +timestamp flag before 1.46 keeps exactly the behaviour they had. diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 51477220b..70546fd43 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -268,7 +268,12 @@ }, { "name": "DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP", - "purpose": "\"true\" (or \"1\") accepts only signed deploy-webhook requests (X-Deploy-Webhook-Signature with a fresh X-Deploy-Webhook-Timestamp); the secret header alone is refused with 401. Leave unset for Coolify's stock sender, which can only send the secret header.", + "purpose": "\"true\" (or \"1\") requires a fresh X-Deploy-Webhook-Timestamp (within 5 minutes) on a deploy-webhook request that authenticates with the secret header; one without it is refused with 401. Keeps a stale capture out, not a fresh forgery by someone who saw the secret. Leave unset for Coolify's stock sender, which sends no timestamp.", + "example": "true" + }, + { + "name": "DEPLOY_WEBHOOK_REQUIRE_SIGNATURE", + "purpose": "\"true\" (or \"1\") accepts only signed deploy-webhook requests (X-Deploy-Webhook-Signature with a fresh X-Deploy-Webhook-Timestamp); the secret header alone is refused with 401. The setting that stops a replay. Leave unset for Coolify's stock sender, which can only send the secret header.", "example": "true" } ] 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 86c18de62..407bac693 100644 --- a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts +++ b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts @@ -444,12 +444,18 @@ describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () = */ describe("POST /api/internal/deploy-webhook (signed requests)", () => { const ORIGINAL_REQUIRE = process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP; + const ORIGINAL_SIGNATURE = process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE; afterEach(() => { if (ORIGINAL_REQUIRE === undefined) { delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP; } else { process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = ORIGINAL_REQUIRE; } + if (ORIGINAL_SIGNATURE === undefined) { + delete process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE; + } else { + process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE = ORIGINAL_SIGNATURE; + } }); async function signed( @@ -491,8 +497,8 @@ describe("POST /api/internal/deploy-webhook (signed requests)", () => { 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"; + it("with DEPLOY_WEBHOOK_REQUIRE_SIGNATURE accepts only a signed request, not the bare secret with a fresh timestamp", async () => { + process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE = "true"; const unsigned = jsonRequest( { status: "success" }, { @@ -503,4 +509,24 @@ describe("POST /api/internal/deploy-webhook (signed requests)", () => { expect((await POST(unsigned)).status).toBe(401); expect((await POST(await signed('{"status":"success"}'))).status).toBe(200); }); + + it("keeps DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP meaning the secret plus a fresh timestamp", async () => { + // What the flag meant before 1.46, and what an operator who set it with + // a sender that timestamps but cannot sign still gets. + process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true"; + const fresh = jsonRequest( + { status: "success" }, + { + "x-deploy-webhook-secret": "test-secret", + "x-deploy-webhook-timestamp": String(Math.floor(Date.now() / 1000)), + }, + ); + expect((await POST(fresh)).status).toBe(200); + const bare = jsonRequest( + { status: "success" }, + { "x-deploy-webhook-secret": "test-secret" }, + ); + expect((await POST(bare)).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 9c09600e8..ef5aaef34 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -110,9 +110,15 @@ function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent { * - 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. + * - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: what it has always meant, the + * secret header plus a fresh timestamp; an unsigned request without one + * is refused. It keeps a stale capture out, not a fresh forgery by + * someone who saw the secret. A signed request is accepted as above. + * - `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true`: only a signed request is + * accepted; the secret header alone is refused. This is the setting + * that stops a replay. It has its own name so that an operator who set + * the timestamp flag, with a sender that adds a timestamp but cannot + * sign, keeps the behaviour they asked for. * * See docs/ops/deploy.md ("Deploy-status webhook") for the operator * guidance. @@ -132,7 +138,11 @@ function checkTimestampFreshness( now: number = Date.now(), ): { ok: boolean; reason?: string } { const raw = request.headers.get("x-deploy-webhook-timestamp"); - if (raw === null || raw === "") return { ok: true }; + if (raw === null || raw === "") { + return flagSet("DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP") + ? { ok: false, reason: "timestamp_missing" } + : { ok: true }; + } const ts = parseTimestamp(raw); if (ts === null) return { ok: false, reason: "timestamp_invalid" }; if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) { @@ -141,11 +151,11 @@ function checkTimestampFreshness( return { ok: true }; } -function strictMode(): boolean { - return ( - process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" || - process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1" - ); +function flagSet( + name: "DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP" | "DEPLOY_WEBHOOK_REQUIRE_SIGNATURE", +): boolean { + const value = process.env[name]?.trim().toLowerCase(); + return value === "true" || value === "1"; } /** @@ -296,7 +306,10 @@ export const POST = apiHandler(async (request: NextRequest) => { getEvent()?.setAuth({ auth_method: "webhook_secret" }); annotate({ meta: { deploy_webhook_auth: "signature" } }); } else { - if (strictMode() || !hasValidSecret(request)) { + if ( + flagSet("DEPLOY_WEBHOOK_REQUIRE_SIGNATURE") || + !hasValidSecret(request) + ) { return NextResponse.json({ status: "unauthorized" }, { status: 401 }); } // Freshness AFTER the secret check: an unauthenticated caller learns