🛡️ fix: Cover full fe80::/10 link-local range in IPv6 check (#12244)

* 🛡️ fix: Cover full fe80::/10 link-local range in SSRF IPv6 check

The `isPrivateIP` check used `startsWith('fe80')` which only matched
fe80:: but missed fe90::–febf:: (the rest of the RFC 4291 fe80::/10
link-local block). Replace with a proper bitwise hextet check.

* 🛡️ fix: Guard isIPv6LinkLocal against parseInt partial-parse on hostnames

parseInt('fe90.example.com', 16) stops at the dot and returns 0xfe90,
which passes the bitmask check and false-positives legitimate domains.

Add colon-presence guard (IPv6 literals always contain ':') and a hex
regex validation on the first hextet before parseInt.

Also document why fc/fd use startsWith while fe80::/10 needs bitwise.

*  test: Harden IPv6 link-local SSRF tests with false-positive guards

- Assert fe90/fea0/febf hostnames are NOT blocked (regression guard)
- Add feb0::1 and bracket form [fe90::1] to isPrivateIP coverage
- Extend resolveHostnameSSRF tests for fe90::1 and febf::1
This commit is contained in:
Danny Avila 2026-03-15 17:07:55 -04:00 committed by GitHub
parent a01959b3d2
commit a0b4949a05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 40 additions and 3 deletions

View file

@ -177,6 +177,20 @@ describe('isSSRFTarget', () => {
expect(isSSRFTarget('fd00::1')).toBe(true);
expect(isSSRFTarget('fe80::1')).toBe(true);
});
it('should block full fe80::/10 link-local range (fe80febf)', () => {
expect(isSSRFTarget('fe90::1')).toBe(true);
expect(isSSRFTarget('fea0::1')).toBe(true);
expect(isSSRFTarget('feb0::1')).toBe(true);
expect(isSSRFTarget('febf::1')).toBe(true);
expect(isSSRFTarget('fec0::1')).toBe(false);
});
it('should NOT false-positive on hostnames whose first label resembles a link-local prefix', () => {
expect(isSSRFTarget('fe90.example.com')).toBe(false);
expect(isSSRFTarget('fea0.api.io')).toBe(false);
expect(isSSRFTarget('febf.service.net')).toBe(false);
});
});
describe('internal hostnames', () => {
@ -277,10 +291,17 @@ describe('isPrivateIP', () => {
expect(isPrivateIP('[::1]')).toBe(true);
});
it('should detect unique local (fc/fd) and link-local (fe80)', () => {
it('should detect unique local (fc/fd) and link-local (fe80::/10)', () => {
expect(isPrivateIP('fc00::1')).toBe(true);
expect(isPrivateIP('fd00::1')).toBe(true);
expect(isPrivateIP('fe80::1')).toBe(true);
expect(isPrivateIP('fe90::1')).toBe(true);
expect(isPrivateIP('fea0::1')).toBe(true);
expect(isPrivateIP('feb0::1')).toBe(true);
expect(isPrivateIP('febf::1')).toBe(true);
expect(isPrivateIP('[fe90::1]')).toBe(true);
expect(isPrivateIP('fec0::1')).toBe(false);
expect(isPrivateIP('fe90.example.com')).toBe(false);
});
});
@ -482,6 +503,8 @@ describe('resolveHostnameSSRF', () => {
expect(await resolveHostnameSSRF('::1')).toBe(true);
expect(await resolveHostnameSSRF('fc00::1')).toBe(true);
expect(await resolveHostnameSSRF('fe80::1')).toBe(true);
expect(await resolveHostnameSSRF('fe90::1')).toBe(true);
expect(await resolveHostnameSSRF('febf::1')).toBe(true);
expect(mockedLookup).not.toHaveBeenCalled();
});

View file

@ -59,6 +59,20 @@ function isPrivateIPv4(a: number, b: number, c: number): boolean {
return false;
}
/** Checks if a pre-normalized (lowercase, bracket-stripped) IPv6 address falls within fe80::/10 */
function isIPv6LinkLocal(ipv6: string): boolean {
if (!ipv6.includes(':')) {
return false;
}
const firstHextet = ipv6.split(':', 1)[0];
if (!firstHextet || !/^[0-9a-f]{1,4}$/.test(firstHextet)) {
return false;
}
const hextet = parseInt(firstHextet, 16);
// /10 mask (0xffc0) preserves top 10 bits: fe80 = 1111_1110_10xx_xxxx
return (hextet & 0xffc0) === 0xfe80;
}
/** Checks if an IPv6 address embeds a private IPv4 via 6to4, NAT64, or Teredo */
function hasPrivateEmbeddedIPv4(ipv6: string): boolean {
if (!ipv6.startsWith('2002:') && !ipv6.startsWith('64:ff9b::') && !ipv6.startsWith('2001::')) {
@ -132,9 +146,9 @@ export function isPrivateIP(ip: string): boolean {
if (
normalized === '::1' ||
normalized === '::' ||
normalized.startsWith('fc') ||
normalized.startsWith('fc') || // fc00::/7 — exactly prefixes 'fc' and 'fd'
normalized.startsWith('fd') ||
normalized.startsWith('fe80')
isIPv6LinkLocal(normalized) // fe80::/10 — spans 0xfe800xfebf; bitwise check required
) {
return true;
}