Files
HealthLog/src/lib/validations/notifications.ts
T
Marc-André Bombeck 908532be73 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>).
2026-04-27 00:28:28 +02:00

109 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod/v4";
import { EVENT_TYPES } from "@/lib/notifications/types";
export const notificationPreferenceSchema = z.object({
channelId: z.string().min(1),
eventType: z.enum(EVENT_TYPES),
enabled: z.boolean(),
});
/**
* Parse a dotted-quad IPv4 string into normalized octets.
*
* Returns null if the input is not exactly four octets, contains non-digit
* characters, has any octet outside 0–255, or contains leading zeros (which
* some `parseInt` paths historically allowed — bypassing prefix checks like
* `h.startsWith("10.")` because "010.x.x.x" still parses to 10).
*/
function parseIpv4Strict(host: string): [number, number, number, number] | null {
const parts = host.split(".");
if (parts.length !== 4) return null;
const octets: number[] = [];
for (const part of parts) {
if (!/^(0|[1-9][0-9]?|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(part)) {
return null;
}
octets.push(Number(part));
}
return [octets[0], octets[1], octets[2], octets[3]];
}
/**
* Block private/internal network URLs to prevent SSRF. Exported so other
* server-initiated egress (AI providers, webhooks) can reuse it.
*
* Defense layers:
* 1. Protocol allowlist: only http(s).
* 2. Hostname denylist for unicast loopback / link-local / metadata services.
* 3. RFC1918 + CGNAT + 169.254/16 block via strict IPv4 parser
* (rejects leading zeros so "010.0.0.1" cannot bypass the "10." check).
* 4. IPv6 loopback / link-local block.
*
* NOTE: This is an *input-time* check on user-supplied URLs. It does not
* defeat DNS rebinding (where a public hostname later resolves to a private
* IP). For that, pin the resolved IP at fetch time or use an HTTP client
* that blocks redirects to private ranges.
*/
export function isPublicUrl(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return false;
}
const h = parsed.hostname.toLowerCase();
// Hostname denylist (literal strings — no IP parsing involved).
if (
h === "localhost" ||
h.endsWith(".internal") ||
h.endsWith(".local") ||
h.endsWith(".localhost")
) {
return false;
}
// IPv6 loopback / link-local / unique-local. URL.hostname strips brackets.
if (h === "::1" || h === "[::1]" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) {
return false;
}
// Strict IPv4 parsing — rejects leading-zero forms like "010.0.0.1".
const ip = parseIpv4Strict(h);
if (ip) {
const [a, b] = ip;
if (a === 127) return false; // 127.0.0.0/8 loopback
if (a === 0) return false; // 0.0.0.0/8 reserved
if (a === 10) return false; // 10.0.0.0/8 RFC1918
if (a === 169 && b === 254) return false; // 169.254.0.0/16 link-local + AWS metadata
if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12 RFC1918
if (a === 192 && b === 168) return false; // 192.168.0.0/16 RFC1918
if (a === 100 && b >= 64 && b <= 127) return false; // 100.64.0.0/10 CGNAT
// Any other valid IPv4 is treated as public.
}
return true;
} catch {
return false;
}
}
export const ntfySettingsSchema = z.object({
serverUrl: z
.url("Ungültige Server-URL")
.max(200)
.refine((url) => isPublicUrl(url), "Server-URL darf nicht auf interne Netzwerke zeigen")
.optional()
.or(z.literal("")),
topic: z.string().max(100).optional().or(z.literal("")),
authToken: z.string().max(200).optional().or(z.literal("")),
enabled: z.boolean(),
});
export const webPushSubscriptionSchema = z.object({
endpoint: z.url("Ungültiger Endpoint"),
keys: z.object({
p256dh: z.string().min(1),
auth: z.string().min(1),
}),
});