mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(notifications): keep a quick answer single-use after its entry is purged and document the topic exposure
This commit is contained in:
1 parent
4ff9aa7e39
commit
5dbc6fcec1
5 files changed
+183
-15
No files matched your search
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user