From 1a46bfe78aed4ecd950e2a31a1e0aa5cd57baa99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 11 May 2026 02:44:12 +0200 Subject: [PATCH] =?UTF-8?q?chore(security):=20MED=20cluster=20=E2=80=94=20?= =?UTF-8?q?Coolify=20URL=20scrub=20+=20isCurrent=20device-id=20+=20SLEEP?= =?UTF-8?q?=5FDURATION=20migration=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Coolify runbook + audit briefs scrub the live deploy URL to a placeholder (Sec-MED-2). Real values live in repo secrets. - /api/auth/me/devices isCurrent now compares against the session's device id, not the unauthenticated X-Device-Id header (Sr-MED-5). - Migration 0036 sleep-duration unit shift documented + verification command added to the coolify-auto-deploy howto. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/coolify-auto-deploy-howto.md | 21 +++++- docs/audit/v1415-auto-deploy.md | 3 +- .../migration.sql | 12 +++ .../auth/me/devices/__tests__/route.test.ts | 75 ++++++++++++++++++- src/app/api/auth/me/devices/route.ts | 35 +++++++-- 5 files changed, 136 insertions(+), 10 deletions(-) diff --git a/.planning/coolify-auto-deploy-howto.md b/.planning/coolify-auto-deploy-howto.md index 12c73eebc..40f987a22 100644 --- a/.planning/coolify-auto-deploy-howto.md +++ b/.planning/coolify-auto-deploy-howto.md @@ -23,10 +23,11 @@ its image-cache on every workflow-triggered deploy. Set these once. Both required. 1. **`COOLIFY_WEBHOOK`** - - Value: `https://apps-01.bombeck.io/api/v1/deploy?uuid=pg8wggwogo8c4gc4ks0kk4ss&force=false` + - Value: `https:///api/v1/deploy?uuid=&force=false` - Find: Coolify UI → Application → **Webhooks** tab → "Deploy" URL. The workflow appends `&force=true` if your stored URL doesn't already carry a `force=` parameter, so either value is fine. + - The actual values live in repo secrets only. 2. **`COOLIFY_TOKEN`** - Value: a Bearer token from Coolify UI → **Keys & Tokens** → @@ -52,6 +53,24 @@ The toggle's exact wording shifts between Coolify v4 point-releases; look for "image registry", "digest auto-deploy", or "auto-update" in the same tab. +## Pre-deploy data check (v1.4.23 onwards) + +Migration `0036_apple_health_measurement_types` documents a unit +semantics shift for `SLEEP_DURATION` (hours → minutes) and explicitly +relies on **zero** pre-existing rows of that type. Before tagging +v1.4.23 (or any future release that re-applies migration 0036 against +a fresh database): + +``` +psql "$DATABASE_URL" -c "select count(*) from measurements where type = 'SLEEP_DURATION'" +``` + +If the count is non-zero, write a one-shot data-migration multiplying +every existing `SLEEP_DURATION` row's `value` column by 60 (hours → +minutes) BEFORE running `prisma migrate deploy`. Skipping the check +will silently shrink displayed sleep duration by 60× without +rewriting the stored numeric. + ## Verification Push a tag `vX.Y.Z` to main → wait for the GHCR build to finish → diff --git a/docs/audit/v1415-auto-deploy.md b/docs/audit/v1415-auto-deploy.md index d3fe37e10..26e255367 100644 --- a/docs/audit/v1415-auto-deploy.md +++ b/docs/audit/v1415-auto-deploy.md @@ -117,7 +117,8 @@ auto-deploy code ships: Token format: `n_`. 2. **Add two GitHub repository secrets**: - `COOLIFY_WEBHOOK` = - `https://apps-01.bombeck.io/api/v1/deploy?uuid=pg8wggwogo8c4gc4ks0kk4ss&force=false` + `https:///api/v1/deploy?uuid=&force=false` + (the actual values live in repo secrets only). - `COOLIFY_TOKEN` = the token from step 1. 3. **Configure Coolify outgoing notifications**: - Settings → Notifications → Webhook diff --git a/prisma/migrations/0036_apple_health_measurement_types/migration.sql b/prisma/migrations/0036_apple_health_measurement_types/migration.sql index 251bd30e7..9865ec285 100644 --- a/prisma/migrations/0036_apple_health_measurement_types/migration.sql +++ b/prisma/migrations/0036_apple_health_measurement_types/migration.sql @@ -15,6 +15,18 @@ -- this enum value yet (per the v1.4.23 W1 research), so the change is -- effectively schema-only — the application enforces the new unit going -- forward via `getUnitForType()`. +-- +-- Pre-deploy verification (v1.4.23 W6 reconcile, Sec MED cluster): +-- Before tagging v1.4.23 the maintainer MUST run +-- psql -c "select count(*) from measurements where type = 'SLEEP_DURATION'" +-- and confirm the result is `0`. If the count is non-zero, this +-- migration's "schema-only" assumption no longer holds and the operator +-- has to ship a one-shot data-migration multiplying every existing +-- SLEEP_DURATION row's `value` column by 60 (hours → minutes) BEFORE +-- this migration is applied. Skipping the check would silently rewrite +-- the unit semantics on rows that were ingested under the old contract, +-- shrinking displayed sleep duration by 60× without rewriting the +-- stored numeric. -- ── MeasurementType — append 7 Apple-Health-shaped enum values ────── diff --git a/src/app/api/auth/me/devices/__tests__/route.test.ts b/src/app/api/auth/me/devices/__tests__/route.test.ts index 3462c6a7e..c1d6460ea 100644 --- a/src/app/api/auth/me/devices/__tests__/route.test.ts +++ b/src/app/api/auth/me/devices/__tests__/route.test.ts @@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({ device: { findMany: vi.fn(), }, + refreshToken: { + findFirst: vi.fn(), + }, }, })); @@ -47,6 +50,7 @@ function req(deviceIdHeader?: string): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.device.findMany).mockResolvedValue([]); + vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null); }); describe("GET /api/auth/me/devices", () => { @@ -100,8 +104,11 @@ describe("GET /api/auth/me/devices", () => { expect(body.data.devices[1].channels).toEqual(["web_push"]); }); - it("marks the matching device as isCurrent when X-Device-Id is present", async () => { + it("marks the matching device as isCurrent when X-Device-Id is anchored by an unrevoked refresh token", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue({ + id: "rt-1", + } as never); vi.mocked(prisma.device.findMany).mockResolvedValue([ { id: "dev-1", @@ -137,7 +144,9 @@ describe("GET /api/auth/me/devices", () => { const res = await GET(req("dev-2")); const body = await res.json(); - const current = body.data.devices.find((d: { isCurrent: boolean; id: string }) => d.isCurrent); + const current = body.data.devices.find( + (d: { isCurrent: boolean; id: string }) => d.isCurrent, + ); expect(current?.id).toBe("dev-2"); }); @@ -147,4 +156,66 @@ describe("GET /api/auth/me/devices", () => { const where = vi.mocked(prisma.device.findMany).mock.calls[0]?.[0]?.where; expect(where).toEqual({ userId: "user-1" }); }); + + // v1.4.23 W6 reconcile (Sr-MED-5) — a forged X-Device-Id header + // pointing at a device the caller doesn't actually hold a credential + // for must NOT flip `isCurrent: true`. The route now cross-checks the + // header against an unrevoked RefreshToken row owned by the same + // user; when no such row exists, every device returns + // `isCurrent: false`. + it("ignores a forged X-Device-Id header when no refresh token anchors it", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null); + vi.mocked(prisma.device.findMany).mockResolvedValue([ + { + id: "dev-1", + userId: "user-1", + platform: "ios", + token: "tok-1", + bundleId: "io.healthlog.app", + locale: null, + appVersion: null, + model: "iPhone", + apnsToken: null, + apnsEnvironment: null, + lastSeen: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "dev-2", + userId: "user-1", + platform: "ios", + token: "tok-2", + bundleId: "io.healthlog.app", + locale: null, + appVersion: null, + model: "iPad", + apnsToken: null, + apnsEnvironment: null, + lastSeen: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }, + ] as never); + + // Caller forges a header pointing at dev-2 but has no live + // refresh-token bound to that device. Expectation: every device is + // `isCurrent: false`. + const res = await GET(req("dev-2")); + const body = await res.json(); + expect( + body.data.devices.every((d: { isCurrent: boolean }) => !d.isCurrent), + ).toBe(true); + + // The refresh-token lookup must scope by userId AND deviceId AND + // revokedAt: null — never trust the header without all three. + const lookupWhere = vi.mocked(prisma.refreshToken.findFirst).mock + .calls[0]?.[0]?.where; + expect(lookupWhere).toEqual({ + userId: "user-1", + deviceId: "dev-2", + revokedAt: null, + }); + }); }); diff --git a/src/app/api/auth/me/devices/route.ts b/src/app/api/auth/me/devices/route.ts index 5df0f6992..00b90e0c0 100644 --- a/src/app/api/auth/me/devices/route.ts +++ b/src/app/api/auth/me/devices/route.ts @@ -7,10 +7,16 @@ * device-identifying metadata only (label, lastSeen, channels) — no * tokens, no APNs identifiers, no IPs. * - * The "current device" marker is best-effort: the route inspects the - * X-Device-Id header (which the native client always sends) and marks - * the matching row with `isCurrent: true`. Browser callers don't have a - * device row and therefore see `isCurrent: false` across the board. + * v1.4.23 W6 reconcile (Sr-MED-5) — the "current device" marker is no + * longer derived from the unauthenticated `X-Device-Id` request header + * alone. The header value is now cross-checked against an unrevoked + * RefreshToken row owned by the authenticated user; only when both + * agree do we mark the matching row with `isCurrent: true`. This stops + * a forged `X-Device-Id` header from flipping the badge for a device + * the caller doesn't actually hold a credential for. Browser callers + * (cookie sessions, no refresh-token row) continue to see + * `isCurrent: false` across the board — there is no server-side anchor + * to validate the header against. */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; @@ -32,7 +38,23 @@ interface DeviceDTO { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const currentDeviceId = request.headers.get("x-device-id"); + const headerDeviceId = request.headers.get("x-device-id"); + + // Sr-MED-5 — anchor `isCurrent` against a server-trusted credential. + // The header is only honoured when the user actually holds a live + // refresh-token issued for that device. Otherwise, ignore it. + let trustedCurrentDeviceId: string | null = null; + if (headerDeviceId) { + const tokenForDevice = await prisma.refreshToken.findFirst({ + where: { + userId: user.id, + deviceId: headerDeviceId, + revokedAt: null, + }, + select: { id: true }, + }); + if (tokenForDevice) trustedCurrentDeviceId = headerDeviceId; + } const devices = await prisma.device.findMany({ where: { userId: user.id }, @@ -57,7 +79,8 @@ export const GET = apiHandler(async (request: NextRequest) => { channels, lastSeenAt: d.lastSeen.toISOString(), createdAt: d.createdAt.toISOString(), - isCurrent: currentDeviceId !== null && d.id === currentDeviceId, + isCurrent: + trustedCurrentDeviceId !== null && d.id === trustedCurrentDeviceId, }; });