Why an Allowed IPv4 Address Looked Forbidden

An IPv4-mapped IPv6 address passed one family check, then was normalized with the wrong text and mask. Canonicalizing before adding the prefix fixed it.

Signalbin's endpoint allowlist accepted a bare IPv4-mapped IPv6 address, then normalized it into a network that did not describe that host. The family check and the text used to construct the CIDR disagreed.

I found the bug while reviewing the newly shipped allowlist on August 21, 2026. The fix landed in commit f12ba97, with an empirical reproduction and a regression test.

The address had two valid representations

Consider this input:

::ffff:203.0.113.5

Go's net.IP.To4() recognizes it as an IPv4 address. The normalizer therefore selected a 32-bit host mask. The old code then appended /32 to the original text, producing:

::ffff:203.0.113.5/32

When net.ParseCIDR parsed that string, the colon made it an IPv6 network. The result was a 32-bit prefix over a 128-bit address, not an IPv4 host route. The allowlist stored a much broader and semantically different network than intended.

The fix was to canonicalize the address before constructing the CIDR. If To4() succeeds, use the four-byte address's string form and append /32. Otherwise use the canonical IPv6 form and append /128.

With Go's newer net/netip API, the same intent is expressed explicitly by 1: convert an IPv4-mapped IPv6 address to its IPv4 form before family-dependent operations.

Validate the representation you will store

The bug survived because each local decision looked reasonable. The code correctly detected an IPv4-capable address. It correctly chose 32 as an IPv4 host prefix. It correctly called a CIDR parser. The mismatch existed between those decisions, where original user text was reused after the address family had been canonicalized conceptually.

The regression test now submits the mapped form as a bare allowlist entry and asserts that it is stored as:

203.0.113.5/32

That assertion is stronger than checking that parsing succeeds. It verifies the security-relevant meaning of the normalized value.

Text is not an address family

Looking for a colon, preserving a user's spelling, or choosing a mask before canonicalization all create opportunities for mixed representations to disagree. Parse once, reduce mapped addresses to the family the application intends to treat them as, then derive both text and prefix length from that canonical value.

IP allowlists are a poor place for representation ambiguity. A normalized string is not cosmetic output when it is parsed again for authorization. It is part of the policy, so the test should assert the exact network that the policy will evaluate.