mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(deploy-webhook): keep DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP's meaning, add a signature-only flag
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP had been changed to mean signed requests only, so an operator who set it for a sender that adds a timestamp but cannot sign would have had every deploy notification refused after the upgrade. It means the secret header plus a fresh timestamp again. Signature-only, the setting that stops a replay, is DEPLOY_WEBHOOK_REQUIRE_SIGNATURE, on the compose whitelist, in the env manifest and examples, and in the deploy guide.
This commit is contained in:
1 parent
d87718407e
commit
70df2009d9
7 files changed
+93
-26
No files matched your search
+5
-1
@@ -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=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -295,11 +295,17 @@ 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: 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"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+9
-5
@@ -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=<HMAC of
|
||||
# "<X-Deploy-Webhook-Timestamp>.<body>" 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
|
||||
|
||||
+13
-4
@@ -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://<host>/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.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user