fix(notifications): keep a quick answer single-use after its entry is purged and document the topic exposure

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:46:12 +02:00
1 parent 4ff9aa7e39
commit 5dbc6fcec1
5 files changed
+183 -15

No files matched your search

+19 -1
View File
@@ -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.
@@ -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",
}),
});
});
});
@@ -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" },
+71
View File
@@ -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<boolean> {
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<void> {
await prisma.idempotencyKey.deleteMany({
where: {
userId: input.userId,
key: quickActionExternalId(input.nonce),
method: CLAIM_METHOD,
path: CLAIM_PATH,
},
});
}
@@ -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);
});
});