Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 68 additions & 17 deletions plugins/security/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +62 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since we are switching to a robust buffer-based check to detect IPv4-mapped IPv6 addresses, the IPV4_MAPPED_RE regular expression is no longer needed and can be safely removed.

const topDomains: Record<string, number> = {};
['.net.cn', '.gov.cn', '.org.cn', '.com.cn'].forEach((item) => {
topDomains[item] = 2 - item.split('.').length;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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., 0:0:0:0:0:ffff:127.0.0.1 or ::ffff:7f00:1). An attacker could potentially use an alternative representation to bypass the regex check while still targeting the internal IPv4 address.

Instead, we can perform a robust buffer-based check. Since IP.toBuffer normalizes all valid representations of an IP address, we can check if the resulting 16-byte buffer represents an IPv4-mapped IPv6 address (where the first 10 bytes are 0 and the next 2 bytes are 0xff), and then extract the IPv4 address directly from the last 4 bytes.

        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;
}
}
}
}
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The parsed prefixLength is not validated to ensure it is within valid bounds. If a user configures an invalid CIDR with a negative prefix length (e.g., 1.2.3.4/-5), prefixLength will be negative, causing isInSubnet to skip the comparison loop and incorrectly return true (matching any IP address). Similarly, a prefix length greater than the address's bit length (32 for IPv4, 128 for IPv6) is invalid.

We should validate that prefixLength is non-negative and does not exceed the maximum bit length of the IP family.

  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;
}
32 changes: 32 additions & 0 deletions plugins/security/test/ssrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
Expand Down
Loading