import { z } from "zod/v4"; import { isIP } from "node:net"; 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(), }); export const notificationChannelEnabledSchema = z.strictObject({ 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. Pre-URL leading-zero IPv4 check on the *raw* input, because the * `URL` constructor silently normalises "010.0.0.1" to "8.0.0.1" * (octal interpretation) — a real SSRF bypass on naive checks. * 3. Hostname denylist for unicast loopback / link-local / metadata services. * 4. RFC1918 + CGNAT + 169.254/16 + 127.0.0.0/8 + 0.0.0.0/8 block via * strict IPv4 parser. * 5. IPv6 loopback / link-local / unique-local block (brackets-aware). * * 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. */ function isPrivateIpv4(ip: [number, number, number, number]): boolean { const [a, b] = ip; if (a === 127) return true; // 127.0.0.0/8 loopback if (a === 0) return true; // 0.0.0.0/8 reserved (also covers literal 0.0.0.0) if (a === 10) return true; // 10.0.0.0/8 RFC1918 if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + AWS metadata if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 RFC1918 if (a === 192 && b === 168) return true; // 192.168.0.0/16 RFC1918 if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT return false; } /** * Parse an IPv6 literal into its canonical 16 bytes. * * Node's `net.isIP` supplies the syntax verdict. The small expansion below is * only for policy inspection: comparing normalized bytes avoids the compressed * and mixed-spelling bypasses that string-prefix checks cannot cover. */ function parseIpv6Bytes(input: string): Uint8Array | null { if (isIP(input) !== 6) return null; let value = input.toLowerCase(); const dottedTail = value.match(/(\d+\.\d+\.\d+\.\d+)$/)?.[1]; if (dottedTail) { const ipv4 = parseIpv4Strict(dottedTail); if (!ipv4) return null; const high = ((ipv4[0] << 8) | ipv4[1]).toString(16); const low = ((ipv4[2] << 8) | ipv4[3]).toString(16); value = `${value.slice(0, -dottedTail.length)}${high}:${low}`; } const compressed = value.split("::"); if (compressed.length > 2) return null; const left = compressed[0] ? compressed[0].split(":") : []; const right = compressed.length === 2 && compressed[1] ? compressed[1].split(":") : []; const missing = 8 - left.length - right.length; if ( missing < 0 || (compressed.length === 1 && missing !== 0) || (compressed.length === 2 && missing < 1) ) { return null; } const words = [ ...left, ...Array.from({ length: missing }, () => "0"), ...right, ].map((word) => Number.parseInt(word, 16)); if ( words.length !== 8 || words.some((word) => !Number.isInteger(word) || word < 0 || word > 0xffff) ) { return null; } const bytes = new Uint8Array(16); words.forEach((word, index) => { bytes[index * 2] = word >> 8; bytes[index * 2 + 1] = word & 0xff; }); return bytes; } function bytesEqual( bytes: Uint8Array, offset: number, expected: readonly number[], ): boolean { return expected.every((value, index) => bytes[offset + index] === value); } /** * Extract the embedded IPv4 address from standard transition formats. * * The local-use `64:ff9b:1::/48` layout follows the repository's RFC8215 * fixtures: the first two IPv4 octets occupy bytes 6–7 and the final two * occupy bytes 10–11 around the reserved zero field. */ function embeddedIpv4( bytes: Uint8Array, ): [number, number, number, number] | null { const firstTenZero = bytesEqual(bytes, 0, Array(10).fill(0)); if (firstTenZero && bytes[10] === 0xff && bytes[11] === 0xff) { return [bytes[12], bytes[13], bytes[14], bytes[15]]; } if (bytesEqual(bytes, 0, Array(12).fill(0))) { return [bytes[12], bytes[13], bytes[14], bytes[15]]; } if (bytes[0] === 0x20 && bytes[1] === 0x02) { return [bytes[2], bytes[3], bytes[4], bytes[5]]; } if ( bytesEqual(bytes, 0, [0x00, 0x64, 0xff, 0x9b]) && bytesEqual(bytes, 4, Array(8).fill(0)) ) { return [bytes[12], bytes[13], bytes[14], bytes[15]]; } if ( bytesEqual(bytes, 0, [0x00, 0x64, 0xff, 0x9b, 0x00, 0x01]) && bytes[8] === 0 && bytes[9] === 0 ) { return [bytes[6], bytes[7], bytes[10], bytes[11]]; } return null; } /** * Decide whether a raw IP address string (as returned by `dns.lookup`) * points at a public range — i.e. is safe to dial. * * Used by the pinned dispatcher (issue #217) inside undici's connect * hook to defeat DNS rebinding: even when `isPublicUrl` accepted the * hostname at input time, the resolver can flip between accept and * dispatch. This second check runs against the literal address the * connection would target. * * Accepts: * - dotted-quad IPv4 ("203.0.113.5", "10.0.0.1") * - IPv6 literal ("2001:db8::1", "::1", "fe80::1", "::ffff:127.0.0.1") * * Rejects every range `isPublicUrl` would reject at input time plus * IPv4-mapped IPv6 (resolvers occasionally emit these). */ export function isPublicIp(ip: string): boolean { if (!ip) return false; const lower = ip.toLowerCase(); const family = isIP(lower); if (family === 4) { const parsed = parseIpv4Strict(lower); if (!parsed) return false; return !isPrivateIpv4(parsed); } if (family === 6) { const bytes = parseIpv6Bytes(lower); if (!bytes) return false; // Unspecified, loopback, link-local, and unique-local IPv6. if (bytesEqual(bytes, 0, Array(16).fill(0))) return false; if (bytesEqual(bytes, 0, Array(15).fill(0)) && bytes[15] === 1) { return false; } if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return false; if ((bytes[0] & 0xfe) === 0xfc) return false; const embedded = embeddedIpv4(bytes); if (embedded && isPrivateIpv4(embedded)) return false; return true; } // Anything else (non-IP string, malformed) — refuse rather than // dispatch. return false; } export function isPublicUrl(url: string): boolean { try { // Pre-URL guard #1: the WHATWG URL parser interprets leading-zero IPv4 // octets as octal. "010.0.0.1" silently becomes "8.0.0.1" (public), // bypassing any post-parse "starts with 10." check. const rawHostMatch = url.match(/^[a-z]+:\/\/(?:[^@/]*@)?([^:/?#]+)/i); const rawHost = rawHostMatch?.[1] ?? ""; if (/^\d+\.\d+\.\d+\.\d+$/.test(rawHost)) { // Reject any segment with a leading zero (octal-bypass surface). if (/(?:^|\.)0\d/.test(rawHost)) return false; } // Pre-URL guard #2: hex-notation IPv4 ("http://0x7f.0.0.1" or // "http://0x7f000001") and decimal-notation IPv4 ("http://2130706433" // = 127.0.0.1). The URL parser normalises both into a real IPv4 string // — the post-parse path *would* catch them, but only via Node's parser // behaviour which has historically been inconsistent. Reject the raw // alternate notations outright. if (/^0x[0-9a-f]+(?:\.|$)/i.test(rawHost)) return false; if (/^\d+$/.test(rawHost) && rawHost.length >= 8) { // Pure-decimal IPv4 ("4294967295" max). Only treat as suspicious when // the value could plausibly be an IP — short numeric "hostnames" // would not parse as URLs anyway. return false; } const parsed = new URL(url); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { return false; } let h = parsed.hostname.toLowerCase(); if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1); // Hostname denylist (literal strings). if ( h === "localhost" || h.endsWith(".internal") || h.endsWith(".local") || h.endsWith(".localhost") ) { return false; } // One normalized byte-level verdict for every literal IP. DNS hostnames // deliberately fall through to the pinned resolver boundary. if (isIP(h) !== 0) return isPublicIp(h); 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(), }); /** * Generic-webhook channel settings (v1.17.1). The URL is user-supplied and * must pass the SSRF floor (`isPublicUrl`) at input time; the dispatcher * re-checks it at fetch time via `safeFetch({ requirePublicHost: true })`. The * optional header name/value carry a shared secret (e.g. Gotify token). */ export const webhookSettingsSchema = z.object({ url: z .url("Ungültige Webhook-URL") .max(500) .refine( (url) => isPublicUrl(url), "Webhook-URL darf nicht auf interne Netzwerke zeigen", ) .optional() .or(z.literal("")), headerName: z.string().max(100).optional().or(z.literal("")), headerValue: z.string().max(500).optional().or(z.literal("")), enabled: z.boolean(), }); /** * Email channel settings (v1.17.1). The SMTP transport is operator-configured; * the only per-user value is the recipient address. */ export const emailSettingsSchema = z.object({ recipient: z .email("Ungültige E-Mail-Adresse") .max(254) .optional() .or(z.literal("")), enabled: z.boolean(), }); export const webPushSubscriptionSchema = z.object({ endpoint: z .url("Ungültiger Endpoint") .max(500) .refine( (url) => url.startsWith("https://"), "Endpoint muss HTTPS verwenden", ) .refine( (url) => isPublicUrl(url), "Endpoint darf nicht auf interne Netzwerke zeigen", ), keys: z.object({ p256dh: z.string().min(1), auth: z.string().min(1), }), });