From ce3b4c70c8d1e2550a3ae2bf9a4af1fb9f1480f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 00:10:01 +0200 Subject: [PATCH] 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. --- .env.production.example | 5 + .github/workflows/docker-publish.yml | 35 ++++++ docker-compose.yml | 4 + docs/ops/backup-restore.md | 27 +++++ docs/ops/deploy.md | 75 ++++++++++++- lefthook.yml | 32 ++++++ package.json | 2 + pnpm-lock.yaml | 100 ++++++++++++++++++ .../deploy-webhook/__tests__/route.test.ts | 81 ++++++++++++++ src/app/api/internal/deploy-webhook/route.ts | 63 +++++++++++ src/proxy.ts | 13 ++- 11 files changed, 433 insertions(+), 4 deletions(-) create mode 100644 lefthook.yml diff --git a/.env.production.example b/.env.production.example index e4f73447e..28072371e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -68,6 +68,11 @@ 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. +# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false" # ----------------------------------------------------------------------------- diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7b8585d2b..7089c65cf 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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: + # + # 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. diff --git a/docker-compose.yml b/docker-compose.yml index f6c68cbb6..b173c7de4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 6884f70ce..af110f832 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -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`. diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 822748a72..ad890d7d9 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -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_`), edit the SQL, +dev --create-only --name vX_Y_Z_rollback_`), 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:` 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:///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"}' + ``` diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 000000000..211156e44 --- /dev/null +++ b/lefthook.yml @@ -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 diff --git a/package.json b/package.json index 8f1f3da18..2c420f2f0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e4d11f33..a26df611a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 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 04a21adae..621248027 100644 --- a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts +++ b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts index 25d0a583a..ab60010e5 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -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 } = diff --git a/src/proxy.ts b/src/proxy.ts index 382b7c815..f2c8664cf 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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/`, // which leaked the email digest to Automattic on every authenticated