mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: block loopback, CGNAT and IPv6 transition ranges in the SSRF guard
The URL guard behind fetch-ical, OAuth discovery, branding and the server-side JMAP fetches blocked only the single address 127.0.0.1 plus the RFC 1918, link-local and ULA subnets. Any logged-in user could make the server fetch 127.0.0.2, 100.64.0.0/10, 198.18.0.0/15, multicast, reserved space or a NAT64/6to4/Teredo address that wraps an internal IPv4 address. The telemetry guard already rejected all of these (GHSA-m7j8), so its range check moves to `lib/security/ip-ranges.ts` and both guards use it. `guardedLookup` inherits the wider list, so the connect-time rebinding check covers the new ranges too.
This commit is contained in:
@@ -97,6 +97,45 @@ describe('isPublicHttpUrl', () => {
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://127.0.0.2/',
|
||||
'http://127.255.255.254/',
|
||||
'http://100.64.0.1/',
|
||||
'http://100.100.100.200/',
|
||||
'http://198.18.0.1/',
|
||||
'http://240.0.0.1/',
|
||||
'http://224.0.0.1/',
|
||||
'http://255.255.255.255/',
|
||||
'http://0.1.2.3/',
|
||||
'http://[::ffff:127.0.0.2]/',
|
||||
'http://[64:ff9b::7f00:1]/',
|
||||
'http://[64:ff9b::a9fe:a9fe]/',
|
||||
'http://[64:ff9b:1::1]/',
|
||||
'http://[2002:7f00:1::]/',
|
||||
'http://[2002:a9fe:a9fe::]/',
|
||||
'http://[2001:0:4136:e378:8000:63bf:80ff:fffe]/',
|
||||
'http://[ff02::1]/',
|
||||
])('rejects the special-purpose literal %s', async (url) => {
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl(url)).toBe(false);
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['127.0.0.2', '100.64.0.1', '198.18.0.1', '64:ff9b::7f00:1', '2002:7f00:1::'])(
|
||||
'rejects a hostname resolving to %s',
|
||||
async (address) => {
|
||||
lookup.mockResolvedValue([{ address, family: address.includes(':') ? 6 : 4 }]);
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('https://rebind.example.com/')).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts public addresses embedded in NAT64 / 6to4 forms', async () => {
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('http://[64:ff9b::5db8:d822]/')).toBe(true);
|
||||
expect(await isPublicHttpUrl('http://[2002:5db8:d822::1]/')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when DNS resolves to a private address (rebinding)', async () => {
|
||||
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||
const isPublicHttpUrl = await load();
|
||||
@@ -186,6 +225,15 @@ describe('guardedLookup', () => {
|
||||
expect(result.err).toMatchObject({ code: 'EBLOCKED', address: '169.254.169.254' });
|
||||
});
|
||||
|
||||
it.each(['127.0.0.2', '100.64.0.1', '64:ff9b::7f00:1'])(
|
||||
'refuses a public name that resolves to %s',
|
||||
async (address) => {
|
||||
lookup.mockResolvedValue([{ address, family: address.includes(':') ? 6 : 4 }]);
|
||||
const result = await run('rebind.attacker.example', { all: true });
|
||||
expect(result.err).toMatchObject({ code: 'EBLOCKED', address });
|
||||
},
|
||||
);
|
||||
|
||||
it('rebinding: a name that was public on the first answer is refused on the second', async () => {
|
||||
lookup
|
||||
.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }])
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
// The one definition of "not a public unicast address" for every server-side
|
||||
// guard (url-guard for caller-supplied URLs, telemetry for the admin-set
|
||||
// collector). Two copies drifted once: url-guard blocked only 127.0.0.1 while
|
||||
// telemetry already rejected all of 127/8, CGNAT and the IPv6 transition
|
||||
// prefixes.
|
||||
|
||||
const PRIVATE_V4: RegExp[] = [
|
||||
/^0\./, // 0.0.0.0/8
|
||||
/^10\./, // 10.0.0.0/8
|
||||
/^127\./, // loopback
|
||||
/^169\.254\./, // link-local + cloud metadata
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12
|
||||
/^192\.168\./, // 192.168.0.0/16
|
||||
/^192\.0\.0\./, // IETF reserved
|
||||
/^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT
|
||||
/^22[4-9]\./, // 224.0.0.0/4 multicast
|
||||
/^23\d\./,
|
||||
/^2[4-5]\d\./, // 240.0.0.0/4 reserved + broadcast
|
||||
];
|
||||
|
||||
function isPrivateV4(ip: string): boolean {
|
||||
return PRIVATE_V4.some((re) => re.test(ip));
|
||||
}
|
||||
|
||||
// Expand an IPv6 literal into its eight 16-bit groups. Accepts `::`
|
||||
// compression, a trailing embedded dotted quad (`::ffff:127.0.0.1`) and a
|
||||
// zone id suffix. Returns null when the string isn't a well-formed address.
|
||||
//
|
||||
// We parse structurally rather than regex-matching the textual form because
|
||||
// the same address has several spellings: the WHATWG URL parser rewrites
|
||||
// `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, while dns.lookup returns the
|
||||
// dotted form. A textual check that only knew one spelling let the other
|
||||
// straight through (GHSA-m7j8-f5q4-vj7x).
|
||||
export function parseV6Groups(ip: string): number[] | null {
|
||||
let s = ip.toLowerCase();
|
||||
const zone = s.indexOf('%');
|
||||
if (zone >= 0) s = s.slice(0, zone);
|
||||
|
||||
const v4 = s.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (v4) {
|
||||
const o = v4.slice(2, 6).map(Number);
|
||||
if (o.some((n) => n > 255)) return null;
|
||||
s = `${v4[1]}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
|
||||
}
|
||||
|
||||
const halves = s.split('::');
|
||||
if (halves.length > 2) return null;
|
||||
const head = halves[0] ? halves[0].split(':') : [];
|
||||
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
||||
const missing = 8 - head.length - tail.length;
|
||||
if (missing < 0 || (halves.length === 1 && missing !== 0)) return null;
|
||||
if (halves.length === 2 && missing === 0) return null;
|
||||
|
||||
const groups = [...head, ...new Array<string>(missing).fill('0'), ...tail]
|
||||
.map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN));
|
||||
if (groups.some(Number.isNaN)) return null;
|
||||
return groups;
|
||||
}
|
||||
|
||||
function v4FromGroups(hi: number, lo: number): string {
|
||||
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
||||
}
|
||||
|
||||
function isPrivateV6(ip: string): boolean {
|
||||
const g = parseV6Groups(ip);
|
||||
// Fail closed: isIP() said this is IPv6 but we can't make sense of it.
|
||||
if (!g) return true;
|
||||
|
||||
const leadingZero = (n: number) => g.slice(0, n).every((x) => x === 0);
|
||||
|
||||
if (leadingZero(7) && (g[7] === 0 || g[7] === 1)) return true; // :: and ::1
|
||||
if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast
|
||||
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
||||
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
|
||||
if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo
|
||||
if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true; // 64:ff9b:1::/48 local NAT64
|
||||
|
||||
// Forms that embed an IPv4 address: judge them by the embedded address so
|
||||
// an IPv6-only host behind DNS64 can still reach a public server.
|
||||
if (leadingZero(5) && g[5] === 0xffff) { // ::ffff:0:0/96 IPv4-mapped
|
||||
return isPrivateV4(v4FromGroups(g[6], g[7]));
|
||||
}
|
||||
if (leadingZero(6)) { // ::/96 IPv4-compatible (deprecated)
|
||||
return isPrivateV4(v4FromGroups(g[6], g[7]));
|
||||
}
|
||||
if (g[0] === 0x64 && g[1] === 0xff9b &&
|
||||
g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) { // 64:ff9b::/96 NAT64
|
||||
return isPrivateV4(v4FromGroups(g[6], g[7]));
|
||||
}
|
||||
if (g[0] === 0x2002) { // 2002::/16 6to4
|
||||
return isPrivateV4(v4FromGroups(g[1], g[2]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for any address a server-side fetch must not reach: loopback,
|
||||
* RFC 1918, link-local, CGNAT, benchmarking, reserved, multicast, ULA, and
|
||||
* IPv6 transition forms whose embedded IPv4 address is one of those.
|
||||
* Returns false for anything that is not an IP literal.
|
||||
*/
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
const family = isIP(ip);
|
||||
if (family === 4) return isPrivateV4(ip);
|
||||
if (family === 6) return isPrivateV6(ip);
|
||||
return false;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import type { LookupAddress, LookupOptions } from 'node:dns';
|
||||
import { BlockList, isIP, type LookupFunction } from 'node:net';
|
||||
import { isIP, type LookupFunction } from 'node:net';
|
||||
import {
|
||||
Agent,
|
||||
fetch as undiciFetch,
|
||||
@@ -8,18 +8,7 @@ import {
|
||||
type RequestInit as UndiciRequestInit,
|
||||
type Response as UndiciResponse,
|
||||
} from 'undici';
|
||||
|
||||
const blockedAddressRanges = new BlockList();
|
||||
blockedAddressRanges.addAddress('0.0.0.0');
|
||||
blockedAddressRanges.addAddress('127.0.0.1');
|
||||
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
||||
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
||||
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
||||
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
||||
blockedAddressRanges.addAddress('::', 'ipv6');
|
||||
blockedAddressRanges.addAddress('::1', 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
||||
import { isPrivateAddress } from './ip-ranges';
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set(['localhost']);
|
||||
const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain'];
|
||||
@@ -29,11 +18,7 @@ function normalizeHostname(hostname: string): string {
|
||||
}
|
||||
|
||||
function isBlockedIpAddress(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const family = isIP(normalized);
|
||||
if (family === 4) return blockedAddressRanges.check(normalized, 'ipv4');
|
||||
if (family === 6) return blockedAddressRanges.check(normalized, 'ipv6');
|
||||
return false;
|
||||
return isPrivateAddress(normalizeHostname(hostname));
|
||||
}
|
||||
|
||||
function isBlockedHostname(hostname: string): boolean {
|
||||
@@ -67,7 +52,8 @@ function parseAllowedUrl(urlString: string): URL | null {
|
||||
|
||||
/**
|
||||
* Returns true only when the URL targets a public host reachable over http(s).
|
||||
* Rejects loopback / RFC-1918 / link-local / ULA addresses, special hostname
|
||||
* Rejects every non-public range in {@link isPrivateAddress} (loopback,
|
||||
* RFC 1918, link-local, CGNAT, multicast, IPv6 transition forms), special hostname
|
||||
* suffixes (.local, .internal, .arpa, ...), URLs with embedded credentials,
|
||||
* and any hostname whose DNS resolves to a blocked address.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { isPrivateAddress } from '@/lib/security/ip-ranges';
|
||||
|
||||
export { isPrivateAddress, parseV6Groups } from '@/lib/security/ip-ranges';
|
||||
|
||||
// Block telemetry endpoints from pointing at internal/loopback addresses.
|
||||
// Required because the admin UI lets an authenticated admin set an arbitrary
|
||||
@@ -9,103 +12,6 @@ import { isIP } from 'node:net';
|
||||
// Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass - useful only for local
|
||||
// dev where the collector is on the loopback.
|
||||
|
||||
const PRIVATE_V4: RegExp[] = [
|
||||
/^0\./, // 0.0.0.0/8
|
||||
/^10\./, // 10.0.0.0/8
|
||||
/^127\./, // loopback
|
||||
/^169\.254\./, // link-local + cloud metadata
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12
|
||||
/^192\.168\./, // 192.168.0.0/16
|
||||
/^192\.0\.0\./, // IETF reserved
|
||||
/^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT
|
||||
/^22[4-9]\./, // 224.0.0.0/4 multicast
|
||||
/^23\d\./,
|
||||
/^2[4-5]\d\./, // 240.0.0.0/4 reserved
|
||||
];
|
||||
|
||||
function isPrivateV4(ip: string): boolean {
|
||||
return PRIVATE_V4.some((re) => re.test(ip));
|
||||
}
|
||||
|
||||
// Expand an IPv6 literal into its eight 16-bit groups. Accepts `::`
|
||||
// compression, a trailing embedded dotted quad (`::ffff:127.0.0.1`) and a
|
||||
// zone id suffix. Returns null when the string isn't a well-formed address.
|
||||
//
|
||||
// We parse structurally rather than regex-matching the textual form because
|
||||
// the same address has several spellings: the WHATWG URL parser rewrites
|
||||
// `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, while dns.lookup returns the
|
||||
// dotted form. A textual check that only knew one spelling let the other
|
||||
// straight through (GHSA-m7j8-f5q4-vj7x).
|
||||
export function parseV6Groups(ip: string): number[] | null {
|
||||
let s = ip.toLowerCase();
|
||||
const zone = s.indexOf('%');
|
||||
if (zone >= 0) s = s.slice(0, zone);
|
||||
|
||||
const v4 = s.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (v4) {
|
||||
const o = v4.slice(2, 6).map(Number);
|
||||
if (o.some((n) => n > 255)) return null;
|
||||
s = `${v4[1]}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
|
||||
}
|
||||
|
||||
const halves = s.split('::');
|
||||
if (halves.length > 2) return null;
|
||||
const head = halves[0] ? halves[0].split(':') : [];
|
||||
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
||||
const missing = 8 - head.length - tail.length;
|
||||
if (missing < 0 || (halves.length === 1 && missing !== 0)) return null;
|
||||
if (halves.length === 2 && missing === 0) return null;
|
||||
|
||||
const groups = [...head, ...new Array<string>(missing).fill('0'), ...tail]
|
||||
.map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN));
|
||||
if (groups.some(Number.isNaN)) return null;
|
||||
return groups;
|
||||
}
|
||||
|
||||
function v4FromGroups(hi: number, lo: number): string {
|
||||
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
||||
}
|
||||
|
||||
function isPrivateV6(ip: string): boolean {
|
||||
const g = parseV6Groups(ip);
|
||||
// Fail closed: isIP() said this is IPv6 but we can't make sense of it.
|
||||
if (!g) return true;
|
||||
|
||||
const leadingZero = (n: number) => g.slice(0, n).every((x) => x === 0);
|
||||
|
||||
if (leadingZero(7) && (g[7] === 0 || g[7] === 1)) return true; // :: and ::1
|
||||
if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast
|
||||
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
||||
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
|
||||
if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo
|
||||
if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true; // 64:ff9b:1::/48 local NAT64
|
||||
|
||||
// Forms that embed an IPv4 address: judge them by the embedded address so
|
||||
// an IPv6-only host behind DNS64 can still reach a public collector.
|
||||
if (leadingZero(5) && g[5] === 0xffff) { // ::ffff:0:0/96 IPv4-mapped
|
||||
return isPrivateV4(v4FromGroups(g[6], g[7]));
|
||||
}
|
||||
if (leadingZero(6)) { // ::/96 IPv4-compatible (deprecated)
|
||||
return isPrivateV4(v4FromGroups(g[6], g[7]));
|
||||
}
|
||||
if (g[0] === 0x64 && g[1] === 0xff9b &&
|
||||
g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) { // 64:ff9b::/96 NAT64
|
||||
return isPrivateV4(v4FromGroups(g[6], g[7]));
|
||||
}
|
||||
if (g[0] === 0x2002) { // 2002::/16 6to4
|
||||
return isPrivateV4(v4FromGroups(g[1], g[2]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
const family = isIP(ip);
|
||||
if (family === 4) return isPrivateV4(ip);
|
||||
if (family === 6) return isPrivateV6(ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
const BAD_HOSTS = new Set([
|
||||
'localhost',
|
||||
'localhost.localdomain',
|
||||
|
||||
Reference in New Issue
Block a user