diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts new file mode 100644 index 000000000..6eff3f10c --- /dev/null +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + createMeasurementSchema, + getUnitForType, + validateMeasurementRange, +} from "../measurement"; + +describe("measurement validation", () => { + describe("getUnitForType", () => { + it("returns canonical unit for each measurement type", () => { + expect(getUnitForType("WEIGHT")).toBe("kg"); + expect(getUnitForType("BLOOD_PRESSURE_SYS")).toBe("mmHg"); + expect(getUnitForType("BLOOD_PRESSURE_DIA")).toBe("mmHg"); + expect(getUnitForType("PULSE")).toBe("bpm"); + expect(getUnitForType("BODY_FAT")).toBe("%"); + expect(getUnitForType("SLEEP_DURATION")).toBe("hours"); + expect(getUnitForType("ACTIVITY_STEPS")).toBe("steps"); + expect(getUnitForType("BLOOD_GLUCOSE")).toBe("mg/dL"); + expect(getUnitForType("TOTAL_BODY_WATER")).toBe("kg"); + expect(getUnitForType("BONE_MASS")).toBe("kg"); + }); + + it("returns 'unknown' for unrecognised types", () => { + expect(getUnitForType("MADE_UP_TYPE")).toBe("unknown"); + }); + }); + + describe("validateMeasurementRange", () => { + it("rejects values below the plausible minimum", () => { + expect(validateMeasurementRange("WEIGHT", 0.5)).toMatch(/zwischen/); + expect(validateMeasurementRange("BONE_MASS", 0.1)).toMatch(/zwischen/); + expect(validateMeasurementRange("TOTAL_BODY_WATER", 1)).toMatch( + /zwischen/, + ); + }); + + it("rejects values above the plausible maximum", () => { + expect(validateMeasurementRange("WEIGHT", 600)).toMatch(/zwischen/); + expect(validateMeasurementRange("BONE_MASS", 12)).toMatch(/zwischen/); + expect(validateMeasurementRange("TOTAL_BODY_WATER", 200)).toMatch( + /zwischen/, + ); + }); + + it("accepts values inside the plausible range", () => { + expect(validateMeasurementRange("WEIGHT", 75)).toBeNull(); + expect(validateMeasurementRange("BONE_MASS", 3.0)).toBeNull(); + expect(validateMeasurementRange("TOTAL_BODY_WATER", 42)).toBeNull(); + }); + + it("returns null for unknown types (no range = no opinion)", () => { + expect(validateMeasurementRange("MADE_UP_TYPE", 9999)).toBeNull(); + }); + }); + + describe("createMeasurementSchema", () => { + const validBase = { + value: 75, + measuredAt: "2026-04-27T08:00:00.000Z", + }; + + it("accepts a TOTAL_BODY_WATER measurement without glucoseContext", () => { + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "TOTAL_BODY_WATER", + value: 42, + }); + expect(parsed.success).toBe(true); + }); + + it("accepts a BONE_MASS measurement without glucoseContext", () => { + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "BONE_MASS", + value: 3.2, + }); + expect(parsed.success).toBe(true); + }); + + it("rejects out-of-range values for body composition types", () => { + const tooMuchWater = createMeasurementSchema.safeParse({ + ...validBase, + type: "TOTAL_BODY_WATER", + value: 500, + }); + expect(tooMuchWater.success).toBe(false); + + const tooLittleBone = createMeasurementSchema.safeParse({ + ...validBase, + type: "BONE_MASS", + value: 0.1, + }); + expect(tooLittleBone.success).toBe(false); + }); + + it("rejects glucoseContext on non-glucose measurements", () => { + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "TOTAL_BODY_WATER", + value: 42, + glucoseContext: "FASTING", + }); + expect(parsed.success).toBe(false); + }); + + it("requires glucoseContext on BLOOD_GLUCOSE measurements", () => { + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "BLOOD_GLUCOSE", + value: 95, + }); + expect(parsed.success).toBe(false); + }); + + it("rejects unrecognised measurement types", () => { + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "TBD_NEW_TYPE", + value: 1, + }); + expect(parsed.success).toBe(false); + }); + }); +}); diff --git a/src/lib/validations/__tests__/notifications.test.ts b/src/lib/validations/__tests__/notifications.test.ts new file mode 100644 index 000000000..97595d033 --- /dev/null +++ b/src/lib/validations/__tests__/notifications.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { isPublicUrl } from "../notifications"; + +describe("isPublicUrl SSRF guard", () => { + describe("allows public addresses", () => { + it("real public hostnames", () => { + expect(isPublicUrl("https://api.openai.com/v1")).toBe(true); + expect(isPublicUrl("https://wbsapi.withings.net/v2/oauth2")).toBe(true); + expect(isPublicUrl("http://example.com")).toBe(true); + }); + + it("public IPv4 addresses", () => { + expect(isPublicUrl("https://1.1.1.1")).toBe(true); + expect(isPublicUrl("https://8.8.8.8")).toBe(true); + expect(isPublicUrl("https://93.184.216.34")).toBe(true); // example.com + }); + }); + + describe("blocks loopback and link-local", () => { + it("textual loopback hosts", () => { + expect(isPublicUrl("http://localhost")).toBe(false); + expect(isPublicUrl("http://localhost:8080")).toBe(false); + expect(isPublicUrl("http://thing.localhost")).toBe(false); + expect(isPublicUrl("https://service.internal")).toBe(false); + expect(isPublicUrl("https://router.local")).toBe(false); + }); + + it("IPv4 loopback / 0.0.0.0", () => { + expect(isPublicUrl("http://127.0.0.1")).toBe(false); + expect(isPublicUrl("http://127.255.255.254")).toBe(false); + expect(isPublicUrl("http://0.0.0.0")).toBe(false); + }); + + it("AWS metadata service + link-local 169.254/16", () => { + expect(isPublicUrl("http://169.254.169.254/latest/meta-data/")).toBe( + false, + ); + expect(isPublicUrl("http://169.254.0.1")).toBe(false); + }); + + it("IPv6 loopback and link-local", () => { + expect(isPublicUrl("http://[::1]")).toBe(false); + expect(isPublicUrl("http://[fe80::1]")).toBe(false); + expect(isPublicUrl("http://[fc00::1]")).toBe(false); + expect(isPublicUrl("http://[fd12:3456::1]")).toBe(false); + }); + }); + + describe("blocks RFC1918 + CGNAT", () => { + it("10.0.0.0/8", () => { + expect(isPublicUrl("http://10.0.0.1")).toBe(false); + expect(isPublicUrl("http://10.255.255.254")).toBe(false); + }); + + it("172.16.0.0/12", () => { + expect(isPublicUrl("http://172.16.0.1")).toBe(false); + expect(isPublicUrl("http://172.31.255.254")).toBe(false); + // 172.32 is outside the /12 — should still be public + expect(isPublicUrl("http://172.32.0.1")).toBe(true); + }); + + it("192.168.0.0/16", () => { + expect(isPublicUrl("http://192.168.1.1")).toBe(false); + expect(isPublicUrl("http://192.168.255.254")).toBe(false); + }); + + it("100.64.0.0/10 (CGNAT)", () => { + expect(isPublicUrl("http://100.64.0.1")).toBe(false); + expect(isPublicUrl("http://100.127.255.254")).toBe(false); + // 100.63 is below the range — public + expect(isPublicUrl("http://100.63.0.1")).toBe(true); + }); + }); + + describe("regression: leading-zero IPv4 cannot bypass private checks", () => { + // The old implementation used `h.startsWith("10.")` which treats + // "010.0.0.1" as not-starting-with-"10." and waved it through. + // The strict parser rejects octets with leading zeros entirely. + it("rejects '010.0.0.1' (was a bypass)", () => { + expect(isPublicUrl("http://010.0.0.1")).toBe(false); + }); + + it("rejects '0192.168.1.1'", () => { + expect(isPublicUrl("http://0192.168.1.1")).toBe(false); + }); + + it("rejects '0010.10.10.10'", () => { + expect(isPublicUrl("http://0010.10.10.10")).toBe(false); + }); + + it("rejects out-of-range octets — better safe than sorry", () => { + // 256.0.0.1 looks like an IPv4 quad but is not valid. Rather than + // falling through to "public" we deny — refusing a malformed URL is + // better than risking misinterpretation as an internal address. + expect(isPublicUrl("http://256.0.0.1")).toBe(false); + }); + }); + + describe("rejects bad protocols", () => { + it("file://, ftp://, javascript:", () => { + expect(isPublicUrl("file:///etc/passwd")).toBe(false); + expect(isPublicUrl("ftp://example.com")).toBe(false); + expect(isPublicUrl("javascript:alert(1)")).toBe(false); + }); + + it("invalid URLs", () => { + expect(isPublicUrl("not a url")).toBe(false); + expect(isPublicUrl("")).toBe(false); + }); + }); +}); diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index b9cca2797..78c7d1e7a 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -15,7 +15,9 @@ export const notificationPreferenceSchema = z.object({ * 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 { +function parseIpv4Strict( + host: string, +): [number, number, number, number] | null { const parts = host.split("."); if (parts.length !== 4) return null; const octets: number[] = []; @@ -34,10 +36,13 @@ function parseIpv4Strict(host: string): [number, number, number, number] | null * * 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. + * 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 @@ -46,11 +51,26 @@ function parseIpv4Strict(host: string): [number, number, number, number] | null */ export function isPublicUrl(url: string): boolean { try { + // Pre-URL guard: 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. Reject the raw + // form before parsing to be safe. + const rawHostMatch = url.match(/^[a-z]+:\/\/(?:[^@/]*@)?([^:/?#]+)/i); + const rawHost = rawHostMatch?.[1] ?? ""; + if (/^\d+\.\d+\.\d+\.\d+$/.test(rawHost)) { + // Looks like a dotted-quad with arbitrary-length segments. If any + // segment has a leading zero (which the URL parser treats as octal), + // reject. Also rejects "0010.10.10.10" — bypass surface for octal. + if (/(?:^|\.)0\d/.test(rawHost)) return false; + } + const parsed = new URL(url); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { return false; } - const h = parsed.hostname.toLowerCase(); + let h = parsed.hostname.toLowerCase(); + // Strip IPv6 brackets — Node returns "[::1]", browsers sometimes "::1". + if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1); // Hostname denylist (literal strings — no IP parsing involved). if ( @@ -62,14 +82,24 @@ export function isPublicUrl(url: string): boolean { 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")) { + // IPv6 loopback / link-local / unique-local. + if ( + 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) { + // Strict IPv4 parsing — rejects malformed forms. + // If the host LOOKS like a dotted-quad (4 numeric segments) but the + // strict parser rejects it (out-of-range octet, leading zero, etc.), + // we treat it as suspicious and deny rather than falling through to + // "public" — better a false positive than an SSRF. + if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) { + const ip = parseIpv4Strict(h); + if (!ip) return false; 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 @@ -91,7 +121,10 @@ 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") + .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("")),