diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 48275d773..9ce67b0e9 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -17,8 +17,9 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, apiError } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; import { predictCycle, type NightlyTempInput } from "@/lib/cycle"; import { LUTEAL_DEFAULT } from "@/lib/cycle/types"; import { @@ -54,6 +55,14 @@ export const GET = apiHandler(async () => { if (!gate.enabled) return gate.response; const profile = gate.profile; + // This route runs the FDR phase-contrast over a 365-day window — the most + // compute-heavy read in the cycle vertical. Cap repeated hits so a single + // authenticated session can't spin it for a self-inflicted compute DoS. + const rl = await checkRateLimit(`cycle:insights:${user.id}`, 30, 60_000); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + const tz = user.timezone ?? DEFAULT_TIMEZONE; const today = moodDateKey(new Date(), tz); const from = addDays(today, -WINDOW_DAYS); diff --git a/src/app/api/notifications/web-push/route.ts b/src/app/api/notifications/web-push/route.ts index 76371982b..45ca81ba5 100644 --- a/src/app/api/notifications/web-push/route.ts +++ b/src/app/api/notifications/web-push/route.ts @@ -4,15 +4,15 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { encrypt } from "@/lib/crypto"; +import { webPushSubscriptionSchema } from "@/lib/validations/notifications"; import { z } from "zod/v4"; -const subscribeSchema = z.object({ - endpoint: z.string().url(), - keys: z.object({ - p256dh: z.string().min(1), - auth: z.string().min(1), - }), -}); +// The endpoint is later dialled by `web-push`, which does its own internal +// fetch that bypasses safeFetch — so the SSRF floor has to be enforced here +// at input time. `webPushSubscriptionSchema` requires https + isPublicUrl +// (blocks loopback/link-local/metadata/internal hosts). The sender applies +// the same check again at egress time as defence-in-depth. +const subscribeSchema = webPushSubscriptionSchema; const unsubscribeSchema = z.object({ endpoint: z.string().url(), diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index c7a13f81f..c0dba549c 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -6,6 +6,7 @@ import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getVapidConfig } from "@/lib/notifications/vapid-config"; import { getEvent } from "@/lib/logging/context"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { isPublicUrl } from "@/lib/validations/notifications"; /** * Send Web Push notification to all subscribed devices of a user. @@ -95,6 +96,16 @@ export async function sendViaWebPush( for (const sub of subscriptions) { try { + // Egress-time SSRF floor: `web-push` dials this endpoint with its own + // internal fetch (no safeFetch). The subscribe route already rejects + // non-public endpoints, but re-check here to cover any row stored + // before that guard landed. A non-public endpoint is dropped, never + // dialled — treat like an expired subscription. + if (!isPublicUrl(sub.endpoint)) { + expiredIds.push(sub.id); + continue; + } + const p256dh = decrypt(sub.p256dh); const auth = decrypt(sub.auth);