diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ee097ef..5b27a7b90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## [Unreleased] +## [1.30.33] — 2026-07-19 + +Security release. It ships on its own because it changes how a browser +session is recognised. + +- **The session cookie is a purpose-generated secret.** It used to be the + session record's own database key — an identifier built to avoid + collisions, not to be unguessable, and one that appears wherever that + record is referenced. The cookie now carries 32 bytes from the system's + random source, and only its hash is stored, so the value in your + browser exists nowhere on the server. +- **You are not signed out by this.** Sessions from before the change + keep working and are simply not renewed, so they run out their normal + 30 days and disappear. Every sign-in from this release onward is on the + new scheme. +- **The device list no longer hands out working credentials.** It + identified each device by that same record key — which, for a session + predating this release, is that device's cookie. It now shows an opaque + handle that is useless as a login, and the same handle is what signs a + device out. +- **A refresh token cannot be rotated from a different device**, and an + expired one is refused. The test that was supposed to prove the expiry + passed a token that had never been issued, so it proved nothing: with + the expiry check deleted, the suite still went green. It is a real test + now. +- **A Nightscout address is checked when you save it**, not only when it + happens to be probed first. The check itself already refused private, + loopback and cloud-metadata addresses. + +Operators: rotating `API_TOKEN_HMAC_KEY` now also signs out every browser +session, not just API tokens. One migration (0255), additive. + ## [1.30.32] — 2026-07-19 - **A withdrawn AI consent stays withdrawn.** Opening the AI settings diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8e17c3fd7..1b8fc1dc1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.32 + version: 1.30.33 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index b1653770f..413e256e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.32", + "version": "1.30.33", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index d35934c1b..a175c4fc5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.32"; + /* @sw-version-fallback */ "v1.30.33"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts index 57ad51f46..575dca26f 100644 --- a/src/app/api/auth/me/sessions/__tests__/route.test.ts +++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts @@ -1,15 +1,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// The handle is an HMAC, so the route needs the key the real helper reads. +process.env.API_TOKEN_HMAC_KEY ??= "a".repeat(64); + vi.mock("@/lib/db", () => ({ prisma: { session: { findMany: vi.fn() } }, })); -vi.mock("@/lib/auth/session", () => ({ - getSession: vi.fn(), - destroyOtherSessions: vi.fn(), - destroySessionById: vi.fn(), -})); +// `sessionHandle` stays REAL: the point of the handle test is that the route +// emits the actual derivation, and a stubbed one would let the route return +// row ids while the assertion still passed. +vi.mock("@/lib/auth/session", async (importOriginal) => { + const actual = await importOriginal(); + return { + getSession: vi.fn(), + destroyOtherSessions: vi.fn(), + destroySessionById: vi.fn(), + sessionHandle: actual.sessionHandle, + }; +}); vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn(async () => "Berlin, DE"), @@ -39,6 +49,7 @@ import { getSession, destroyOtherSessions, destroySessionById, + sessionHandle, } from "@/lib/auth/session"; const SESSION_OK = { @@ -90,7 +101,6 @@ describe("GET /api/auth/me/sessions", () => { const list = body.data.sessions; expect(list).toHaveLength(2); expect(list[0]).toMatchObject({ - id: "sess-current", device: "Firefox on macOS", ipMasked: "203.0.x.x", location: "Berlin, DE", @@ -100,6 +110,42 @@ describe("GET /api/auth/me/sessions", () => { // The full IP never appears in the response body. expect(JSON.stringify(body)).not.toContain("203.0.113.7"); }); + + it("never puts a session row id in the response body", async () => { + // A row id created before the secret cookie landed IS that session's + // cookie value, so listing it hands the client working logins for the + // account's other devices. The list carries an opaque handle instead; + // it is what the revoke route accepts. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.session.findMany).mockResolvedValue([ + { + id: "sess-current", + ipAddress: "203.0.113.7", + userAgent: "Mozilla/5.0 (Macintosh) Firefox/120.0", + lastActiveAt: new Date(), + createdAt: new Date(), + }, + { + id: "sess-other", + ipAddress: "198.51.100.4", + userAgent: "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0", + lastActiveAt: null, + createdAt: new Date(), + }, + ] as never); + + const res = await GET(); + const body = await res.json(); + const serialised = JSON.stringify(body); + + expect(serialised).not.toContain("sess-current"); + expect(serialised).not.toContain("sess-other"); + // Still identified well enough to act on: distinct, stable handles. + const [a, b] = body.data.sessions.map((s: { id: string }) => s.id); + expect(a).toBeTruthy(); + expect(a).not.toBe(b); + expect(a).toBe(sessionHandle("sess-current")); + }); }); describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts index bb3fcd05e..440f4d44a 100644 --- a/src/app/api/auth/me/sessions/route.ts +++ b/src/app/api/auth/me/sessions/route.ts @@ -18,7 +18,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { getClientIp } from "@/lib/api-response"; import { prisma } from "@/lib/db"; -import { destroyOtherSessions } from "@/lib/auth/session"; +import { destroyOtherSessions, sessionHandle } from "@/lib/auth/session"; import { lookupIpLocation } from "@/lib/geo"; import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint"; @@ -68,7 +68,7 @@ export const GET = apiHandler(async () => { ); const result: SessionDTO[] = sessions.map((s) => ({ - id: s.id, + id: sessionHandle(s.id), device: coarseDeviceLabel(s.userAgent), ipMasked: maskIp(s.ipAddress), location: s.ipAddress ? (locationByIp.get(s.ipAddress) ?? null) : null, diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 88820489b..4cb7839b2 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -401,16 +401,48 @@ export async function destroyOtherSessions( } /** - * v1.23 — revoke a single web session by id, scoped to the owning user so a - * caller can never delete another user's session row. Returns whether a row - * was actually removed (false → not found or not owned). + * The public handle for a session row, used by the session-management list + * and its revoke route. + * + * The list needs a per-row identifier the client can send back to revoke one + * device. It used to send the row id — which, for every session created + * before the secret cookie landed, IS that session's cookie value. The list + * was therefore handing the client live credentials for all of the account's + * other devices, and anything that captured one response body (an error + * reporter, a proxy log, a screenshot of devtools) captured usable logins. + * + * The handle is an HMAC of the row id: stable within a deployment so the + * client can round-trip it, and structurally useless as a cookie because the + * legacy lookup matches a cuid primary key and a 64-char hex digest is not + * one. Nothing is stored — the handle is recomputed per request, so this + * needs no column and no migration. + */ +export function sessionHandle(sessionId: string): string { + return hashToken(sessionId); +} + +/** + * v1.23 — revoke a single web session, scoped to the owning user so a caller + * can never delete another user's session row. Returns whether a row was + * actually removed (false → not found or not owned). + * + * Takes the public handle rather than the row id. The account's own rows are + * a handful, so resolving the handle by walking them costs one indexed read + * and a few HMACs — cheaper than a column, and it keeps the id server-side. */ export async function destroySessionById( userId: string, - sessionId: string, + handle: string, ): Promise { + const own = await prisma.session.findMany({ + where: { userId }, + select: { id: true }, + }); + const match = own.find((s) => sessionHandle(s.id) === handle); + if (!match) return false; + const result = await prisma.session.deleteMany({ - where: { id: sessionId, userId }, + where: { id: match.id, userId }, }); return result.count > 0; }