mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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.
|
Set these once. Both required.
|
||||||
|
|
||||||
1. **`COOLIFY_WEBHOOK`**
|
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.
|
- Find: Coolify UI → Application → **Webhooks** tab → "Deploy" URL.
|
||||||
The workflow appends `&force=true` if your stored URL doesn't
|
The workflow appends `&force=true` if your stored URL doesn't
|
||||||
already carry a `force=` parameter, so either value is fine.
|
already carry a `force=` parameter, so either value is fine.
|
||||||
|
- The actual values live in repo secrets only.
|
||||||
|
|
||||||
2. **`COOLIFY_TOKEN`**
|
2. **`COOLIFY_TOKEN`**
|
||||||
- Value: a Bearer token from Coolify UI → **Keys & Tokens** →
|
- 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
|
look for "image registry", "digest auto-deploy", or
|
||||||
"auto-update" in the same tab.
|
"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
|
## Verification
|
||||||
|
|
||||||
Push a tag `vX.Y.Z` to main → wait for the GHCR build to finish →
|
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>`.
|
Token format: `n_<base62>`.
|
||||||
2. **Add two GitHub repository secrets**:
|
2. **Add two GitHub repository secrets**:
|
||||||
- `COOLIFY_WEBHOOK` =
|
- `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.
|
- `COOLIFY_TOKEN` = the token from step 1.
|
||||||
3. **Configure Coolify outgoing notifications**:
|
3. **Configure Coolify outgoing notifications**:
|
||||||
- Settings → Notifications → Webhook
|
- Settings → Notifications → Webhook
|
||||||
|
|||||||
@@ -15,6 +15,18 @@
|
|||||||
-- this enum value yet (per the v1.4.23 W1 research), so the change is
|
-- 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
|
-- effectively schema-only — the application enforces the new unit going
|
||||||
-- forward via `getUnitForType()`.
|
-- 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 ──────
|
-- ── MeasurementType — append 7 Apple-Health-shaped enum values ──────
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({
|
|||||||
device: {
|
device: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
},
|
},
|
||||||
|
refreshToken: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ function req(deviceIdHeader?: string): NextRequest {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.mocked(prisma.device.findMany).mockResolvedValue([]);
|
vi.mocked(prisma.device.findMany).mockResolvedValue([]);
|
||||||
|
vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/auth/me/devices", () => {
|
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"]);
|
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(getSession).mockResolvedValue(SESSION_OK as never);
|
||||||
|
vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue({
|
||||||
|
id: "rt-1",
|
||||||
|
} as never);
|
||||||
vi.mocked(prisma.device.findMany).mockResolvedValue([
|
vi.mocked(prisma.device.findMany).mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: "dev-1",
|
id: "dev-1",
|
||||||
@@ -137,7 +144,9 @@ describe("GET /api/auth/me/devices", () => {
|
|||||||
|
|
||||||
const res = await GET(req("dev-2"));
|
const res = await GET(req("dev-2"));
|
||||||
const body = await res.json();
|
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");
|
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;
|
const where = vi.mocked(prisma.device.findMany).mock.calls[0]?.[0]?.where;
|
||||||
expect(where).toEqual({ userId: "user-1" });
|
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
|
* device-identifying metadata only (label, lastSeen, channels) — no
|
||||||
* tokens, no APNs identifiers, no IPs.
|
* tokens, no APNs identifiers, no IPs.
|
||||||
*
|
*
|
||||||
* The "current device" marker is best-effort: the route inspects the
|
* v1.4.23 W6 reconcile (Sr-MED-5) — the "current device" marker is no
|
||||||
* X-Device-Id header (which the native client always sends) and marks
|
* longer derived from the unauthenticated `X-Device-Id` request header
|
||||||
* the matching row with `isCurrent: true`. Browser callers don't have a
|
* alone. The header value is now cross-checked against an unrevoked
|
||||||
* device row and therefore see `isCurrent: false` across the board.
|
* 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 { NextRequest } from "next/server";
|
||||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||||
@@ -32,7 +38,23 @@ interface DeviceDTO {
|
|||||||
|
|
||||||
export const GET = apiHandler(async (request: NextRequest) => {
|
export const GET = apiHandler(async (request: NextRequest) => {
|
||||||
const { user } = await requireAuth();
|
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({
|
const devices = await prisma.device.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
@@ -57,7 +79,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
|||||||
channels,
|
channels,
|
||||||
lastSeenAt: d.lastSeen.toISOString(),
|
lastSeenAt: d.lastSeen.toISOString(),
|
||||||
createdAt: d.createdAt.toISOString(),
|
createdAt: d.createdAt.toISOString(),
|
||||||
isCurrent: currentDeviceId !== null && d.id === currentDeviceId,
|
isCurrent:
|
||||||
|
trustedCurrentDeviceId !== null && d.id === trustedCurrentDeviceId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user