-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(security): enforce SSRF ipBlackList on IPv6 addresses #5967
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, number> = {}; | ||
| ['.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]); | ||
| } | ||
|
Comment on lines
+155
to
159
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using a regular expression to detect and extract IPv4-mapped IPv6 addresses is prone to bypasses because IPv6 addresses can be represented in multiple valid textual formats (e.g., Instead, we can perform a robust buffer-based check. Since const candidates = [address];
const addressBuffer = toBufferOrNull(address);
if (addressBuffer && addressBuffer.length === 16) {
let isMapped = addressBuffer[10] === 0xff && addressBuffer[11] === 0xff;
if (isMapped) {
for (let i = 0; i < 10; i++) {
if (addressBuffer[i] !== 0) {
isMapped = false;
break;
}
}
}
if (isMapped) {
candidates.push(`${addressBuffer[12]}.${addressBuffer[13]}.${addressBuffer[14]}.${addressBuffer[15]}`);
}
} |
||
| // 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); | ||
|
Comment on lines
+228
to
+233
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The parsed We should validate that 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);
if (prefixLength < 0 || prefixLength > baseBuffer.length * 8) {
throw new Error(`invalid CIDR subnet: ${rule}`);
} |
||
| 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| }); | ||
|
Comment on lines
+242
to
+246
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To ensure the robustness of the buffer-based IPv4-mapped IPv6 detection, we should add test cases covering alternative valid representations of IPv4-mapped IPv6 addresses (such as using full zero expansion or hex representation for the IPv4 part). 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);
expect(checkAddress([{ address: '0:0:0:0:0:ffff:127.0.0.1', family: 6 }], 6, 'evil.example.com')).toBe(false);
expect(checkAddress([{ address: '::ffff:7f00:1', 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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we are switching to a robust buffer-based check to detect IPv4-mapped IPv6 addresses, the
IPV4_MAPPED_REregular expression is no longer needed and can be safely removed.