Skip to content

Fix IPAddr#private?, #link_local? and #loopback? incorrectly matching public IPv6 addresses as IPv4-mapped#106

Merged
taketo1113 merged 1 commit intoruby:masterfrom
jarthod:fix-ipv4-mapped-detection-in-private-loopback-link-local
Apr 23, 2026
Merged

Fix IPAddr#private?, #link_local? and #loopback? incorrectly matching public IPv6 addresses as IPv4-mapped#106
taketo1113 merged 1 commit intoruby:masterfrom
jarthod:fix-ipv4-mapped-detection-in-private-loopback-link-local

Conversation

@jarthod
Copy link
Copy Markdown
Contributor

@jarthod jarthod commented Apr 20, 2026

Summary

IPAddr#private?, #loopback?, and #link_local? all use this pattern to detect whether an IPv6 address is an IPv4-mapped private/loopback/link-local address:

@addr & 0xffff_0000_0000 == 0xffff_0000_0000

This mask only tests bits 32–47 of the 128-bit address. It does not verify that the upper 80 bits are zero, which is required for a true IPv4-mapped address (::ffff:x.x.x.x = 0000:0000:0000:0000:0000:ffff:x:x).

Any native IPv6 global unicast address that has 0xffff in its 5th 16-bit group will incorrectly match. For example:

IPAddr.new("2001:718:1404:c8:0:ffff:ac19:c80e").private?   # => true  (wrong)
IPAddr.new("2001:db8:1:1:0:ffff:a9fe:101").link_local?     # => true  (wrong)
IPAddr.new("2001:db8:1:1:0:ffff:7f00:1").loopback?         # => true  (wrong)

These are legitimate globally routable addresses. ipv4_mapped? correctly returns false for all of them (it's implementation is more precise)

Fix

Replace the loose mask with a right-shift that validates all 96 upper bits at once, exactly the same test that ipv4_mapped? is doing:

# Before — only checks bits 32-47
@addr & 0xffff_0000_0000 == 0xffff_0000_0000

# After — checks that bits 32-127 are exactly ::ffff: (0000:0000:0000:0000:0000:ffff:)
@addr >> 32 == 0xffff

@addr >> 32 == 0xffff requires the upper 96 bits to equal 0x0000_0000_0000_0000_0000_ffff, which is the precise definition of the IPv4-mapped prefix.

Real-world impact

This issue was brought to my attention by a savvy user of my service whose monitored site has AAAA record 2001:718:1404:c8:0:ffff:ac19:c80e (a perfectly valid and working global unicast IPv6). My SSRF protection, which relied on IPAddr#private?, started incorrectly rejecting it as a private address.

Do not hesitate if you need any other tweaks, tests or explanation.
Thanks !

@jarthod jarthod requested a review from knu as a code owner April 20, 2026 07:30
@taketo1113
Copy link
Copy Markdown
Collaborator

+1

I verified that 2001:718:1404:c8:0:ffff:ac19:c80e is a globally reachable address allocated via RIPE:
https://apps.db.ripe.net/db-web-ui/query?bflag=false&dflag=false&rflag=true&searchtext=2001:718:1404:c8:0:ffff:ac19:c80e&source=RIPE

I also confirmed the behavior introduced by this change.

IPAddr.new("2001:718:1404:c8:0:ffff:ac19:c80e").private? #=> false (as expected)
IPAddr.new("::ffff:ac19:c80e").to_s #=> "::ffff:172.25.200.14"
IPAddr.new("::ffff:ac19:c80e").private? #=> true

IPAddr.new("2001:718:1404:c8:0:ffff:a9fe:101").link_local? #=> false (as expected)
IPAddr.new("::ffff:a9fe:101").to_s #=> "::ffff:169.254.1.1"
IPAddr.new("::ffff:a9fe:101").link_local? #=> true

IPAddr.new("2001:718:1404:c8:0:ffff:7f00:1").loopback? #=> false (as expected)
IPAddr.new("::ffff:7f00:1").to_s #=> "::ffff:127.0.0.1"
IPAddr.new("::ffff:7f00:1").loopback? #=> true

@taketo1113 taketo1113 merged commit cc5c97e into ruby:master Apr 23, 2026
29 checks passed
@taketo1113
Copy link
Copy Markdown
Collaborator

@jarthod Thank you for your contribution. It's a good catch.
(This comment is from a maintainer. I became one yesterday :))

@jarthod
Copy link
Copy Markdown
Contributor Author

jarthod commented Apr 24, 2026

Awesome, thank you 🥳

@taketo1113
Copy link
Copy Markdown
Collaborator

@jarthod I have released ipaddr v1.2.9. Please give it a try.
https://github.com/ruby/ipaddr/releases/tag/v1.2.9

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