diff --git a/packages/api/src/auth/domain.spec.ts b/packages/api/src/auth/domain.spec.ts index 9812960cd9..8ba72d82a2 100644 --- a/packages/api/src/auth/domain.spec.ts +++ b/packages/api/src/auth/domain.spec.ts @@ -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 (fe80–febf)', () => { + 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(); }); diff --git a/packages/api/src/auth/domain.ts b/packages/api/src/auth/domain.ts index 2761a80b55..37510f5e9b 100644 --- a/packages/api/src/auth/domain.ts +++ b/packages/api/src/auth/domain.ts @@ -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 0xfe80–0xfebf; bitwise check required ) { return true; }