fix(security): close authenticated SSRF via Web Push endpoint + rate-limit cycle insights

The Web Push subscribe route validated the endpoint with a bare URL check
while the hardened schema (https + isPublicUrl) sat orphaned, so an
authenticated user could register an endpoint pointing at an internal host
and the dispatcher — which dials it through web-push's own fetch, bypassing
safeFetch — would reach it. Route the subscribe input through the hardened
schema and re-check isPublicUrl at egress time in the sender to cover any
row stored before the fix.

Also cap GET /api/cycle/insights with a per-user rate limit; it runs the
365-day FDR phase contrast, the heaviest read in the cycle vertical.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 16:22:00 +02:00
1 parent 7ecc3e769c
commit 212e8c9c04
3 files changed
+28 -8

No files matched your search

+10 -1
View File
@@ -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);
+7 -7
View File
@@ -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(),
+11
View File
@@ -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);