diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 0d4ca9eb0..509ac3351 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -529,13 +529,31 @@ token is the only credential, so it is built to be harmless on its own: answer, so it cannot be moved to another account or another answer; - it expires six hours after the reminder was sent; - the three answers of one reminder are one use between them: the first - tap logs, every later tap on that reminder is refused; + tap logs, every later tap on that reminder is refused, and that stays + true when the logged entry is deleted and purged from the trash before + the six hours are up; - the route is rate-limited before it reads the token, refuses when the mood module has been turned off, and the token is scrubbed from logs. The buttons need `APP_URL`, because the ntfy app calls the URL from your phone. Your phone has to reach the instance at that address. +What the token cannot protect against is the topic itself. The button URLs, +token included, travel through your ntfy server inside the message, and the +message is delivered to everyone subscribed to the topic. For the six hours +a token is valid, anyone who can read the topic can log that one answer on +your behalf. On the public `ntfy.sh` the topic name is the only access +control, so: + +- use a long, random topic name and do not share it; +- better, run your own ntfy server with access control and give HealthLog + an access token that may only publish to your topic; +- to invalidate every quick-answer token at once, rotate + `API_TOKEN_HMAC_KEY`. The same key also hashes API tokens and session + cookies, so rotating it invalidates every issued API token and signs out + every browser session and the iOS app as well (see + [getting started](./getting-started.md)). + **Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5 scale, a note and a "later" button. diff --git a/src/app/api/notifications/action/[token]/__tests__/route.test.ts b/src/app/api/notifications/action/[token]/__tests__/route.test.ts index a77eda6cb..728fc42eb 100644 --- a/src/app/api/notifications/action/[token]/__tests__/route.test.ts +++ b/src/app/api/notifications/action/[token]/__tests__/route.test.ts @@ -10,7 +10,10 @@ import { NextRequest } from "next/server"; const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: vi.fn() } }, + prisma: { + user: { findUnique: vi.fn() }, + idempotencyKey: { create: vi.fn(), deleteMany: vi.fn() }, + }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/rate-limit", () => ({ @@ -67,6 +70,10 @@ beforeEach(() => { timezone: "Europe/Berlin", } as never); vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never); + vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({ + count: 1, + } as never); vi.mocked(logTelegramMood).mockResolvedValue({ created: true, moodEntryId: "m1", @@ -151,4 +158,42 @@ describe("POST /api/notifications/action/{token}", () => { const res = await call(token({ action: "mood-bad" })); expect(res.status).toBe(409); }); + + it("refuses a spent nonce with 409 before writing anything", async () => { + const { Prisma } = await import("@/generated/prisma/client"); + vi.mocked(prisma.idempotencyKey.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("unique", { + code: "P2002", + clientVersion: "test", + }), + ); + const res = await call(token()); + expect(res.status).toBe(409); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("claims the nonce until the token expires", async () => { + const now = Date.now(); + await call(token({ now })); + expect(prisma.idempotencyKey.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user-1", + key: "notification-action:nonce-1", + requestFingerprint: null, + expiresAt: new Date(now + ACTION_TOKEN_TTL_MS), + }), + }); + }); + + it("gives the claim back when the write fails for another reason", async () => { + vi.mocked(logTelegramMood).mockRejectedValue(new Error("db down")); + const res = await call(token()); + expect(res.status).toBe(500); + expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + userId: "user-1", + key: "notification-action:nonce-1", + }), + }); + }); }); diff --git a/src/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts index f10eb613a..cbf074576 100644 --- a/src/app/api/notifications/action/[token]/route.ts +++ b/src/app/api/notifications/action/[token]/route.ts @@ -13,10 +13,12 @@ * 2. Verify the signature (constant-time) and the expiry. * 3. Re-check what may have changed since the reminder went out: the * account still exists and its mood module is still on. - * 4. Write the mood entry under the reminder's nonce. The unique - * `(userId, source, externalId)` key makes the first answer the only + * 4. Claim the reminder's nonce (`action-nonce.ts`), then write the mood + * entry under it. The claim and the entry's unique + * `(userId, source, externalId)` key each make the first answer the only * one: a repeat tap, or a different answer on the same reminder, is - * refused with 409 and writes nothing. + * refused with 409 and writes nothing. The claim is what still holds + * after the entry was deleted and purged inside the token's lifetime. * * The token travels in the path, so it is scrubbed from every log line by * the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the @@ -31,6 +33,10 @@ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; import { logTelegramMood } from "@/lib/mood/create-from-telegram"; +import { + claimActionNonce, + releaseActionNonce, +} from "@/lib/notifications/action-nonce"; import { MOOD_QUICK_ACTIONS, quickActionExternalId, @@ -116,6 +122,23 @@ export const POST = apiHandler( }); } + const alreadyUsed = () => { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "already_used" }, + }); + return apiError("This reminder was already answered", 409, { + errorCode: "notification.action.already_used", + }); + }; + + const claimed = await claimActionNonce({ + userId: user.id, + nonce: verdict.nonce, + expiry: verdict.expiry, + }); + if (!claimed) return alreadyUsed(); + const score = MOOD_QUICK_ACTIONS[verdict.action]; let created: boolean; try { @@ -129,19 +152,15 @@ export const POST = apiHandler( created = result.created; } catch (err) { // Two taps raced past the existence check; the unique key let one in. - if (!isUniqueViolation(err)) throw err; + if (!isUniqueViolation(err)) { + // Nothing was written: give the answer back so a retry can land. + await releaseActionNonce({ userId: user.id, nonce: verdict.nonce }); + throw err; + } created = false; } - if (!created) { - annotate({ - action: { name: "notification.action.refused" }, - meta: { reason: "already_used" }, - }); - return apiError("This reminder was already answered", 409, { - errorCode: "notification.action.already_used", - }); - } + if (!created) return alreadyUsed(); annotate({ action: { name: "notification.action.mood_logged" }, diff --git a/src/lib/notifications/action-nonce.ts b/src/lib/notifications/action-nonce.ts new file mode 100644 index 000000000..df0e49a8b --- /dev/null +++ b/src/lib/notifications/action-nonce.ts @@ -0,0 +1,71 @@ +/** + * The record that a quick-action nonce was spent. + * + * A reminder's answers are single-use through the mood entry they write: its + * unique `(userId, source, externalId)` key refuses a second entry under the + * same nonce. That holds only while the entry exists. A user who answers, + * then deletes the entry and purges it from the trash inside the token's + * six hours, frees the key, and the same notification button would write a + * second entry. So the nonce is claimed on its own as well, in the + * idempotency table, which already holds one-shot claims under a unique key + * and is swept by expiry (`idempotency-cleanup.ts`). The claim expires with + * the token, so the table holds it exactly as long as it matters. + * + * The row can never be read back as a cached response: its method is not an + * HTTP verb, its path names no route, and its fingerprint is null, which + * `withIdempotency` never replays. + */ +import { prisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; +import { quickActionExternalId } from "@/lib/notifications/action-token"; + +const CLAIM_METHOD = "NOTIFICATION_ACTION"; +const CLAIM_PATH = "/api/notifications/action"; +const CLAIM_STATUS = 0; + +/** + * Claim a reminder's nonce. `true` when this call won it, `false` when the + * nonce was already spent (by an earlier answer or a concurrent one). + */ +export async function claimActionNonce(input: { + userId: string; + nonce: string; + expiry: number; +}): Promise { + try { + await prisma.idempotencyKey.create({ + data: { + userId: input.userId, + key: quickActionExternalId(input.nonce), + method: CLAIM_METHOD, + path: CLAIM_PATH, + requestFingerprint: null, + responseStatus: CLAIM_STATUS, + responseBody: "", + expiresAt: new Date(input.expiry), + }, + }); + return true; + } catch (err) { + if (isP2002(err)) return false; + throw err; + } +} + +/** + * Give a claim back when the write it guarded failed for a reason other than + * the nonce being spent, so the person can tap the answer again. + */ +export async function releaseActionNonce(input: { + userId: string; + nonce: string; +}): Promise { + await prisma.idempotencyKey.deleteMany({ + where: { + userId: input.userId, + key: quickActionExternalId(input.nonce), + method: CLAIM_METHOD, + path: CLAIM_PATH, + }, + }); +} diff --git a/tests/integration/notification-action-mood.test.ts b/tests/integration/notification-action-mood.test.ts index ad7ba364f..0806e8390 100644 --- a/tests/integration/notification-action-mood.test.ts +++ b/tests/integration/notification-action-mood.test.ts @@ -100,4 +100,19 @@ describe("POST /api/notifications/action/{token}", () => { await getPrismaClient().moodEntry.count({ where: { userId: USER } }), ).toBe(2); }); + + it("refuses the reminder again after its entry was deleted and purged", async () => { + const { good, bad } = await tokens("reminder-purged"); + expect((await call(good)).status).toBe(200); + + // Delete and purge inside the token's lifetime: the row that held the + // unique key is gone. + await getPrismaClient().moodEntry.deleteMany({ where: { userId: USER } }); + + expect((await call(good)).status).toBe(409); + expect((await call(bad)).status).toBe(409); + expect( + await getPrismaClient().moodEntry.count({ where: { userId: USER } }), + ).toBe(0); + }); });