From 904676de8e4fc9fc9c513085d91b83c5d84961d8 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 10 Jun 2026 23:13:40 +0800 Subject: [PATCH] fix(security): enforce SSRF ipBlackList on IPv6 addresses The SSRF address check skipped every IPv6 address and then fell through to "allow", so a hostname resolving to an internal IPv6 address (or an IPv4-mapped IPv6 such as `::ffff:127.0.0.1`) bypassed `ssrf.ipBlackList`. `@eggjs/ip`'s `cidrSubnet().contains()` is also IPv4-only, so it cannot evaluate IPv6 ranges at all. Match IPv6 addresses against the configured rules with a buffer-based prefix comparison, and also check IPv4-mapped IPv6 addresses against IPv4 rules. --- plugins/security/src/lib/utils.ts | 85 ++++++++++++++++++++++++------ plugins/security/test/ssrf.test.ts | 32 +++++++++++ 2 files changed, 100 insertions(+), 17 deletions(-) diff --git a/plugins/security/src/lib/utils.ts b/plugins/security/src/lib/utils.ts index 1f771df0d6..83d71b7b42 100644 --- a/plugins/security/src/lib/utils.ts +++ b/plugins/security/src/lib/utils.ts @@ -59,6 +59,8 @@ export function checkIfIgnore(opts: { enable: boolean; matching?: PathMatchingFu } const IP_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; +// IPv4-mapped IPv6 address, e.g. `::ffff:127.0.0.1` +const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i; const topDomains: Record = {}; ['.net.cn', '.gov.cn', '.org.cn', '.com.cn'].forEach((item) => { topDomains[item] = 2 - item.split('.').length; @@ -145,22 +147,28 @@ export function preprocessConfig(config: SecurityConfig): void { if (typeof ipAddress === 'string') { address = ipAddress; } else { - // FIXME: should support ipv6 - if (ipAddress.family === 6) { - continue; - } address = ipAddress.address; } - // check white list first - for (const exception of exceptionList) { - if (exception(address)) { - return true; - } + // An IPv4-mapped IPv6 address (e.g. `::ffff:127.0.0.1`) actually targets + // an IPv4 host, so also match it against IPv4 rules to avoid an SSRF + // bypass when the blacklist only contains IPv4 entries. + const candidates = [address]; + const mapped = IPV4_MAPPED_RE.exec(address); + if (mapped) { + candidates.push(mapped[1]); } - // check black list - for (const contains of blackList) { - if (contains(address)) { - return false; + for (const candidate of candidates) { + // check white list first + for (const exception of exceptionList) { + if (exception(candidate)) { + return true; + } + } + // check black list + for (const contains of blackList) { + if (contains(candidate)) { + return false; + } } } } @@ -204,9 +212,52 @@ export function getFromUrl(url: string, prop: string): string | null { } } -function getContains(ip: string): (address: string) => boolean { - if (IP.isV4Format(ip) || IP.isV6Format(ip)) { - return (address: string) => address === ip; +function getContains(rule: string): (address: string) => boolean { + // Single IPv4/IPv6 address: compare normalized buffers so equivalent textual + // forms of the same address still match (e.g. `::1` and `0:0:0:0:0:0:0:1`). + if (IP.isV4Format(rule) || IP.isV6Format(rule)) { + const ruleBuffer = IP.toBuffer(rule); + return (address: string) => { + const addressBuffer = toBufferOrNull(address); + return addressBuffer !== null && ruleBuffer.equals(addressBuffer); + }; + } + // CIDR range: bitwise prefix comparison that works for both IPv4 and IPv6. + // `@eggjs/ip`'s `cidrSubnet().contains()` is IPv4-only (it relies on 32-bit + // `toLong`), so an IPv6 CIDR rule would otherwise match every address. + const [base, prefix] = rule.split('/'); + const prefixLength = Number.parseInt(prefix, 10); + if (!base || Number.isNaN(prefixLength)) { + throw new Error(`invalid CIDR subnet: ${rule}`); + } + const baseBuffer = IP.toBuffer(base); + return (address: string) => { + const addressBuffer = toBufferOrNull(address); + return addressBuffer !== null && isInSubnet(addressBuffer, baseBuffer, prefixLength); + }; +} + +function toBufferOrNull(address: string): Buffer | null { + try { + return IP.toBuffer(address); + } catch { + return null; + } +} + +function isInSubnet(address: Buffer, base: Buffer, prefixLength: number): boolean { + // Different families (4 vs 16 bytes) can never be in the same subnet. + if (address.length !== base.length) { + return false; + } + let remaining = prefixLength; + for (let i = 0; i < base.length && remaining > 0; i++) { + const take = Math.min(8, remaining); + const mask = (0xff << (8 - take)) & 0xff; + if ((address[i] & mask) !== (base[i] & mask)) { + return false; + } + remaining -= take; } - return IP.cidrSubnet(ip).contains; + return true; } diff --git a/plugins/security/test/ssrf.test.ts b/plugins/security/test/ssrf.test.ts index e0e9346354..1315227877 100644 --- a/plugins/security/test/ssrf.test.ts +++ b/plugins/security/test/ssrf.test.ts @@ -3,6 +3,7 @@ import dns from 'node:dns'; import { mm, type MockApplication } from '@eggjs/mock'; import { describe, it, afterAll, beforeAll, expect, afterEach } from 'vitest'; +import * as securityUtils from '../src/lib/utils.ts'; import { getFixtures } from './utils.ts'; let app: MockApplication; @@ -222,6 +223,37 @@ describe('hostnameExceptionList', () => { }); }); +describe('ipBlackList with ipv6 address', () => { + function buildCheckAddress(ipBlackList: string[], ipExceptionList?: string[]) { + const config: any = { ssrf: { ipBlackList, ipExceptionList } }; + securityUtils.preprocessConfig(config); + return config.ssrf.checkAddress as (addresses: any, family: number | string, hostname: string) => boolean; + } + + it('should block ipv6 address that is in the blacklist', () => { + const checkAddress = buildCheckAddress(['::1/128', 'fd00::/8']); + // resolved addresses are `{ address, family }` objects on Node.js >= 20 + expect(checkAddress([{ address: '::1', family: 6 }], 6, 'evil.example.com')).toBe(false); + expect(checkAddress([{ address: 'fd12::3', family: 6 }], 6, 'evil.example.com')).toBe(false); + // a public ipv6 address that is not blacklisted is still allowed + expect(checkAddress([{ address: '2400:cb00::1', family: 6 }], 6, 'example.com')).toBe(true); + }); + + it('should block IPv4-mapped IPv6 address against IPv4 blacklist rules', () => { + const checkAddress = buildCheckAddress(['127.0.0.1', '10.0.0.0/8']); + expect(checkAddress([{ address: '::ffff:127.0.0.1', family: 6 }], 6, 'evil.example.com')).toBe(false); + expect(checkAddress([{ address: '::ffff:10.1.2.3', family: 6 }], 6, 'evil.example.com')).toBe(false); + }); + + it('should respect ipExceptionList for ipv6 address', () => { + const checkAddress = buildCheckAddress(['::/0'], ['::1/128']); + // ::1 is in the exception list, allowed even though ::/0 blacklists everything + expect(checkAddress([{ address: '::1', family: 6 }], 6, 'host')).toBe(true); + // another ipv6 address is blocked by ::/0 + expect(checkAddress([{ address: 'fd00::1', family: 6 }], 6, 'host')).toBe(false); + }); +}); + async function checkIllegalAddressError(instance: any, url: string) { try { await instance.safeCurl(url);