mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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