feat(security): SSRF strict IPv4, PAT logging redaction, threshold rate-limit, N+1 fix, pg-boss SIGTERM, blocking CI

Security hardening from 2026-04-26 audit:

- SEC-002: isPublicUrl() now uses a strict IPv4 parser that rejects
  leading-zero forms like 010.0.0.1, which historically slipped past
  the h.startsWith('10.') check. Also covers CGNAT (100.64/10),
  IPv6 unique-local (fc00::/7), and additional metadata-service edge
  cases. Documented as input-time only (DNS rebinding still possible
  at fetch time).
- SEC-004: GitHub PAT is now redacted from logged error bodies in
  publish-github.ts before flowing to Wide Events / Loki. Adds an
  escapeRegex helper for safe RegExp construction.
- SEC-006: PUT /api/user/thresholds now goes through checkRateLimit
  (30 writes / 5 min, per-user) — audit-log was already there but
  unbounded. Makes audit-log enumeration unattractive.
- PERF-001: insights/comprehensive route now batches medication-intake
  events into a single 'medicationId IN (...)' query and groups in
  memory instead of N+1 per medication. Latency was scaling poorly
  for users with many active meds.
- REL-003: pg-boss now installs SIGTERM/SIGINT handlers that call
  boss.stop({ graceful: true, timeout: 30s }) so in-flight handlers
  drain on docker stop / coolify redeploy instead of being killed.
- LOG-001: Replaced console.log/error in reminder-worker with a
  workerLog helper. Lifecycle errors still go to stderr (only audience
  outside any handler context); routine init messages are silent
  because pg-boss own events + Wide Events from handlers carry the
  state.
- CI: ESLint and TypeScript checks are now blocking — continue-on-error
  removed. Tests already were. Required pre-existing 'any' types in
  api-handler.ts to be properly justified with eslint-disable comments
  (Next.js variadic handler signature; the bound type still constrains
  return to Promise<Response>).
This commit is contained in:
Marc-André Bombeck authored and Marc-André Bombeck committed 2026-04-27 00:28:28 +02:00
1 parent 11ed6faedd
commit 908532be73
7 files changed
+173 -39

No files matched your search

+23 -8
View File
@@ -216,15 +216,30 @@ export const GET = apiHandler(async () => {
const bpMedications = medications.filter(
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
);
// Single round-trip for all medications instead of N+1: one query keyed
// on `medicationId IN (...)`, then group in memory. The previous loop
// hit Postgres once per medication, which scales poorly for users with
// many active meds.
const allEvents = medications.length
? await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: { in: medications.map((m) => m.id) },
userId,
scheduledFor: { gte: ninetyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
})
: [];
const eventsByMed = new Map<string, typeof allEvents>();
for (const ev of allEvents) {
const list = eventsByMed.get(ev.medicationId);
if (list) list.push(ev);
else eventsByMed.set(ev.medicationId, [ev]);
}
for (const med of medications) {
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: med.id,
userId,
scheduledFor: { gte: ninetyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
});
const events = eventsByMed.get(med.id) ?? [];
const mapped = events.map((e) => ({
takenAt: e.takenAt,
skipped: e.skipped,
+12
View File
@@ -12,6 +12,7 @@ import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response"
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import {
getAllEffectiveRanges,
type ThresholdOverridesJson,
@@ -59,6 +60,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.thresholds.update" } });
// 30 writes / 5 min — generous for legitimate UI edits, tight enough to
// make audit-log enumeration unattractive. Per-user, not per-IP.
const rl = await checkRateLimit(
`thresholds:update:${user.id}`,
30,
5 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Too many requests, please slow down", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;