diff --git a/miniserv.pl b/miniserv.pl index e61f9faf6..71a1cfaff 100755 --- a/miniserv.pl +++ b/miniserv.pl @@ -7027,26 +7027,49 @@ return $_[0] =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/ && } # Check if some IPv6 address is properly formatted, and returns 1 if so. +# Kept in sync with web-lib-funcs.pl's copy. sub check_ip6address { - my @blocks = split(/:/, $_[0]); - return 0 if (@blocks == 0 || @blocks > 8); - 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; - } - return 0 if ($m <0 || $m >128); - my $b; + + # 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); + my $empty = 0; - foreach $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)); + 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 && !($addr =~ /^::/ && $empty == 2)); return 1; } diff --git a/t/miniserv.t b/t/miniserv.t index 72a531516..cacdd0117 100644 --- a/t/miniserv.t +++ b/t/miniserv.t @@ -264,13 +264,41 @@ subtest 'check_ipaddress' => sub { ok(!miniserv::check_ipaddress('not an ip'), 'garbage rejected'); }; +# Kept in lockstep with t/web-lib-funcs-ip.t's matching subtest, since the +# two copies of check_ip6address must accept and reject the same inputs. subtest 'check_ip6address' => sub { + ok( miniserv::check_ip6address('::'), 'unspecified accepted'); ok( miniserv::check_ip6address('::1'), 'loopback accepted'); ok( miniserv::check_ip6address('2001:db8::1'), 'compressed form accepted'); - ok( miniserv::check_ip6address('1:2:3:4:5:6:7:8'), 'full form accepted'); - ok(!miniserv::check_ip6address('not an addr'), 'garbage rejected'); - ok(!miniserv::check_ip6address('1:2:3:4:5:6:7:8:9'), 'too many groups rejected'); + ok( miniserv::check_ip6address('1:2:3:4:5:6:7:8'), 'full eight-block form accepted'); + ok( miniserv::check_ip6address('2001:db8::'), 'trailing :: accepted (no netmask)'); + + # Netmask suffix โ€” both with leading and trailing :: shorthand. + ok( miniserv::check_ip6address('::1/64'), 'address/netmask accepted with leading ::'); + ok( miniserv::check_ip6address('2001:db8::/32'), 'address/netmask accepted with trailing ::'); + ok( miniserv::check_ip6address('::/0'), '::/0 default route accepted'); + ok( miniserv::check_ip6address('fe80::/10'), 'fe80::/10 link-local prefix accepted'); + ok(!miniserv::check_ip6address('::1/200'), 'netmask > 128 rejected'); + + # IPv4-in-IPv6 tails. + ok( miniserv::check_ip6address('::ffff:10.0.0.1'), 'IPv4-mapped (::ffff:N.N.N.N) accepted'); + ok( miniserv::check_ip6address('::ffff:0.0.0.0'), 'IPv4-mapped all-zero accepted'); + ok( miniserv::check_ip6address('::1.2.3.4'), 'IPv4-compatible (::N.N.N.N) accepted'); + ok( miniserv::check_ip6address('0:0:0:0:0:ffff:1.2.3.4'), + 'fully-expanded IPv4-mapped accepted'); + ok(!miniserv::check_ip6address('::ffff:256.0.0.1'), 'IPv4-mapped with octet > 255 rejected'); + ok(!miniserv::check_ip6address('::ffff:1.2.3'), 'IPv4-mapped with too-few octets rejected'); + + # 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(!miniserv::check_ip6address('10.0.0.1'), 'bare IPv4 rejected (type-discriminator contract)'); + ok(!miniserv::check_ip6address('1.2.3.4'), 'bare IPv4 rejected (type-discriminator contract)'); + ok(!miniserv::check_ip6address('gggg::1'), 'non-hex rejected'); + ok(!miniserv::check_ip6address('1:2:3:4:5:6:7:8:9'), 'too many groups rejected'); + ok(!miniserv::check_ip6address('::1::2'), 'multiple :: rejected'); + ok(!miniserv::check_ip6address('not an addr'), 'garbage rejected'); }; # canonicalize_ip6 / expand_ipv6_bytes