refactor: ponytail audit

This commit is contained in:
Amruth Pillai
2026-07-27 20:26:16 +02:00
parent bb1fb3a7d6
commit 9110e86997
157 changed files with 1082 additions and 2177 deletions
+46 -154
View File
@@ -1,4 +1,4 @@
import { isIP } from "node:net";
import { BlockList, isIP } from "node:net";
function normalizeHostname(hostname: string) {
return hostname.trim().toLowerCase();
@@ -8,170 +8,62 @@ function stripIpv6Brackets(hostname: string): string {
return hostname.replace(/^\[/, "").replace(/\]$/, "");
}
function normalizeIpv4MappedIpv6(hostname: string) {
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
const mapped = normalized.match(/^::ffff:(?<address>.+)$/)?.groups?.address;
if (!mapped) return normalized;
if (isIP(mapped) === 4) return mapped;
const hexMatch = mapped.match(/^(?<high>[0-9a-f]{1,4}):(?<low>[0-9a-f]{1,4})$/);
if (!hexMatch?.groups) return normalized;
const { high: highHex, low: lowHex } = hexMatch.groups;
if (!highHex || !lowHex) return normalized;
const high = Number.parseInt(highHex, 16);
const low = Number.parseInt(lowHex, 16);
if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return normalized;
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
}
function isIpv4MappedIpv6(hostname: string) {
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
return normalized.startsWith("::ffff:");
}
const blockedIpv4Cidrs: Array<[number, number]> = [
[knownIpv4ToNumber("0.0.0.0"), 8],
[knownIpv4ToNumber("10.0.0.0"), 8],
[knownIpv4ToNumber("100.64.0.0"), 10],
[knownIpv4ToNumber("127.0.0.0"), 8],
[knownIpv4ToNumber("169.254.0.0"), 16],
[knownIpv4ToNumber("172.16.0.0"), 12],
[knownIpv4ToNumber("192.0.0.0"), 24],
[knownIpv4ToNumber("192.0.2.0"), 24],
[knownIpv4ToNumber("192.88.99.0"), 24],
[knownIpv4ToNumber("192.168.0.0"), 16],
[knownIpv4ToNumber("198.18.0.0"), 15],
[knownIpv4ToNumber("198.51.100.0"), 24],
[knownIpv4ToNumber("203.0.113.0"), 24],
[knownIpv4ToNumber("224.0.0.0"), 4],
[knownIpv4ToNumber("240.0.0.0"), 4],
const blockedIpv4Cidrs: Array<[string, number]> = [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.0.0.0", 24],
["192.0.2.0", 24],
["192.88.99.0", 24],
["192.168.0.0", 16],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4],
["240.0.0.0", 4],
];
const blockedIpv6Cidrs: Array<[bigint, number]> = [
[knownIpv6ToBigInt("::"), 128],
[knownIpv6ToBigInt("::1"), 128],
[knownIpv6ToBigInt("::ffff:0:0"), 96],
[knownIpv6ToBigInt("64:ff9b::"), 96],
[knownIpv6ToBigInt("64:ff9b:1::"), 48],
[knownIpv6ToBigInt("100::"), 64],
[knownIpv6ToBigInt("100:0:0:1::"), 64],
[knownIpv6ToBigInt("2001::"), 23],
[knownIpv6ToBigInt("2001:2::"), 48],
[knownIpv6ToBigInt("2001:10::"), 28],
[knownIpv6ToBigInt("2001:db8::"), 32],
[knownIpv6ToBigInt("2002::"), 16],
[knownIpv6ToBigInt("3fff::"), 20],
[knownIpv6ToBigInt("5f00::"), 16],
[knownIpv6ToBigInt("fc00::"), 7],
[knownIpv6ToBigInt("fe80::"), 10],
[knownIpv6ToBigInt("ff00::"), 8],
const blockedIpv6Cidrs: Array<[string, number]> = [
["::", 128],
["::1", 128],
["::ffff:0:0", 96],
["64:ff9b::", 96],
["64:ff9b:1::", 48],
["100::", 64],
["100:0:0:1::", 64],
["2001::", 23],
["2001:2::", 48],
["2001:10::", 28],
["2001:db8::", 32],
["2002::", 16],
["3fff::", 20],
["5f00::", 16],
["fc00::", 7],
["fe80::", 10],
["ff00::", 8],
];
function ipv4ToNumber(hostname: string) {
const octets = hostname.split(".").map((part) => Number.parseInt(part, 10));
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) return null;
const blockedIpv4s = new BlockList();
for (const [address, prefix] of blockedIpv4Cidrs) blockedIpv4s.addSubnet(address, prefix, "ipv4");
return (((octets[0] ?? 0) << 24) | ((octets[1] ?? 0) << 16) | ((octets[2] ?? 0) << 8) | (octets[3] ?? 0)) >>> 0;
}
function knownIpv4ToNumber(hostname: string) {
const value = ipv4ToNumber(hostname);
if (value === null) throw new Error(`Invalid IPv4 CIDR base: ${hostname}`);
return value;
}
function isIpv4InCidr(address: number, base: number, prefix: number) {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return (address & mask) === (base & mask);
}
function expandIpv6(hostname: string) {
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
const [head = "", tail = ""] = normalized.split("::", 2);
const headParts = head ? head.split(":") : [];
const tailParts = tail ? tail.split(":") : [];
const missing = 8 - headParts.length - tailParts.length;
if (missing < 0) return null;
const parts = [...headParts, ...Array.from({ length: missing }, () => "0"), ...tailParts];
if (parts.length !== 8) return null;
const hextets = parts.map((part) => {
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
return Number.parseInt(part, 16);
});
return hextets.every((part) => part !== null && part >= 0 && part <= 0xffff) ? (hextets as number[]) : null;
}
function ipv6ToBigInt(hostname: string) {
const hextets = expandIpv6(hostname);
if (!hextets) return null;
return hextets.reduce((value, hextet) => (value << 16n) | BigInt(hextet), 0n);
}
function knownIpv6ToBigInt(hostname: string) {
const value = ipv6ToBigInt(hostname);
if (value === null) throw new Error(`Invalid IPv6 CIDR base: ${hostname}`);
return value;
}
function isIpv6InCidr(address: bigint, base: bigint, prefix: number) {
const bits = 128n;
const hostBits = bits - BigInt(prefix);
const mask = prefix === 0 ? 0n : ((1n << bits) - 1n) ^ ((1n << hostBits) - 1n);
return (address & mask) === (base & mask);
}
function isLoopbackOrLocalHostname(hostname: string) {
const normalized = normalizeHostname(hostname);
return (
normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.endsWith(".localhost")
);
}
function isPrivateIPv4(hostname: string) {
const address = ipv4ToNumber(hostname);
if (address === null) return false;
return blockedIpv4Cidrs.some(([base, prefix]) => isIpv4InCidr(address, base ?? 0, prefix));
}
function isPrivateIPv6(hostname: string) {
const address = ipv6ToBigInt(hostname);
if (address === null) return false;
return blockedIpv6Cidrs.some(([base, prefix]) => isIpv6InCidr(address, base, prefix));
}
const blockedIpv6s = new BlockList();
for (const [address, prefix] of blockedIpv6Cidrs) blockedIpv6s.addSubnet(address, prefix, "ipv6");
export function isPrivateOrLoopbackHost(hostname: string) {
if (isIpv4MappedIpv6(hostname)) return true;
const normalized = normalizeIpv4MappedIpv6(hostname);
if (isLoopbackOrLocalHostname(normalized)) return true;
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
if (normalized.startsWith("::ffff:")) return true;
if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".localhost")) return true;
const ipVersion = isIP(normalized);
if (ipVersion === 4) return isPrivateIPv4(normalized);
if (ipVersion === 6) return isPrivateIPv6(normalized);
if (ipVersion === 4) return blockedIpv4s.check(normalized, "ipv4");
if (ipVersion === 6) return blockedIpv6s.check(normalized, "ipv6");
return false;
}
function isOAuthLoopbackRedirectHost(hostname: string) {
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
export function parseUrl(input: string) {
try {
return new URL(input);
@@ -192,9 +84,9 @@ export function isAllowedOAuthRedirectUri(input: string, trustedOrigins: string[
if (parsed.hash) return false;
const origin = parsed.origin.toLowerCase();
const hostname = normalizeHostname(parsed.hostname);
const hostname = stripIpv6Brackets(normalizeHostname(parsed.hostname));
if (parsed.protocol === "http:") return isOAuthLoopbackRedirectHost(hostname);
if (parsed.protocol === "http:") return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
if (parsed.protocol !== "https:") return false;
if (isPrivateOrLoopbackHost(hostname)) return false;