chore(release): v1.30.33 — the session cookie becomes a secret

Also closes the device list's exposure of the same value, refuses a
cross-device refresh rotation, replaces a vacuous expiry test with one
that fails when the check is removed, and asserts the Nightscout host
guard at save time rather than relying on the probe's placement.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 12:09:17 +02:00
1 parent 03fdcddb92
commit fd9603a578
7 files changed
+126 -16

No files matched your search

+32
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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<typeof import("@/lib/auth/session")>();
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)", () => {
+2 -2
View File
@@ -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,
+37 -5
View File
@@ -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<boolean> {
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;
}