feat(02-11): secure guardian notification delivery

- Remove record-mutating affordances from Guardian channel payloads\n- Reject managed Telegram callbacks and verify channel attribution
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:44:41 +02:00
1 parent accb98e223
commit 60b4845fea
8 files changed
+276 -41

No files matched your search

@@ -6,23 +6,15 @@ describe("managed Guardian notification payload safety", () => {
it("recognises only cross-account record delivery as managed Guardian delivery", () => {
expect(
isManagedGuardianDelivery({
eventType: "MEDICATION_REMINDER",
userId: "record-1",
recordUserId: "record-1",
recipientUserId: "guardian-1",
title: "t",
message: "m",
}),
).toBe(true);
expect(
isManagedGuardianDelivery({
eventType: "MEDICATION_REMINDER",
userId: "user-1",
recordUserId: "user-1",
recipientUserId: "user-1",
title: "t",
message: "m",
}),
).toBe(false);
});
@@ -165,7 +165,7 @@ describe("web-push reminder push — stable tag + badge", () => {
expect(body.tag).toBe("REMINDER");
});
it("uses an ordinary landing route without a persistent prompt for a managed Guardian", async () => {
it("uses an ordinary landing route without actions for a managed Guardian", async () => {
findManyMock.mockResolvedValue([SUB]);
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 201 }),
@@ -186,7 +186,7 @@ describe("web-push reminder push — stable tag + badge", () => {
generateRequestDetailsMock.mock.calls[0][1] as string,
);
expect(body.url).toBe("/");
expect(body.requireInteraction).toBe(false);
expect(body.actions).toBeUndefined();
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { NotificationPayload } from "@/lib/notifications/types";
/**
* A cross-account notification can only reach a Guardian after the
* dispatcher has revalidated the marked record and its active MANAGE grant.
* Senders use this narrow invariant to remove record-mutating affordances.
*/
export function isManagedGuardianDelivery(
payload: Pick<NotificationPayload, "recordUserId" | "recipientUserId">,
): boolean {
return (
payload.recordUserId !== undefined &&
payload.recipientUserId !== undefined &&
payload.recordUserId !== payload.recipientUserId
);
}
@@ -620,7 +620,6 @@ describe("sendViaApns — dispatcher fan-out", () => {
title: "t",
message: "m",
eventType: "MEDICATION_REMINDER",
userId: "record-1",
recordUserId: "record-1",
recipientUserId: "guardian-1",
metadata: {
+11 -7
View File
@@ -31,6 +31,7 @@ import { getEvent } from "@/lib/logging/context";
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { plainPushText } from "@/lib/notifications/strip-emoji";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
export interface ApnsPayload {
/** APNs `aps.alert.title` + `aps.alert.body`. */
@@ -628,10 +629,7 @@ export async function sendViaApns(
process.env.APNS_CRITICAL_ENTITLEMENT === "true";
let result: Awaited<ReturnType<typeof sendApnsPush>> | null = null;
const managedDelivery =
payload.recordUserId !== undefined &&
payload.recipientUserId !== undefined &&
payload.recordUserId !== payload.recipientUserId;
const managedDelivery = isManagedGuardianDelivery(payload);
// Discreet mode (cycle privacy): the lock-screen-visible routing
// metadata must not carry the event name. Collapse the category /
// threadId / collapseId and the data eventType to a generic value so
@@ -671,10 +669,16 @@ export async function sendViaApns(
// In discreet mode also drop cycle-semantic metadata (e.g. `phase`)
// so a future cycle push can't name a cycle concept on the wire even
// though the allowlist permits `phase` (QA LOW).
// A Guardian may read a managed record but must never receive
// an iOS action identifier or deep link that can mutate it.
// Keep the ordinary event envelope so opening the notification
// follows the normal authorised record-selection flow.
...pickIosMetadata(
payload.discreet
? stripCycleMetadata(payload.metadata)
: payload.metadata,
managedDelivery
? undefined
: payload.discreet
? stripCycleMetadata(payload.metadata)
: payload.metadata,
),
},
threadId: routingEvent,
@@ -9,6 +9,7 @@ import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-at
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
/**
* Send Web Push notification to all subscribed devices of a user.
@@ -30,6 +31,7 @@ export async function sendViaWebPush(
payload: NotificationPayload,
): Promise<SendOutcome> {
try {
const managedDelivery = isManagedGuardianDelivery(payload);
// Lazy import to avoid issues when web-push is not installed
const webpush = await import("web-push");
@@ -116,6 +118,7 @@ export async function sendViaWebPush(
// target. Only relative paths are honoured so a poisoned metadata
// value can never point the SW at a foreign origin.
url:
!managedDelivery &&
typeof payload.metadata?.url === "string" &&
payload.metadata.url.startsWith("/")
? payload.metadata.url
+41
View File
@@ -107,6 +107,30 @@ async function cleanupReminderTracking(
}
}
/**
* A managed-record reminder is tracked under the Guardian recipient so a
* replacement can delete the prior message. Its callback must never mutate
* the managed record, even if a crafted Telegram update supplies action data.
*/
async function isManagedReminderCallback(input: {
recipientUserId: string;
chatId: string;
messageId: number;
}): Promise<boolean> {
const reminder = await prisma.telegramReminderMessage.findFirst({
where: {
recipientUserId: input.recipientUserId,
chatId: input.chatId,
messageId: input.messageId,
},
select: { medication: { select: { userId: true } } },
});
return (
reminder?.medication?.userId !== undefined &&
reminder.medication.userId !== input.recipientUserId
);
}
// v1.19.0 — the auto-delete window dropped from 1 h to ~30 min and moved to
// the shared `scheduleTelegramAutoDelete` helper so the inbound (free-text)
// and outbound (unanswered prompt) paths agree on the window.
@@ -379,6 +403,23 @@ export async function handleCallback(update: TelegramUpdate) {
const data = callback.data ?? "";
const messageId = callback.message?.message_id;
if (
messageId !== undefined &&
/^(taken|snooze|skip|ack):/.test(data) &&
(await isManagedReminderCallback({
recipientUserId: user.id,
chatId,
messageId,
}))
) {
await answerTelegramCallbackQuery(
botToken,
callback.id,
t("telegram.errorInvalidAction"),
);
return;
}
if (data.startsWith("taken:")) {
const medicationId = data.slice("taken:".length).trim();
if (!medicationId) {