IPv4-Mapped IPv6 Addresses Bypass a Set-Based ipaddress Blocklist (tested)
An IPv6 address can embed an IPv4 address inside it -- ::ffff:127.0.0.1 means the same host as 127.0.0.1. We measured whether Python's ipaddress module treats them as equal for blocklist purposes. Checking attributes like is_loopback correctly recognizes it. Checking set membership against a list of plain IPv4Address objects does not.
1. is_loopback/is_private correctly recognize the IPv4-mapped form
import ipaddress
mapped = ipaddress.ip_address("::ffff:127.0.0.1")
print(type(mapped).__name__)
print(mapped.is_loopback)
print(ipaddress.ip_address("::ffff:192.168.1.1").is_private)
IPv6Address
True
True
Measured: ipaddress parses "::ffff:127.0.0.1" as an IPv6Address object, but its is_loopback and is_private properties correctly see through the IPv4-mapped encoding and return the same answer as the equivalent plain IPv4 address. This part works exactly as you'd hope.
2. But it silently fails a set-based blocklist check
import ipaddress
blocklist = {ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("10.0.0.5")}
candidate = ipaddress.ip_address("::ffff:127.0.0.1")
print(candidate in blocklist)
print(candidate.is_loopback)
False
True
Measured: the exact address that is_loopback just confirmed is loopback -- i.e. the same host as an entry already in the blocklist -- returns False for membership in a set of IPv4Address objects. IPv4Address and IPv6Address instances are never equal to each other in Python, regardless of what address they represent, so a blocklist built by collecting IP objects and comparing with in or == silently lets the IPv4-mapped IPv6 form through.
3. The fix: normalize with .ipv4_mapped before comparing
import ipaddress
blocklist = {ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("10.0.0.5")}
candidate = ipaddress.ip_address("::ffff:127.0.0.1")
normalized = candidate.ipv4_mapped
print(normalized)
print(normalized in blocklist)
127.0.0.1
True
Measured: IPv6Address.ipv4_mapped returns the embedded IPv4Address (or None if the address isn't in that mapped form), and comparing that against the blocklist correctly matches. If any code path accepts raw IP strings from a client and checks them against an address-object blocklist, it needs to call .ipv4_mapped (falling back to the address itself when it's None) before comparing -- otherwise an attacker can reach a blocked address just by sending its IPv4-mapped IPv6 spelling instead of the plain IPv4 form.
Frequently asked questions
Does Python's ipaddress module treat ::ffff:127.0.0.1 and 127.0.0.1 as equal?
No -- we measured ipaddress.ip_address('::ffff:127.0.0.1') == ipaddress.ip_address('127.0.0.1') returning False, and the IPv6 form failing membership in a set containing the IPv4 form, even though they represent the same host.
Does is_loopback work correctly on IPv4-mapped IPv6 addresses?
Yes -- we measured ipaddress.ip_address('::ffff:127.0.0.1').is_loopback returning True, correctly identifying it as loopback despite being parsed as an IPv6Address object.
Can an IP blocklist in Python be bypassed with IPv4-mapped IPv6 notation?
Yes, if the blocklist is a set/list of address objects compared with == or in -- we measured a blocked IPv4 address's IPv4-mapped IPv6 spelling passing the membership check undetected, while per-address checks like is_loopback still caught it correctly.
How do I compare an IPv4-mapped IPv6 address against a list of IPv4 addresses?
Use the .ipv4_mapped attribute on the IPv6Address object to get the embedded IPv4Address first, then compare that. We confirmed this normalized form correctly matches the equivalent plain IPv4 entry in a set.
What does IPv6Address.ipv4_mapped return for a normal IPv6 address?
None -- it's only populated for addresses in the ::ffff:a.b.c.d mapped form, per the ipaddress module's design; we measured it returning the embedded IPv4Address object only for that specific address shape.
Written with AI assistance. Measured on 2026-10-04 with Python 3.13.5's built-in ipaddress module: built a blocklist as a set of IPv4Address objects, then checked membership for the IPv4-mapped IPv6 form of a blocked address ('::ffff:127.0.0.1') both directly and after normalizing with the .ipv4_mapped attribute, and separately checked .is_loopback/.is_private on the same address. PlainHeaders is our extension; Python's ipaddress module is built in.