mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
chore(ops): signed images, a secret gate before commit, and a monthly restore drill
Published manifests are now cosign-signed keyless with a documented digest-pinning path, a lefthook pre-commit runs gitleaks over staged changes, responses opt into a credentialless embedder policy, the deploy webhook can require a fresh timestamp, and a monthly drill decrypts and parses the newest offhost backup so a broken key or format surfaces before an emergency does.
This commit is contained in:
1 parent
410c62c292
commit
ce3b4c70c8
11 files changed
+433
-4
No files matched your search
@@ -68,6 +68,11 @@ 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.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -268,6 +268,12 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# v1.16.4 — keyless image signing. The job requests an OIDC
|
||||
# token from GitHub's issuer; cosign exchanges it for a
|
||||
# short-lived Fulcio certificate bound to this workflow identity
|
||||
# and records the signature in the Rekor transparency log. No
|
||||
# long-lived signing key exists anywhere in this pipeline.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Download digest artifacts
|
||||
@@ -333,6 +339,35 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
|
||||
|
||||
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
|
||||
# Signing the INDEX digest covers both per-platform manifests in
|
||||
# one signature, and the digest is the only stable identity — every
|
||||
# tag (`:latest`, `:1.16.4`, …) is a mutable alias that a registry
|
||||
# compromise could repoint. Operators verify with:
|
||||
#
|
||||
# cosign verify \
|
||||
# --certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
|
||||
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
# ghcr.io/mbombeck/healthlog:<tag>
|
||||
#
|
||||
# See docs/ops/deploy.md ("Verify the image signature") for the
|
||||
# digest-pinned deploy recommendation that pairs with this.
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Sign multi-arch manifest (keyless)
|
||||
env:
|
||||
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
if [ -z "$digest" ]; then
|
||||
echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted."
|
||||
exit 1
|
||||
fi
|
||||
echo "Signing ${IMAGE_REF}@${digest}"
|
||||
cosign sign --yes "${IMAGE_REF}@${digest}"
|
||||
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}"
|
||||
|
||||
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
|
||||
# the new image. Replaces the former manual SSH + force-pull recipe
|
||||
# on the deployment host.
|
||||
|
||||
@@ -77,6 +77,10 @@ services:
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
|
||||
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
|
||||
# Optional replay hardening: when "true", the deploy webhook
|
||||
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
|
||||
# See docs/ops/deploy.md.
|
||||
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
|
||||
# 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
|
||||
|
||||
@@ -85,3 +85,30 @@ pnpm tsx scripts/restore-backup.ts \
|
||||
The script downloads the object, decrypts it, and writes the JSON dump
|
||||
to disk. Importing the JSON back into a HealthLog instance is left to
|
||||
the operator (use `prisma db seed` or a custom script).
|
||||
|
||||
## Monthly restore drill (automatic)
|
||||
|
||||
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
|
||||
04:11 on the 1st of each month) exercises the read path end-to-end:
|
||||
fetch the most recent backup object from the bucket, decrypt it under
|
||||
the current `BACKUP_ENCRYPTION_KEY`, JSON-parse it, and sanity-check
|
||||
the payload shape. It performs **no database restore** — it validates
|
||||
the artefact, not the import path.
|
||||
|
||||
Outcomes:
|
||||
|
||||
- **Success** — record counts, object age, and sizes land in the
|
||||
wide-event meta (`job.restore_drill`).
|
||||
- **Stale chain** — the newest object is older than 3 days: the nightly
|
||||
uploader has stalled (or the lifecycle rule is too aggressive). The
|
||||
drill pages via the worker error reporter (stderr + GlitchTip).
|
||||
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
|
||||
rotated key), malformed JSON: pages the same way. A decryption
|
||||
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
|
||||
key cannot read the existing objects — re-encrypt or accept that
|
||||
pre-rotation backups are only readable with the retired key.
|
||||
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
|
||||
silently (wide-event warning only).
|
||||
|
||||
The drill needs no IAM grant beyond the uploader's existing
|
||||
`GetObject` + `ListBucket`.
|
||||
+74
-1
@@ -70,7 +70,7 @@ roll back:
|
||||
release pair shipped on the v1.4.x line.
|
||||
3. If a column-drop migration ships in the rollback target, hand-roll
|
||||
the column back via a fresh forward migration (`pnpm prisma migrate
|
||||
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
commit, and re-deploy. Never `migrate resolve --rolled-back` against
|
||||
production unless you've taken a fresh `pg_dump` first.
|
||||
|
||||
@@ -90,3 +90,76 @@ pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
Pass `--only=prod` / `--only=demo` to check a single target, or
|
||||
`--attempts=N --interval=ms` to widen the retry window while a slow
|
||||
pull settles.
|
||||
|
||||
## Verify the image signature
|
||||
|
||||
Every release image (the multi-arch manifest **index**, covering both
|
||||
`amd64` and `arm64`) is signed keyless via Sigstore in the
|
||||
`docker-publish.yml` merge job: GitHub's OIDC token is exchanged for a
|
||||
short-lived Fulcio certificate bound to the workflow identity, and the
|
||||
signature lands in the Rekor transparency log. There is no long-lived
|
||||
signing key to leak or rotate.
|
||||
|
||||
Verify before (or after) a deploy with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/):
|
||||
|
||||
```sh
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
|
||||
ghcr.io/mbombeck/healthlog:1.16.4
|
||||
```
|
||||
|
||||
A passing verification proves the image was built by this repository's
|
||||
release workflow on a `v*` tag — not by anyone who merely obtained push
|
||||
access to the GHCR package.
|
||||
|
||||
### Prefer digest-pinned deploys
|
||||
|
||||
Tags (`:latest`, `:1.16.4`) are mutable aliases: a registry-side
|
||||
compromise or an accidental re-push can repoint them. The digest is the
|
||||
image's only immutable identity, and it is what the signature actually
|
||||
covers. For any deploy where supply-chain integrity matters more than
|
||||
convenience:
|
||||
|
||||
1. Resolve and verify the digest once:
|
||||
|
||||
```sh
|
||||
cosign verify ... ghcr.io/mbombeck/healthlog:1.16.4 \
|
||||
| jq -r '.[0].critical.image."docker-manifest-digest"'
|
||||
```
|
||||
|
||||
2. Pin the compose `image:` (or the Coolify image reference) to
|
||||
`ghcr.io/mbombeck/healthlog@sha256:<digest>` instead of the tag.
|
||||
|
||||
The day-to-day `:latest` + `pull_policy: always` flow stays the
|
||||
documented default for self-hosters; digest pinning is the recommended
|
||||
hardening for operators with a threat model that includes the registry.
|
||||
|
||||
## 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.
|
||||
|
||||
Two modes:
|
||||
|
||||
- **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.:
|
||||
|
||||
```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"}'
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# Git-hook manager (v1.16.4). `pnpm install` wires the hooks via the
|
||||
# `prepare` script in package.json; no manual step needed.
|
||||
#
|
||||
# Pre-commit secret scan: `gitleaks protect --staged` scans exactly the
|
||||
# staged hunks against the repo's `.gitleaks.toml` before every commit,
|
||||
# so a secret-shaped string never reaches the object store (where only
|
||||
# a history rewrite can remove it again).
|
||||
#
|
||||
# Requirements: the `gitleaks` binary must be on PATH — the command
|
||||
# below fails with install pointers when it is missing rather than
|
||||
# silently skipping the scan.
|
||||
#
|
||||
# Opt-out (one commit): LEFTHOOK=0 git commit ...
|
||||
# Opt-out (skip one job): LEFTHOOK_EXCLUDE=gitleaks git commit ...
|
||||
# Opt-out (permanent): pnpm lefthook uninstall
|
||||
# Per project policy (`--no-verify` is banned), prefer fixing the
|
||||
# finding over bypassing the hook.
|
||||
|
||||
pre-commit:
|
||||
commands:
|
||||
gitleaks:
|
||||
run: |
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "ERROR: gitleaks binary not found on PATH — the staged-secret scan cannot run." >&2
|
||||
echo "Install it first, e.g.:" >&2
|
||||
echo " brew install gitleaks # macOS" >&2
|
||||
echo " apt/apk/pacman install gitleaks # Linux distros" >&2
|
||||
echo " https://github.com/gitleaks/gitleaks#installing" >&2
|
||||
echo "One-off bypass (use sparingly): LEFTHOOK=0 git commit ..." >&2
|
||||
exit 1
|
||||
fi
|
||||
gitleaks protect --staged --redact --no-banner
|
||||
@@ -24,6 +24,7 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
|
||||
"dev": "next dev",
|
||||
"prebuild": "node scripts/generate-sw-version.mjs",
|
||||
"build": "next build",
|
||||
@@ -109,6 +110,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"knip": "^6.16.1",
|
||||
"lefthook": "^2.1.9",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.21.0",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
Generated
+100
@@ -183,6 +183,9 @@ importers:
|
||||
knip:
|
||||
specifier: ^6.16.1
|
||||
version: 6.16.1
|
||||
lefthook:
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9
|
||||
pdf-parse:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
@@ -4792,6 +4795,60 @@ packages:
|
||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||
engines: {node: '>= 0.6.3'}
|
||||
|
||||
lefthook-darwin-arm64@2.1.9:
|
||||
resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
lefthook-darwin-x64@2.1.9:
|
||||
resolution: {integrity: sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
lefthook-freebsd-arm64@2.1.9:
|
||||
resolution: {integrity: sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
lefthook-freebsd-x64@2.1.9:
|
||||
resolution: {integrity: sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
lefthook-linux-arm64@2.1.9:
|
||||
resolution: {integrity: sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lefthook-linux-x64@2.1.9:
|
||||
resolution: {integrity: sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lefthook-openbsd-arm64@2.1.9:
|
||||
resolution: {integrity: sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw==}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
lefthook-openbsd-x64@2.1.9:
|
||||
resolution: {integrity: sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
lefthook-windows-arm64@2.1.9:
|
||||
resolution: {integrity: sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
lefthook-windows-x64@2.1.9:
|
||||
resolution: {integrity: sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
lefthook@2.1.9:
|
||||
resolution: {integrity: sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ==}
|
||||
hasBin: true
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -11428,6 +11485,49 @@ snapshots:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
|
||||
lefthook-darwin-arm64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook-darwin-x64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook-freebsd-arm64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook-freebsd-x64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook-linux-arm64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook-linux-x64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook-openbsd-arm64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook-openbsd-x64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook-windows-arm64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook-windows-x64@2.1.9:
|
||||
optional: true
|
||||
|
||||
lefthook@2.1.9:
|
||||
optionalDependencies:
|
||||
lefthook-darwin-arm64: 2.1.9
|
||||
lefthook-darwin-x64: 2.1.9
|
||||
lefthook-freebsd-arm64: 2.1.9
|
||||
lefthook-freebsd-x64: 2.1.9
|
||||
lefthook-linux-arm64: 2.1.9
|
||||
lefthook-linux-x64: 2.1.9
|
||||
lefthook-openbsd-arm64: 2.1.9
|
||||
lefthook-openbsd-x64: 2.1.9
|
||||
lefthook-windows-arm64: 2.1.9
|
||||
lefthook-windows-x64: 2.1.9
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -330,3 +330,84 @@ describe("GET /api/internal/deploy-webhook (reachability check)", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
function timestamped(ts: string): NextRequest {
|
||||
return jsonRequest(
|
||||
{ status: "success" },
|
||||
{
|
||||
"x-deploy-webhook-secret": "test-secret",
|
||||
"x-deploy-webhook-timestamp": ts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("accepts a fresh unix-seconds timestamp", async () => {
|
||||
const res = await POST(timestamped(String(Math.floor(Date.now() / 1000))));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts a fresh ISO-8601 timestamp", async () => {
|
||||
const res = await POST(timestamped(new Date().toISOString()));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a timestamp older than the 5-minute window", async () => {
|
||||
const stale = Math.floor((Date.now() - 6 * 60 * 1000) / 1000);
|
||||
const res = await POST(timestamped(String(stale)));
|
||||
expect(res.status).toBe(401);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a timestamp too far in the future (clock-skew bound)", async () => {
|
||||
const future = Math.floor((Date.now() + 6 * 60 * 1000) / 1000);
|
||||
const res = await POST(timestamped(String(future)));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects an unparseable timestamp value", async () => {
|
||||
const res = await POST(timestamped("not-a-timestamp"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts a request without the header in tolerant default mode", async () => {
|
||||
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ status: "success" },
|
||||
{ "x-deploy-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a request without the header when DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true", async () => {
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ status: "success" },
|
||||
{ "x-deploy-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still rejects a stale timestamp before any audit-log write", async () => {
|
||||
const stale = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
|
||||
const res = await POST(timestamped(String(stale)));
|
||||
expect(res.status).toBe(401);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,58 @@ 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.
|
||||
*
|
||||
* 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) {
|
||||
@@ -173,6 +225,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
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 } =
|
||||
|
||||
+10
-3
@@ -290,6 +290,15 @@ export function proxy(request: NextRequest) {
|
||||
// Flash/PDF crossdomain.xml channel that some scanners still flag.
|
||||
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
||||
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
|
||||
// COEP `credentialless` completes the cross-origin-isolation triad
|
||||
// without the operational hazard of `require-corp`: cross-origin
|
||||
// no-CORS subresources load with credentials stripped instead of
|
||||
// being blocked outright. The app's CSP already pins `img-src` to
|
||||
// `'self' data:` (Gravatar is proxied same-origin since v1.5.5), so
|
||||
// no shipped surface loads cross-origin subresources at all — the
|
||||
// header is pure defence-in-depth against a future regression and
|
||||
// unlocks `crossOriginIsolated` capabilities should they be needed.
|
||||
response.headers.set("Cross-Origin-Embedder-Policy", "credentialless");
|
||||
response.headers.set("X-Permitted-Cross-Domain-Policies", "none");
|
||||
|
||||
// CSP — permissive in dev, strict in production. Third-party hosts in
|
||||
@@ -321,9 +330,7 @@ export function proxy(request: NextRequest) {
|
||||
const isWhoopRoute =
|
||||
pathname.startsWith("/settings/integrations/whoop") ||
|
||||
pathname.startsWith("/api/whoop/");
|
||||
const whoopConnectSrc = isWhoopRoute
|
||||
? " https://api.prod.whoop.com"
|
||||
: "";
|
||||
const whoopConnectSrc = isWhoopRoute ? " https://api.prod.whoop.com" : "";
|
||||
// v1.5.5 — Gravatar host removed from `img-src`. The /me payload
|
||||
// used to return `gravatarUrl: https://www.gravatar.com/avatar/<sha256(email)>`,
|
||||
// which leaked the email digest to Automattic on every authenticated
|
||||
|
||||
Reference in new issue
Block a user