mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
- Normalize IPv6 literals before transition-prefix inspection - Reject mapped, compatible, 6to4, and NAT64 private embeddings - Share the verdict with pinned public and operator dispatchers
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
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),
|
||
}),
|
||
});
|