mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
test(security): add SSRF guard tests + measurement validation tests
- New unit tests for isPublicUrl covering loopback, link-local, RFC1918, CGNAT, IPv6 ranges, and the leading-zero IPv4 octal-bypass regression (URL parser silently turns '010.0.0.1' into '8.0.0.1' — caught now). - Strengthened isPublicUrl: pre-URL leading-zero check on the raw input, bracket-aware IPv6, deny on malformed IPv4 quads instead of falling through to 'public' (better safe than sorry on user-supplied URLs). - New unit tests for measurement validation covering the new TOTAL_BODY_WATER and BONE_MASS types: ranges, glucoseContext rejection on non-glucose types, and unrecognised type rejection.
This commit is contained in:
1 parent
3de2e37ed5
commit
ef3e37b7da
3 files changed
+280
-12
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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("")),
|
||||
|
||||
Reference in new issue
Block a user