mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(deploy-webhook): accept signed requests and stop calling a bare timestamp replay protection
The deploy-status webhook authenticated with a static secret in a header, and the timestamp the docs called replay protection was covered by nothing: whoever captures one request holds the secret and can send a fresh request with any content. The strict setting, documented as closing the replay window completely, only made that timestamp mandatory. A sender can now sign instead: X-Deploy-Webhook-Signature is the HMAC-SHA256 of the timestamp and the exact body under the shared secret, with the timestamp within five minutes, and the secret never travels. The strict setting accepts only signed requests. The secret header still works by default for senders that cannot sign, and the docs describe its timestamp as the staleness hint it is.
This commit is contained in:
1 parent
807d483b89
commit
f2a9b0fc0e
5 files changed
+204
-71
No files matched your search
@@ -295,10 +295,10 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# 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"
|
||||
|
||||
|
||||
|
||||
+30
-23
@@ -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=<hex>` 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://<host>/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://<host>/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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NextRequest> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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=<hex>` 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<CoolifyDeployPayload>(request, { maxBytes: 64 * 1024 });
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const event = normalizePayload(payload);
|
||||
|
||||
annotate({
|
||||
|
||||
Reference in new issue
Block a user