Files
HealthLog/src/lib/validations/notifications.ts
T
Marc-André Bombeck c8035fdd78 Describe the integration, notification and MCP surfaces in the contract
Sixteen routes the native client already calls were absent from the OpenAPI
registry, so the published spec said nothing about them. `openapi:check`
compares the registry against the YAML and never the routes against the
registry, which is why the gap survived.

Nightscout status / test / disconnect, the WHOOP credential and status reads,
and the Withings status / disconnect pair land in the integrations module. The
three per-channel configs under `/api/settings/*` join the settings module; the
notification preference and delivery-health surfaces get their own module, as do
the MCP connector connections and tokens.

These are credential-bearing surfaces, so the contract states what each response
actually carries. Every channel GET reduces its secret to a boolean, and the
descriptions say so, along with the consequence: a client cannot read the value
back, so an empty value on the way in preserves the stored one rather than
clearing it. `POST /api/mcp/tokens` is the one operation that returns a usable
Bearer, and its description says outright that this is the only copy.

Two inline request schemas move to the validations modules they belong in, so
the registry documents the same object the handler parses rather than a
restatement of it.
2026-08-22 12:21:16 +02:00

350 lines
11 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 { 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(),
});
/**
* Body of `POST /api/notifications/status` — clear the auto-disable on one of
* the caller's own channels. The channel is looked up scoped to the session
* user, so an id alone cannot re-enable somebody else's channel.
*/
export const reEnableChannelSchema = z.object({
channelId: z.string().min(1).max(64),
});
/**
* 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),
}),
});