diff --git a/t/web-lib-funcs-ip.t b/t/web-lib-funcs-ip.t index 7d1da8658..8412c33e8 100644 --- a/t/web-lib-funcs-ip.t +++ b/t/web-lib-funcs-ip.t @@ -35,9 +35,9 @@ subtest 'check_ipaddress' => sub { # check_ip6address — IPv6, optionally with /N netmask suffix. # -# Unlike the docstring, this sub also accepts an address/netmask form, but -# only when the `::` shorthand is at the *start* of the address — see the -# bug notes below. Pin current behaviour so a future fix shows up loudly. +# Accepts the standard text forms, the "::" shorthand at any position, an +# optional /N netmask, and the IPv4-in-IPv6 dotted-quad tail (RFC 4291 +# §2.5.5: "::ffff:N.N.N.N" mapped and "X:X:X:X:X:X:N.N.N.N" compatible). subtest 'check_ip6address' => sub { ok( main::check_ip6address('::'), 'unspecified accepted'); ok( main::check_ip6address('::1'), 'loopback accepted'); @@ -45,23 +45,27 @@ subtest 'check_ip6address' => sub { ok( main::check_ip6address('1:2:3:4:5:6:7:8'), 'full eight-block form accepted'); ok( main::check_ip6address('2001:db8::'), 'trailing :: accepted (no netmask)'); - # Netmask suffix. - ok( main::check_ip6address('::1/64'), 'address/netmask accepted when :: is at start'); - ok(!main::check_ip6address('::1/200'), 'netmask > 128 rejected'); + # Netmask suffix — both with leading and trailing :: shorthand. + ok( main::check_ip6address('::1/64'), 'address/netmask accepted with leading ::'); + ok( main::check_ip6address('2001:db8::/32'), 'address/netmask accepted with trailing ::'); + ok( main::check_ip6address('::/0'), '::/0 default route accepted'); + ok( main::check_ip6address('fe80::/10'), 'fe80::/10 link-local prefix accepted'); + ok(!main::check_ip6address('::1/200'), 'netmask > 128 rejected'); - # BUG: a netmask suffix combined with a trailing `::` shorthand - # fails. The validator's empty-block accounting is thrown off because - # split() no longer trims trailing empties when the final element is - # the netmask. Real-world example: "2001:db8::/32" — a perfectly - # valid CIDR — is rejected. - ok(!main::check_ip6address('2001:db8::/32'), - 'BUG: valid CIDR with trailing :: rejected by validator'); + # IPv4-in-IPv6 tails. + ok( main::check_ip6address('::ffff:10.0.0.1'), 'IPv4-mapped (::ffff:N.N.N.N) accepted'); + ok( main::check_ip6address('::ffff:0.0.0.0'), 'IPv4-mapped all-zero accepted'); + ok( main::check_ip6address('::1.2.3.4'), 'IPv4-compatible (::N.N.N.N) accepted'); + ok( main::check_ip6address('0:0:0:0:0:ffff:1.2.3.4'), + 'fully-expanded IPv4-mapped accepted'); + ok(!main::check_ip6address('::ffff:256.0.0.1'), 'IPv4-mapped with octet > 255 rejected'); + ok(!main::check_ip6address('::ffff:1.2.3'), 'IPv4-mapped with too-few octets rejected'); - # BUG: IPv4-mapped IPv6 (RFC 4291 §2.5.5.2) is rejected because the - # per-block regex requires hex digits. Notably, is_non_public_ipaddress - # has an unreachable ::ffff:N.N.N.N branch downstream of this check. - ok(!main::check_ip6address('::ffff:10.0.0.1'), - 'BUG: IPv4-mapped IPv6 rejected by validator'); + # Bare IPv4 must be rejected — callers (e.g. ip_match) use this sub + # as a type discriminator and a true result re-routes IPv4 input + # through the IPv6 codepath. + ok(!main::check_ip6address('10.0.0.1'), 'bare IPv4 rejected (type-discriminator contract)'); + ok(!main::check_ip6address('1.2.3.4'), 'bare IPv4 rejected (type-discriminator contract)'); ok(!main::check_ip6address('gggg::1'), 'non-hex rejected'); ok(!main::check_ip6address('1:2:3:4:5:6:7:8:9'), 'too many groups rejected'); @@ -111,12 +115,11 @@ subtest 'is_non_public_ipaddress (IPv6)' => sub { ok( main::is_non_public_ipaddress('fc00::1'), 'ULA (fc00)'); ok( main::is_non_public_ipaddress('fd12::1'), 'ULA (fd12)'); - # IPv4-mapped (::ffff:N.N.N.N) is meant to recurse on the embedded - # IPv4, but the branch is unreachable: check_ip6address rejects all - # ::ffff:N.N.N.N inputs (see BUG note in check_ip6address subtest). - # Both calls below currently return 0 — pin that. - ok(!main::is_non_public_ipaddress('::ffff:10.0.0.1'), - 'BUG: ::ffff: falsely reported as public (validator rejects input)'); + # IPv4-mapped (::ffff:N.N.N.N) recurses on the embedded IPv4. + ok( main::is_non_public_ipaddress('::ffff:10.0.0.1'), + '::ffff: recurses → non-public'); + ok( main::is_non_public_ipaddress('::ffff:192.168.1.1'), + '::ffff: recurses → non-public'); ok(!main::is_non_public_ipaddress('::ffff:8.8.8.8'), '::ffff: reported as public'); diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index 920949c74..9cb93fe27 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -693,35 +693,48 @@ Check if some IPv6 address is properly formatted, and returns 1 if so. =cut sub check_ip6address { -# Special case for unspecified address (analogous to 0.0.0.0 in IPv4) -return 1 if ($_[0] eq "::"); -my @blocks = split(/:/, $_[0]); -return 0 if (@blocks == 0 || @blocks > 8); - -# The address/netmask format is accepted. So we're looking for a "/" to isolate a possible netmask. -# After that, we delete the netmask to control the address only format, but we verify whether the netmask -# value is in [0;128]. -my $ib = $#blocks; -my $where = index($blocks[$ib],"/"); +my $addr = $_[0]; my $m = 0; -if ($where != -1) { -my $b = substr($blocks[$ib],0,$where); -$m = substr($blocks[$ib],$where+1,length($blocks[$ib])-($where+1)); -$blocks[$ib]=$b; -} -# The netmask must take its value in [0;128] -return 0 if ($m <0 || $m >128); +# Strip an optional /N netmask before splitting. Doing this on the +# raw string (rather than from the last split element) keeps split()'s +# trailing-empty accounting intact for inputs like "2001:db8::/32", +# where the netmask would otherwise hide the trailing "::" shorthand. +if ($addr =~ s{/(\d+)\z}{}) { + $m = $1; + } +return 0 if ($m < 0 || $m > 128); + +# Special case for unspecified address (analogous to 0.0.0.0 in IPv4), +# both bare and with a netmask. +return 1 if ($addr eq "::"); + +my @blocks = split(/:/, $addr); +return 0 if (@blocks == 0); + +# Accept the IPv4-in-IPv6 forms (RFC 4291 §2.5.5: "::ffff:N.N.N.N" +# IPv4-mapped, and the more general "X:X:X:X:X:X:N.N.N.N"). If the +# last block is a dotted-quad, validate the octets and count it as two +# 16-bit groups for the overall 8-group ceiling. The leading ":" guard +# distinguishes IPv4-tailed IPv6 from a bare IPv4 address — callers +# like ip_match() rely on this sub returning false for "10.0.0.1". +my $count = scalar(@blocks); +if ($addr =~ /:/ && + $blocks[-1] =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)\z/) { + return 0 if ($1 > 255 || $2 > 255 || $3 > 255 || $4 > 255); + $count++; + pop(@blocks); + } +return 0 if ($count > 8); # Check the different blocks of the address : 16 bits block in hexa notation. # Possibility of 1 empty block or 2 if the address begins with "::". -my $b; my $empty = 0; -foreach $b (@blocks) { +foreach my $b (@blocks) { return 0 if ($b ne "" && $b !~ /^[0-9a-f]{1,4}$/i); $empty++ if ($b eq ""); } -return 0 if ($empty > 1 && !($_[0] =~ /^::/ && $empty == 2)); +return 0 if ($empty > 1 && !($addr =~ /^::/ && $empty == 2)); return 1; }