Files
HealthLog/src/lib/validations/__tests__/notifications.test.ts
T
Marc-André Bombeck fb533147d3 test(01-02): add red IPv6 transition classifier matrix
- cover mapped compatible 6to4 and NAT64 private embeddings
- exercise both literal URL and raw DNS address verdicts
- preserve public IPv4 native IPv6 and transition forms
2026-07-29 13:38:11 +02:00

261 lines
9.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isPublicIp,
isPublicUrl,
webPushSubscriptionSchema,
} from "../notifications";
const EMBEDDED_PRIVATE_IPV4_CASES = [
["IPv4-mapped mixed loopback", "::ffff:127.0.0.1"],
["IPv4-mapped compressed RFC1918", "::ffff:a00:1"],
["IPv4-compatible mixed loopback", "::127.0.0.1"],
["IPv4-compatible compressed metadata", "::a9fe:a9fe"],
["6to4 compressed RFC1918", "2002:a00:1::"],
["6to4 expanded metadata", "2002:a9fe:a9fe:0:0:0:0:1"],
["NAT64 WKP mixed RFC1918", "64:ff9b::192.168.1.1"],
["NAT64 WKP compressed RFC1918", "64:ff9b::c0a8:101"],
["RFC8215 local-use compressed RFC1918", "64:ff9b:1:ac10:0:1::"],
["RFC8215 local-use expanded RFC1918", "64:ff9b:1:c0a8:0:101:0:0"],
] as const;
const PUBLIC_ADDRESS_CASES = [
["public IPv4", "8.8.8.8"],
["public native IPv6", "2606:4700:4700::1111"],
["public IPv4-mapped IPv6", "::ffff:8.8.8.8"],
["public IPv4-compatible IPv6", "::808:808"],
["public 6to4 IPv6", "2002:808:808::"],
["public NAT64 WKP IPv6", "64:ff9b::808:808"],
["public RFC8215 local-use IPv6", "64:ff9b:1:808:0:808::"],
] as const;
function literalUrl(ip: string): string {
return ip.includes(":") ? `https://[${ip}]/push` : `https://${ip}/push`;
}
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("does not block DNS labels that happen to start with 'fc'/'fd' (V3 audit regression)", () => {
// The IPv6 unique-local-address check is gated on a colon. A domain
// like fcm.googleapis.com or fd-cdn.example.com previously matched
// `startsWith("fc"/"fd")` and was rejected. Pin the contract.
expect(isPublicUrl("https://fcm.googleapis.com/fcm/send/abc")).toBe(true);
expect(isPublicUrl("https://fd-cdn.example.com")).toBe(true);
expect(isPublicUrl("https://fc.example.com")).toBe(true);
});
it("still blocks real IPv6 unique-local addresses (must contain colon)", () => {
expect(isPublicUrl("http://[fc00::1]")).toBe(false);
expect(isPublicUrl("http://[fd12:3456::1]")).toBe(false);
});
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 / unspecified / link-local / unique-local", () => {
expect(isPublicUrl("http://[::1]")).toBe(false);
expect(isPublicUrl("http://[::]")).toBe(false);
expect(isPublicUrl("http://[fe80::1]")).toBe(false);
expect(isPublicUrl("http://[fc00::1]")).toBe(false);
expect(isPublicUrl("http://[fd12:3456::1]")).toBe(false);
});
it("IPv4-mapped IPv6 (::ffff:...) embedding private IPv4", () => {
// Both notations parsers might emit:
expect(isPublicUrl("http://[::ffff:127.0.0.1]")).toBe(false);
expect(isPublicUrl("http://[::ffff:10.0.0.1]")).toBe(false);
expect(isPublicUrl("http://[::ffff:192.168.1.1]")).toBe(false);
expect(isPublicUrl("http://[::ffff:7f00:1]")).toBe(false); // hex form of 127.0.0.1
expect(isPublicUrl("http://[::ffff:c0a8:0101]")).toBe(false); // hex form of 192.168.1.1
});
});
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("regression: alternate IPv4 notations cannot bypass private checks", () => {
it("hex IPv4 (0x7f.0.0.1 = 127.0.0.1)", () => {
expect(isPublicUrl("http://0x7f.0.0.1")).toBe(false);
expect(isPublicUrl("http://0x7f000001")).toBe(false);
expect(isPublicUrl("http://0xa.0.0.1")).toBe(false); // 10.0.0.1
});
it("decimal IPv4 (2130706433 = 127.0.0.1)", () => {
expect(isPublicUrl("http://2130706433")).toBe(false);
expect(isPublicUrl("http://167772161")).toBe(false); // 10.0.0.1
expect(isPublicUrl("http://3232235777")).toBe(false); // 192.168.1.1
});
});
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);
});
});
});
describe("canonical IPv4-embedded IPv6 transition matrix (SEC-03)", () => {
it.each(EMBEDDED_PRIVATE_IPV4_CASES)(
"rejects %s at both literal URL and raw-IP boundaries",
(_label, ip) => {
expect.soft(isPublicIp(ip)).toBe(false);
expect(isPublicUrl(literalUrl(ip))).toBe(false);
},
);
it.each(PUBLIC_ADDRESS_CASES)(
"preserves %s at both literal URL and raw-IP boundaries",
(_label, ip) => {
expect.soft(isPublicIp(ip)).toBe(true);
expect(isPublicUrl(literalUrl(ip))).toBe(true);
},
);
it.each(["not-an-ip", "2001:::1", "12345::1"])(
"denies invalid raw IP syntax: %s",
(ip) => {
expect(isPublicIp(ip)).toBe(false);
},
);
it.each(["::", "::1", "fe80::1", "fd12:3456::1"])(
"continues denying ordinary IPv6 special-use address %s",
(ip) => {
expect.soft(isPublicIp(ip)).toBe(false);
expect(isPublicUrl(literalUrl(ip))).toBe(false);
},
);
});
// V3 audit: webPushSubscriptionSchema previously accepted any URL,
// allowing an authenticated user to point Push delivery at a private
// network (RFC1918 / link-local / loopback) → blind SSRF probe.
describe("webPushSubscriptionSchema SSRF guard (V3 audit)", () => {
const keys = { p256dh: "abc", auth: "def" };
it("accepts a real public HTTPS endpoint", () => {
const r = webPushSubscriptionSchema.safeParse({
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
keys,
});
expect(r.success).toBe(true);
});
it("rejects an HTTP endpoint", () => {
const r = webPushSubscriptionSchema.safeParse({
endpoint: "http://fcm.googleapis.com/fcm/send/abc",
keys,
});
expect(r.success).toBe(false);
});
it("rejects an internal RFC1918 endpoint over https", () => {
const r = webPushSubscriptionSchema.safeParse({
endpoint: "https://10.0.0.1/push",
keys,
});
expect(r.success).toBe(false);
});
it("rejects loopback https endpoint", () => {
const r = webPushSubscriptionSchema.safeParse({
endpoint: "https://127.0.0.1/push",
keys,
});
expect(r.success).toBe(false);
});
it("rejects AWS metadata service link-local", () => {
const r = webPushSubscriptionSchema.safeParse({
endpoint: "https://169.254.169.254/latest/meta-data/",
keys,
});
expect(r.success).toBe(false);
});
});