Skip to content

fix(auth/oauth2): block SSRF via IPv4-mapped IPv6 and fix dead 172.16/12 check#341

Closed
g0w6y wants to merge 1 commit into
google:mainfrom
g0w6y:fix/ssrf-ipv4-mapped-ipv6-bypass-oauth2-discovery
Closed

fix(auth/oauth2): block SSRF via IPv4-mapped IPv6 and fix dead 172.16/12 check#341
g0w6y wants to merge 1 commit into
google:mainfrom
g0w6y:fix/ssrf-ipv4-mapped-ipv6-bypass-oauth2-discovery

Conversation

@g0w6y
Copy link
Copy Markdown
Contributor

@g0w6y g0w6y commented May 13, 2026

validateDiscoveryUrl() had three bugs that allowed SSRF via crafted URLs. Both discoverAuthServerMetadata() and discoverResourceMetadata() pass user-controlled URLs directly into this function.

What was broken

1. IPv4-mapped IPv6 bypass (CWE-918)
The WHATWG URL parser serialises IPv4-mapped addresses as compressed hex [::ffff:7f00:1] not 127.0.0.1. The blocklist never matched, so every blocked IPv4 range was reachable via its IPv6-mapped form.

2. Dead 172.16.0.0/12 check
secondOcte was declared but secondOctet was referenced in the condition. ReferenceError silently caught the entire 172.16–172.31 range was never blocked.

3. IPv6 ULA fd::/8 not blocked
The regex f[ce89ab] matched fc::/8 but missed fd::/8. RFC 4193 §3.2.2 defines fd::/8 as Locally Assigned ULA the prefix used by default in Docker, Kubernetes, and AWS dual-stack.

What was fixed

  • Added normaliseHostname() to unwrap [::ffff:HHHH:LLLL] to dotted-decimal before any blocklist check
  • Renamed secondOctesecondOctet
  • Fixed ULA regex to /^\[(f[cd][0-9a-f]{2}|fe[89ab][0-9a-f]):/i covers both fc::/8 and fd::/8
  • Added missing ranges: 0.0.0.0, 100.64.0.0/10, fc00::/7, fe80::/10

Tests added

// IPv4-mapped IPv6 bypass vectors
'https://[::ffff:7f00:1]/'    // 127.0.0.1        -> blocked
'https://[::ffff:a9fe:a9fe]/' // 169.254.169.254   -> blocked
'https://[::ffff:a00:1]/'     // 10.0.0.1          -> blocked
'https://[::ffff:ac10:1]/'    // 172.16.0.1        -> blocked
'https://[::ffff:c0a8:101]/'  // 192.168.1.1       -> blocked

// Private IPv4 range edges
'https://172.16.0.1/'  // blocked
'https://172.31.0.1/'  // blocked
'https://172.15.0.1/'  // allowed
'https://172.32.0.1/'  // allowed

// RFC 6598 CGNAT
'https://100.64.0.1/'  // blocked
'https://100.127.0.1/' // blocked
'https://100.63.0.1/'  // allowed

// IPv6 ULA — fd::/8 regression
'https://[fd00::1]/'       // blocked
'https://[fdab:1234::1]/'  // blocked
'https://[fc00::1]/'       // blocked

// IPv6 link-local
'https://[fe80::1]/'  // blocked
'https://[febf::1]/'  // blocked

// Legitimate public IdPs
'https://accounts.google.com/'        // allowed
'https://login.microsoftonline.com/'  // allowed

Copy link
Copy Markdown
Collaborator

@kalenkevich kalenkevich left a comment

Choose a reason for hiding this comment

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

Thanks a a lot, can you please provide some tests as well?

…/12 check

Three bugs fixed in validateDiscoveryUrl() — both call sites
(discoverAuthServerMetadata and discoverResourceMetadata) pass
user-controlled URLs directly into this function.

Bug 1 — SSRF via IPv4-mapped IPv6 (CWE-918)
The WHATWG URL parser returns IPv4-mapped addresses in compressed hex
e.g. [::ffff:7f00:1], not 127.0.0.1. Nothing in the old blocklist
matched that form, bypassing every blocked IPv4 range:

  https://[::ffff:7f00:1]/    -> 127.0.0.1        (loopback)
  https://[::ffff:a9fe:a9fe]/ -> 169.254.169.254   (IMDS)
  https://[::ffff:a00:1]/     -> 10.0.0.1          (RFC 1918)
  https://[::ffff:ac10:1]/    -> 172.16.0.1        (RFC 1918)
  https://[::ffff:c0a8:101]/  -> 192.168.1.1       (RFC 1918)

Fix: normaliseHostname() unwraps [::ffff:HHHH:LLLL] to dotted-decimal
before any blocklist check runs.

Bug 2 — Dead 172.16.0.0/12 check
Variable declared as secondOcte, condition referenced secondOctet.
ReferenceError silently caught — entire 172.16-172.31 block unreachable.
Fix: rename secondOcte to secondOctet.

Bug 3 — IPv6 ULA fd::/8 bypass (regex missing 'd')
/^\[f[ce89ab][0-9a-f]{2}:/ blocked fc::/8 but not fd::/8.
fd::/8 is Locally Assigned ULA (RFC 4193 3.2.2) — the default prefix
in Docker, Kubernetes and AWS dual-stack environments.

  https://[fd00::1]/      -> validateDiscoveryUrl returned true (SSRF)
  https://[fdab:1234::1]/ -> validateDiscoveryUrl returned true (SSRF)

Fix: /^\[(f[cd][0-9a-f]{2}|fe[89ab][0-9a-f]):/i
  f[cd] covers fc::/8 and fd::/8 (full fc00::/7)
  fe[89ab] covers fe80-febf (precise fe80::/10)

Also adds previously missing blocked ranges:
  0.0.0.0       — unspecified, routes to loopback on many stacks
  100.64.0.0/10 — RFC 6598 CGNAT shared address space
  fc00::/7      — IPv6 ULA (both halves)
  fe80::/10     — IPv6 link-local

Tests: 27 cases added to oauth2_discovery_test.ts covering all blocked
ranges, both edges of each range, the five IPv4-mapped bypass vectors,
the fd::/8 regression, and legitimate public IdP URLs.
@g0w6y g0w6y force-pushed the fix/ssrf-ipv4-mapped-ipv6-bypass-oauth2-discovery branch from 5b488ea to 4417c96 Compare May 15, 2026 08:01
@g0w6y g0w6y changed the title fix(auth/oauth2): block IPv4-mapped IPv6 SSRF bypass + dead 172.16/12 check in validateDiscoveryUrl fix(auth/oauth2): block SSRF via IPv4-mapped IPv6 and fix dead 172.16/12 check May 15, 2026
@g0w6y
Copy link
Copy Markdown
Contributor Author

g0w6y commented May 15, 2026

Tests added in 4417c96 covering all fix vectors please review @kalenkevich

@g0w6y g0w6y requested a review from kalenkevich May 16, 2026 12:25
@g0w6y g0w6y closed this by deleting the head repository May 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants