fix(01-07): classify embedded IPv4 destinations

- Normalize IPv6 literals before transition-prefix inspection
- Reject mapped, compatible, 6to4, and NAT64 private embeddings
- Share the verdict with pinned public and operator dispatchers
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 13:49:49 +02:00
1 parent 36cb64c840
commit a177869609
2 files changed
+186 -91

No files matched your search

+66 -11
View File
@@ -1,4 +1,5 @@
import dns from "node:dns";
import { isIP } from "node:net";
import { Agent } from "undici";
import { isPublicIp } from "@/lib/validations/notifications";
@@ -40,7 +41,10 @@ import { isPublicIp } from "@/lib/validations/notifications";
* Wired into `safeFetch` only when `opts.requirePublicHost` is true.
* The outbound paths that accept a user-supplied host all use that flag.
*/
function pinnedLookup(
type AddressPolicy = "public" | "operator-approved";
function pinnedLookupWithPolicy(
policy: AddressPolicy,
hostname: string,
options: dns.LookupOptions,
callback: (
@@ -64,13 +68,20 @@ function pinnedLookup(
callback(err, "");
return;
}
const allowed = addresses.filter((a) => isPublicIp(a.address));
const allowed = addresses.filter((address) =>
policy === "public"
? isPublicIp(address.address)
: isIP(address.address) !== 0,
);
if (allowed.length === 0) {
const refused: NodeJS.ErrnoException = new Error(
`safeFetch refused private resolved address for ${hostname}`,
);
refused.code = "ENOTFOUND";
callback(refused, "");
// Preserve the result shape Undici requested. Auto-select-family asks
// for `all: true`; handing that path a scalar empty string can mask the
// policy error behind an invalid-address error for IPv6 answers.
callback(refused, options.all ? [] : "");
return;
}
@@ -89,19 +100,44 @@ function pinnedLookup(
});
}
function pinnedPublicLookup(
hostname: string,
options: dns.LookupOptions,
callback: (
err: NodeJS.ErrnoException | null,
address: string | dns.LookupAddress[],
family?: number,
) => void,
): void {
pinnedLookupWithPolicy("public", hostname, options, callback);
}
function pinnedOperatorApprovedLookup(
hostname: string,
options: dns.LookupOptions,
callback: (
err: NodeJS.ErrnoException | null,
address: string | dns.LookupAddress[],
family?: number,
) => void,
): void {
pinnedLookupWithPolicy("operator-approved", hostname, options, callback);
}
/**
* Lazily-instantiated singleton. `undici.Agent` keeps an internal pool
* per origin, so reusing one instance across calls preserves the keep-
* alive benefit while still pinning each new connection through the
* vetted lookup.
*/
let cached: Agent | null = null;
let cachedPublic: Agent | null = null;
let cachedOperatorApproved: Agent | null = null;
export function getPinnedPublicDispatcher(): Agent {
if (cached) return cached;
cached = new Agent({
function createPinnedDispatcher(policy: AddressPolicy): Agent {
return new Agent({
connect: {
lookup: pinnedLookup,
lookup:
policy === "public" ? pinnedPublicLookup : pinnedOperatorApprovedLookup,
// RFC 8305 Happy Eyeballs across the vetted survivor set. Without
// this undici pins the single first address and dies when that is
// an unreachable IPv6 record on a v4-only host. With it, undici
@@ -114,7 +150,24 @@ export function getPinnedPublicDispatcher(): Agent {
autoSelectFamilyAttemptTimeout: 250,
},
});
return cached;
}
export function getPinnedPublicDispatcher(): Agent {
cachedPublic ??= createPinnedDispatcher("public");
return cachedPublic;
}
/**
* Pinned dispatcher for an exact operator-approved origin.
*
* Private addresses are permitted only after the Nightscout origin policy has
* matched the complete canonical scheme/host/port trust grant. Resolution is
* still performed once inside Undici's connector and the vetted answer set is
* pinned to the socket, so a later DNS answer cannot replace the destination.
*/
export function getPinnedOperatorApprovedDispatcher(): Agent {
cachedOperatorApproved ??= createPinnedDispatcher("operator-approved");
return cachedOperatorApproved;
}
/**
@@ -123,6 +176,8 @@ export function getPinnedPublicDispatcher(): Agent {
* binds the mock.
*/
export function _resetPinnedDispatcherForTests(): void {
cached?.destroy().catch(() => {});
cached = null;
cachedPublic?.destroy().catch(() => {});
cachedOperatorApproved?.destroy().catch(() => {});
cachedPublic = null;
cachedOperatorApproved = null;
}
+120 -80
View File
@@ -1,4 +1,5 @@
import { z } from "zod/v4";
import { isIP } from "node:net";
import { EVENT_TYPES } from "@/lib/notifications/types";
export const notificationPreferenceSchema = z.object({
@@ -65,6 +66,109 @@ function isPrivateIpv4(ip: [number, number, number, number]): boolean {
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.
@@ -86,45 +190,27 @@ export function isPublicIp(ip: string): boolean {
if (!ip) return false;
const lower = ip.toLowerCase();
// IPv4 dotted-quad (what dns.lookup with family=4 returns).
if (/^\d+\.\d+\.\d+\.\d+$/.test(lower)) {
const family = isIP(lower);
if (family === 4) {
const parsed = parseIpv4Strict(lower);
if (!parsed) return false;
return !isPrivateIpv4(parsed);
}
// IPv6. Only strings with a colon make it past the previous check.
if (lower.includes(":")) {
if (lower === "::1" || lower === "::") return false;
if (lower.startsWith("fe80:")) return false;
if (/^fc[0-9a-f]{0,2}:/.test(lower)) return false;
if (/^fd[0-9a-f]{0,2}:/.test(lower)) return false;
if (family === 6) {
const bytes = parseIpv6Bytes(lower);
if (!bytes) return false;
// IPv4-mapped IPv6 dotted form ("::ffff:127.0.0.1"). Some resolvers
// emit this when an AAAA falls through to a synthesized A.
const mappedDotted = lower.match(/^(?:::ffff:)(\d+\.\d+\.\d+\.\d+)$/);
if (mappedDotted) {
const parsed = parseIpv4Strict(mappedDotted[1]);
if (!parsed || isPrivateIpv4(parsed)) return false;
return true;
// 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;
// IPv4-mapped IPv6 hex pair form ("::ffff:7f00:1").
const mappedHex = lower.match(
/^(?:::ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/,
);
if (mappedHex) {
const high = parseInt(mappedHex[1], 16);
const low = parseInt(mappedHex[2], 16);
const parsed: [number, number, number, number] = [
(high >> 8) & 0xff,
high & 0xff,
(low >> 8) & 0xff,
low & 0xff,
];
if (isPrivateIpv4(parsed)) return false;
return true;
}
const embedded = embeddedIpv4(bytes);
if (embedded && isPrivateIpv4(embedded)) return false;
return true;
}
@@ -176,55 +262,9 @@ export function isPublicUrl(url: string): boolean {
return false;
}
// IPv6 loopback / unspecified / link-local / unique-local. Must be
// gated on a colon — the previous `startsWith("fc")` falsely blocked
// any DNS hostname starting with "fc" or "fd" (e.g. fcm.googleapis.com).
if (h === "::1" || h === "::") {
return false;
}
if (h.includes(":")) {
if (
h.startsWith("fe80:") ||
/^fc[0-9a-f]{0,2}:/.test(h) ||
/^fd[0-9a-f]{0,2}:/.test(h)
) {
return false;
}
}
// IPv4-mapped IPv6 ("::ffff:127.0.0.1" or "::ffff:7f00:1") and
// 6to4 / NAT64 with embedded private IPv4. Extract the trailing
// dotted-quad if present, otherwise extract the last 32 bits as
// hex pairs and reconstruct.
if (h.includes(":")) {
const ipv4MappedDotted = h.match(/^(?:::ffff:)(\d+\.\d+\.\d+\.\d+)$/);
if (ipv4MappedDotted) {
const ip = parseIpv4Strict(ipv4MappedDotted[1]);
if (!ip || isPrivateIpv4(ip)) return false;
}
const ipv4MappedHex = h.match(
/^(?:::ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/,
);
if (ipv4MappedHex) {
const high = parseInt(ipv4MappedHex[1], 16);
const low = parseInt(ipv4MappedHex[2], 16);
const ip: [number, number, number, number] = [
(high >> 8) & 0xff,
high & 0xff,
(low >> 8) & 0xff,
low & 0xff,
];
if (isPrivateIpv4(ip)) return false;
}
}
// Strict IPv4 parsing: reject malformed forms outright (better a false
// positive than an SSRF) and apply RFC1918 / CGNAT / loopback bans.
if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) {
const ip = parseIpv4Strict(h);
if (!ip) return false;
if (isPrivateIpv4(ip)) 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 {