mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
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. 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;
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
|
||
// IPv4 dotted-quad (what dns.lookup with family=4 returns).
|
||
if (/^\d+\.\d+\.\d+\.\d+$/.test(lower)) {
|
||
const parsed = parseIpv4Strict(lower);
|
||
if (!parsed) return false;
|
||
return !isPrivateIpv4(parsed);
|
||
}
|
||
|
||
// IPv6. Only strings with a colon make it past the previous check.
|
||
if (lower.includes(":")) {
|
||
if (lower === "::1" || lower === "::") return false;
|
||
if (lower.startsWith("fe80:")) return false;
|
||
if (/^fc[0-9a-f]{0,2}:/.test(lower)) return false;
|
||
if (/^fd[0-9a-f]{0,2}:/.test(lower)) return false;
|
||
|
||
// IPv4-mapped IPv6 dotted form ("::ffff:127.0.0.1"). Some resolvers
|
||
// emit this when an AAAA falls through to a synthesized A.
|
||
const mappedDotted = lower.match(/^(?:::ffff:)(\d+\.\d+\.\d+\.\d+)$/);
|
||
if (mappedDotted) {
|
||
const parsed = parseIpv4Strict(mappedDotted[1]);
|
||
if (!parsed || isPrivateIpv4(parsed)) return false;
|
||
return true;
|
||
}
|
||
|
||
// IPv4-mapped IPv6 hex pair form ("::ffff:7f00:1").
|
||
const mappedHex = lower.match(
|
||
/^(?:::ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/,
|
||
);
|
||
if (mappedHex) {
|
||
const high = parseInt(mappedHex[1], 16);
|
||
const low = parseInt(mappedHex[2], 16);
|
||
const parsed: [number, number, number, number] = [
|
||
(high >> 8) & 0xff,
|
||
high & 0xff,
|
||
(low >> 8) & 0xff,
|
||
low & 0xff,
|
||
];
|
||
if (isPrivateIpv4(parsed)) return false;
|
||
return true;
|
||
}
|
||
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;
|
||
}
|
||
|
||
// IPv6 loopback / unspecified / link-local / unique-local. Must be
|
||
// gated on a colon — the previous `startsWith("fc")` falsely blocked
|
||
// any DNS hostname starting with "fc" or "fd" (e.g. fcm.googleapis.com).
|
||
if (h === "::1" || h === "::") {
|
||
return false;
|
||
}
|
||
if (h.includes(":")) {
|
||
if (
|
||
h.startsWith("fe80:") ||
|
||
/^fc[0-9a-f]{0,2}:/.test(h) ||
|
||
/^fd[0-9a-f]{0,2}:/.test(h)
|
||
) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// IPv4-mapped IPv6 ("::ffff:127.0.0.1" or "::ffff:7f00:1") and
|
||
// 6to4 / NAT64 with embedded private IPv4. Extract the trailing
|
||
// dotted-quad if present, otherwise extract the last 32 bits as
|
||
// hex pairs and reconstruct.
|
||
if (h.includes(":")) {
|
||
const ipv4MappedDotted = h.match(/^(?:::ffff:)(\d+\.\d+\.\d+\.\d+)$/);
|
||
if (ipv4MappedDotted) {
|
||
const ip = parseIpv4Strict(ipv4MappedDotted[1]);
|
||
if (!ip || isPrivateIpv4(ip)) return false;
|
||
}
|
||
const ipv4MappedHex = h.match(
|
||
/^(?:::ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/,
|
||
);
|
||
if (ipv4MappedHex) {
|
||
const high = parseInt(ipv4MappedHex[1], 16);
|
||
const low = parseInt(ipv4MappedHex[2], 16);
|
||
const ip: [number, number, number, number] = [
|
||
(high >> 8) & 0xff,
|
||
high & 0xff,
|
||
(low >> 8) & 0xff,
|
||
low & 0xff,
|
||
];
|
||
if (isPrivateIpv4(ip)) return false;
|
||
}
|
||
}
|
||
|
||
// Strict IPv4 parsing: reject malformed forms outright (better a false
|
||
// positive than an SSRF) and apply RFC1918 / CGNAT / loopback bans.
|
||
if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) {
|
||
const ip = parseIpv4Strict(h);
|
||
if (!ip) return false;
|
||
if (isPrivateIpv4(ip)) return false;
|
||
}
|
||
|
||
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),
|
||
}),
|
||
});
|