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

+69 -20
View File
@@ -7,31 +7,80 @@ export const notificationPreferenceSchema = z.object({
enabled: z.boolean(),
});
// Block private/internal network URLs to prevent SSRF. Exported so other
// server-initiated egress (AI providers, webhooks) can reuse it.
/**
* 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:")
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return false;
const h = parsed.hostname;
if (
h === "169.254.169.254" ||
h === "127.0.0.1" ||
h === "[::1]" ||
h === "0.0.0.0" ||
h === "localhost" ||
h.startsWith("10.") ||
h.startsWith("192.168.") ||
h.endsWith(".internal") ||
h.endsWith(".local")
)
return false;
// 172.16.0.0/12
if (h.startsWith("172.")) {
const second = parseInt(h.split(".")[1] ?? "0", 10);
if (second >= 16 && second <= 31) 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;