mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore(security): MED cluster — Coolify URL scrub + isCurrent device-id + SLEEP_DURATION migration note
- 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
650f150353
commit
1a46bfe78a
5 files changed
+136
-10
No files matched your search
@@ -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://<COOLIFY_INSTANCE>/api/v1/deploy?uuid=<APPLICATION_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 →
|
||||
|
||||
@@ -117,7 +117,8 @@ auto-deploy code ships:
|
||||
Token format: `n_<base62>`.
|
||||
2. **Add two GitHub repository secrets**:
|
||||
- `COOLIFY_WEBHOOK` =
|
||||
`https://apps-01.bombeck.io/api/v1/deploy?uuid=pg8wggwogo8c4gc4ks0kk4ss&force=false`
|
||||
`https://<COOLIFY_INSTANCE>/api/v1/deploy?uuid=<APPLICATION_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
|
||||
|
||||
@@ -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 ──────
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user