mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
7ecc3e769c
commit
212e8c9c04
3 files changed
+28
-8
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user