Merge pull request #2789 from webmin/dev/download-target-check

Add ACL controls for URL download destinations
This commit is contained in:
Jamie Cameron
2026-07-14 10:09:40 -07:00
committed by GitHub
16 changed files with 450 additions and 27 deletions
+1
View File
@@ -1,6 +1,7 @@
## Changelog
#### 2.652 (July, 2026)
* Add a global per-user ACL control to block URL downloads from non-public IP addresses in File Manager, Mailboxes, and Upload and Download modules
* Fix to recognize hex numeric HTML entities to work in various elements
* Fix `patch` sub-command to reload Webmin instead of restarting
* Fix SSL certificate and TCP monitors to report transient connection failures as down, and SSL check timeouts as timed out, rather than uninstalled
+1 -1
View File
File diff suppressed because one or more lines are too long
+46
View File
@@ -237,6 +237,52 @@ ok(defined &validate_password, 'acl-lib loaded validate_password');
ok(defined &acl_security_save, 'acl_security.pl loaded acl_security_save');
ok(defined &list_acl_yesno_fields, 'acl_security.pl loaded list_acl_yesno_fields');
# Load the top-level global ACL form in a separate namespace, so its
# acl_security_save function does not replace the ACL module's function.
{
package GlobalACLTest;
our %in;
my $r = do "$rootdir/acl_security.pl";
die "compile global acl_security.pl: $@" if $@;
die "open global acl_security.pl: $!" if !defined($r) && $!;
}
{
no warnings 'once';
my (%default_acl, %safe_acl);
read_file("$rootdir/defaultacl", \%default_acl);
read_file("$rootdir/safeacl", \%safe_acl);
is($default_acl{'download_address_mode'}, 'public',
'global default ACL defines the download address mode');
is($safe_acl{'download_address_mode'}, 'public',
'global safe ACL defines the download address mode');
}
{
no warnings 'once';
local %GlobalACLTest::in = (
download_address_mode => 'listed',
download_allowed_addresses => "10.0.0.0/8\n127.0.0.1",
);
my %o;
GlobalACLTest::acl_security_save(\%o);
is($o{'download_address_mode'}, 'listed',
'global ACL saves download address mode');
is($o{'download_allowed_addresses'}, '10.0.0.0/8 127.0.0.1',
'global ACL normalizes download address exceptions');
}
{
no warnings 'once';
local %GlobalACLTest::in = (
download_address_mode => 'invalid',
);
my %o;
GlobalACLTest::acl_security_save(\%o);
is($o{'download_address_mode'}, 'public',
'global ACL rejects an invalid download address mode');
}
# to64: small deterministic vectors over the itoa64 alphabet
# "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
is(to64(0, 1), '.', 'to64 first char');
+23
View File
@@ -28,6 +28,23 @@ print &ui_table_row($text{'acl_fileunix'},
$text{'acl_sameunix'})." ".
&user_chooser_button("fileunix"));
# Destinations allowed for URL downloads
my $download_address_mode = $o->{'download_address_mode'};
print &ui_table_row(
$text{'acl_download_addresses'} .
&ui_help($text{'acl_download_addresses_desc'}),
&ui_radio_table(
"download_address_mode", $download_address_mode,
[ [ 'all', $text{'acl_download_all'} ],
[ 'public', $text{'acl_download_public'} ],
[ 'listed', $text{'acl_download_listed'}."<br>".
&ui_textarea(
"download_allowed_addresses",
join("\n", split(/\s+/,
$o->{'download_allowed_addresses'})),
4, 50) ] ]),
3);
print &ui_hr();
# Users visible in chooser
@@ -119,5 +136,11 @@ $o->{'negative'} = $in{'negative'};
$o->{'readonly'} = $in{'readonly'};
$o->{'fileunix'} = $in{'fileunix_def'} ? undef : $in{'fileunix'};
$o->{'webminsearch'} = $in{'webminsearch'};
my $download_address_mode = $in{'download_address_mode'};
$download_address_mode = 'public'
if ($download_address_mode !~ /^(public|listed|all)$/);
$o->{'download_address_mode'} = $download_address_mode;
$o->{'download_allowed_addresses'} = join(" ",
split(/\s+/, $in{'download_allowed_addresses'}));
}
+1
View File
@@ -7,3 +7,4 @@ readonly=0
nodot=0
fileunix=root
webminsearch=1
download_address_mode=public
+14 -2
View File
@@ -3,6 +3,7 @@
require './filemin-lib.pl';
&ReadParse();
my %download_access = &get_module_acl(undef, "");
get_paths();
if (!$in{'link'}) {
@@ -39,12 +40,23 @@ else {
$progress_callback_url = $in{'link'};
my @st = stat($cwd);
my $address_checker = &get_download_address_callback(
$download_access{'download_address_mode'},
$download_access{'download_allowed_addresses'});
my $download_callback = sub {
if ($_[0] == 7 && defined($_[1]) && $address_checker) {
my $address_error = &$address_checker(
$host, [ $_[1] ]);
&error(&html_escape($address_error)) if ($address_error);
}
&progress_callback(@_);
};
if ($ssl == 0 || $ssl == 1) {
# HTTP or HTTPS download
&http_download(
$host, $port, $page,
$full, undef,
\&progress_callback,
$download_callback,
$ssl, $in{'username'},
$in{'password'});
}
@@ -53,7 +65,7 @@ else {
&ftp_download(
$host, $page, $full,
undef,
\&progress_callback,
$download_callback,
$in{'username'},
$in{'password'}, $port);
}
+5
View File
@@ -224,6 +224,11 @@ acl_negative=Grant new module permissions to user?
acl_fileunix=Browse files as Unix user
acl_sameunix=Same as Webmin login
acl_webminsearch=Show Webmin search field?
acl_download_addresses=URL download destinations
acl_download_addresses_desc=Public-only mode blocks private, loopback, link-local, reserved and multicast IP addresses. Listed exceptions may be entered as one IP address or CIDR network per line.
acl_download_public=Public IP addresses only
acl_download_listed=Public IP addresses and these exceptions
acl_download_all=All IP addresses
month_1=January
month_2=February
+53 -1
View File
@@ -6,12 +6,14 @@ use Cwd qw(abs_path);
use File::Basename qw(dirname);
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use POSIX ();
my $root = abs_path(dirname(__FILE__)."/../..") or die "rootdir: $!";
my $tmp = tempdir(CLEANUP => 1);
our (%config, %userconfig, $module_config_directory, $module_var_directory,
$user_module_config_directory);
$user_module_config_directory, %in, %gconfig, %access, %global_access,
$root_directory, $remote_user, $current_theme);
$module_config_directory = "$tmp/config";
$module_var_directory = "$tmp/var";
make_path($module_config_directory, $module_var_directory);
@@ -49,6 +51,7 @@ return 1;
require "$root/mailboxes/boxes-lib.pl";
require "$root/mailboxes/folders-lib.pl";
require "$root/mailboxes/xhr-lib.pl";
sub write_file
{
@@ -125,4 +128,53 @@ subtest 'mailbox_uncompress_folder skips invalid Maildir subfolders' => sub {
}
};
subtest 'XHR remote content uses global destination ACL' => sub {
my $pid = fork();
if (!defined($pid)) {
plan skip_all => 'fork unavailable';
}
if (!$pid) {
no warnings qw(once redefine);
%in = (
'action' => 'fetch',
'type' => 'download',
'subtype' => 'blob',
'url' => 'http://example.test/image.png',
);
%global_access = (
'download_address_mode' => 'listed',
'download_allowed_addresses' => '10.0.0.0/8',
);
local *html_unescape = sub { return $_[0]; };
local *parse_http_url = sub {
return ('example.test', 80, '/image.png', 0);
};
my $address_checker = sub {
POSIX::_exit(4)
if ($_[0] ne 'example.test' ||
$_[1]->[0] ne '127.0.0.1');
return 'mailbox-address-blocked';
};
local *get_download_address_callback = sub {
POSIX::_exit(3)
if ($_[0] ne 'listed' || $_[1] ne '10.0.0.0/8');
return $address_checker;
};
local *error = sub {
POSIX::_exit($_[0] eq 'mailbox-address-blocked' ? 0 : 1);
};
local *html_escape = sub { return $_[0]; };
local *http_download = sub {
my $callback = $_[5];
POSIX::_exit(5) if (ref($callback) ne 'CODE');
&$callback(7, '127.0.0.1');
POSIX::_exit(2);
};
xhr();
POSIX::_exit(6);
}
waitpid($pid, 0);
is($? >> 8, 0, 'XHR checks mode 7 through the existing callback');
};
done_testing();
+15 -2
View File
@@ -3,7 +3,8 @@
use strict;
our (%in, %gconfig, $root_directory, $remote_user, $current_theme);
our (%in, %gconfig, %access, %global_access, $root_directory, $remote_user,
$current_theme);
sub xhr
{
@@ -35,7 +36,19 @@ if ($in{'action'} eq "fetch") {
$url = &html_unescape($url);
my ($host, $port, $page, $ssl) = &parse_http_url($url);
my ($img, $err, $response_headers);
&http_download($host, $port, $page, \$img, \$err, undef,
my $address_checker = &get_download_address_callback(
$global_access{'download_address_mode'},
$global_access{'download_allowed_addresses'});
my $download_callback = sub {
if ($_[0] == 7 && defined($_[1]) && $address_checker) {
my $address_error = &$address_checker(
$host, [ $_[1] ]);
&error(&html_escape($address_error))
if ($address_error);
}
};
&http_download($host, $port, $page, \$img, \$err,
$download_callback,
$ssl, undef, undef, 10, undef, undef,
undef, \$response_headers);
# Check if download worked
+3 -1
View File
@@ -2,13 +2,15 @@
# A caller for loading XHR related routines
use strict;
our ($root_directory);
our ($root_directory, %access, %global_access);
BEGIN { push(@INC, "."); };
use WebminCore;
&init_config();
&ReadParse();
%access = &get_module_acl();
%global_access = &get_module_acl(undef, "");
&webmin_user_is_admin() or &switch_to_remote_user();
do "./xhr-lib.pl";
+1
View File
@@ -3,3 +3,4 @@ fileunix=
feedback=0
rpc=0
negative=0
download_address_mode=public
+107 -12
View File
@@ -17,6 +17,10 @@ my $script = File::Spec->rel2abs(
File::Spec->catfile(dirname(__FILE__), '..', 'web-lib-funcs.pl'));
require $script;
my $has_ipv6_packing = eval {
defined(main::inet_pton(main::AF_INET6(), '::1'));
};
# check_ipaddress — strict dotted-quad IPv4.
subtest 'check_ipaddress' => sub {
ok( main::check_ipaddress('1.2.3.4'), 'plain IPv4 accepted');
@@ -85,10 +89,9 @@ subtest 'check_ip6address' => sub {
# is_non_public_ipaddress — RFC1918 + reserved-range classifier.
#
# Returns 1 for: 0.x, 10.x, 127.x, 169.254/16, 172.16/12, 192.168/16,
# 100.64/10 (CGNAT), 224+/4 (multicast/reserved); IPv6 loopback, link-local
# (fe80febf), ULA (fc00/fd00), and ::ffff:N.N.N.N when the wrapped IPv4
# is itself non-public.
# Returns 1 for private, local, link-local, documentation, benchmarking,
# translation, reserved and multicast ranges in IPv4 and IPv6, including
# IPv4-mapped IPv6 addresses when the wrapped IPv4 is itself non-public.
subtest 'is_non_public_ipaddress (IPv4)' => sub {
# Private / reserved.
ok( main::is_non_public_ipaddress('10.0.0.1'), '10/8 private');
@@ -100,6 +103,10 @@ subtest 'is_non_public_ipaddress (IPv4)' => sub {
ok( main::is_non_public_ipaddress('0.1.2.3'), '0/8 reserved');
ok( main::is_non_public_ipaddress('100.64.0.1'), 'CGNAT 100.64/10 low');
ok( main::is_non_public_ipaddress('100.127.255.255'), 'CGNAT 100.64/10 high');
ok( main::is_non_public_ipaddress('192.0.2.1'), 'documentation network');
ok( main::is_non_public_ipaddress('198.18.0.1'), 'benchmarking network');
ok( main::is_non_public_ipaddress('198.51.100.1'), 'documentation network');
ok( main::is_non_public_ipaddress('203.0.113.1'), 'documentation network');
ok( main::is_non_public_ipaddress('224.0.0.1'), '224+ multicast / reserved');
ok( main::is_non_public_ipaddress('255.255.255.255'), '255+ reserved');
@@ -124,19 +131,107 @@ subtest 'is_non_public_ipaddress (IPv6)' => sub {
ok( main::is_non_public_ipaddress('feb0::1'), 'link-local (feb0)');
ok( main::is_non_public_ipaddress('fc00::1'), 'ULA (fc00)');
ok( main::is_non_public_ipaddress('fd12::1'), 'ULA (fd12)');
ok( main::is_non_public_ipaddress('ff02::1'), 'IPv6 multicast');
SKIP: {
skip 'IPv6 binary conversion unavailable', 10
if (!$has_ipv6_packing);
ok( main::is_non_public_ipaddress('64:ff9b::a9fe:a9fe'),
'NAT64 translation of link-local IPv4');
ok(!main::is_non_public_ipaddress('64:ff9b::808:808'),
'NAT64 translation of public IPv4 remains public');
ok( main::is_non_public_ipaddress('100::1'),
'discard-only prefix');
ok( main::is_non_public_ipaddress('2001:db8::1'),
'IPv6 documentation prefix');
ok( main::is_non_public_ipaddress('2002:7f00:1::'),
'6to4 translation of loopback IPv4');
ok(!main::is_non_public_ipaddress('2002:808:808::'),
'6to4 translation of public IPv4 remains public');
# IPv4-mapped (::ffff:N.N.N.N) recurses on the embedded IPv4.
ok( main::is_non_public_ipaddress('::ffff:10.0.0.1'),
'::ffff:<private> recurses → non-public');
ok( main::is_non_public_ipaddress('::ffff:192.168.1.1'),
'::ffff:<rfc1918> recurses → non-public');
ok(!main::is_non_public_ipaddress('::ffff:8.8.8.8'),
'::ffff:<public> reported as public');
# IPv4-mapped addresses recurse on the embedded IPv4.
ok( main::is_non_public_ipaddress('::ffff:10.0.0.1'),
'::ffff:<private> recurses → non-public');
ok( main::is_non_public_ipaddress('::ffff:192.168.1.1'),
'::ffff:<rfc1918> recurses → non-public');
ok( main::is_non_public_ipaddress('0:0:0:0:0:ffff:7f00:1'),
'expanded mapped loopback recurses → non-public');
ok(!main::is_non_public_ipaddress('::ffff:8.8.8.8'),
'::ffff:<public> reported as public');
}
# Plainly public IPv6.
ok(!main::is_non_public_ipaddress('2001:db8::1'), '2001:db8 is public per classifier');
ok(!main::is_non_public_ipaddress('2606:4700::1111'),
'global unicast address is public');
};
subtest 'ipaddress_matches_network' => sub {
ok( main::ipaddress_matches_network('10.1.2.3', '10.0.0.0/8'),
'IPv4 address matches CIDR');
ok(!main::ipaddress_matches_network('11.1.2.3', '10.0.0.0/8'),
'IPv4 address outside CIDR does not match');
ok( main::ipaddress_matches_network('192.168.1.2', '192.168.1.2'),
'exact IPv4 address matches');
SKIP: {
skip 'IPv6 binary conversion unavailable', 3
if (!$has_ipv6_packing);
ok( main::ipaddress_matches_network(
'fd00:1234::20', 'fd00:1234::/48'),
'IPv6 address matches CIDR');
ok(!main::ipaddress_matches_network(
'fd00:1235::20', 'fd00:1234::/48'),
'IPv6 address outside CIDR does not match');
ok( main::ipaddress_matches_network(
'::ffff:10.1.2.3', '10.0.0.0/8'),
'IPv4 exception matches mapped IPv6 destination');
}
ok(!main::ipaddress_matches_network('10.1.2.3', 'bad-network'),
'invalid exception does not match');
};
subtest 'download address callback' => sub {
my $public = main::get_download_address_callback('public');
is(&$public('public.test', [ '8.8.8.8' ]), undef,
'public destination is allowed in public mode');
like(&$public('loopback.test', [ '127.0.0.1' ]),
qr/non-public IP address 127\.0\.0\.1/,
'loopback destination is blocked in public mode');
like(&$public('metadata.test', [ '169.254.169.254' ]),
qr/non-public IP address 169\.254\.169\.254/,
'cloud metadata destination is blocked in public mode');
like(&$public('mixed.test', [ '8.8.8.8', '127.0.0.1' ]),
qr/non-public IP address 127\.0\.0\.1/,
'destination is blocked when any resolved address is non-public');
my $listed = main::get_download_address_callback(
'listed', '10.0.0.0/8');
is(&$listed('listed.test', [ '10.1.2.3' ]),
undef, 'listed CIDR permits a private destination');
like(&$listed('unlisted.test', [ '192.168.1.2' ]),
qr/not allowed/, 'unlisted private destination remains blocked');
ok(!defined(main::get_download_address_callback('all')),
'all mode does not install a destination callback');
ok(!defined(main::get_download_address_callback(undef)),
'unspecified policy preserves compatibility for existing callers');
my (@resolved, @checked);
my $callback = sub {
my ($host, $addresses) = @_;
@checked = @$addresses;
return undef;
};
is(main::check_download_address('8.8.8.8', $callback, \@resolved),
undef, 'destination is resolved and passed to its callback');
is_deeply(\@checked, [ '8.8.8.8' ],
'callback receives the resolved destination address');
is_deeply(\@resolved, \@checked,
'checked addresses are returned for the connection');
{
no warnings qw(once redefine);
local *main::to_ipaddress = sub { return; };
local *main::to_ip6address = sub { return; };
like(main::check_download_address('unresolved.example', $public),
qr/Failed to lookup IP address/,
'restricted policy fails closed when DNS cannot resolve');
}
};
done_testing();
+2
View File
@@ -6,6 +6,7 @@ require './updown-lib.pl';
use Time::Local;
&ReadParse();
&error_setup($text{'download_err'});
$can_download || &error($text{'download_ecannot'});
# Validate and store inputs
$i = 0;
@@ -96,6 +97,7 @@ if (!-d $download{'dir'} && $in{'mkdir'}) {
}
# Save the settings
$download{'webmin_user'} = $base_remote_user if (!$module_info{'usermin'});
if ($module_info{'usermin'}) {
&lock_file("$user_module_config_directory/config");
$userconfig{'ddir'} = $in{'dir'};
+1
View File
@@ -78,6 +78,7 @@ download_failed=Download failed : $1
download_eaccess=You are not allowed to download files to $1
download_eucannot=You are not allowed to download files as user $1
download_egcannot=The selected group does not contain the selected user
download_ecannot=You are not allowed to download files from URLs
download_eatd=Scheduled downloads are not possible unless the At daemon is running. It can be started in the <a href='$1'>scheduled commands</a> module.
cancel_err=Failed to cancel downloads
+27 -3
View File
@@ -31,6 +31,8 @@ if ($module_info{'usermin'}) {
$upload_dir = $userconfig{'dir'};
$upload_dir = $remote_user_info[7] if ($upload_dir eq "~");
$upload_max = $config{'max'};
$download_address_mode = 'public';
$download_allowed_addresses = undef;
$fetch_file = $userconfig{'fetch'};
$fetch_show = $userconfig{'show'} || 0;
}
@@ -40,6 +42,7 @@ else {
$atjob_cmd = "$module_config_directory/download.pl";
%access = &get_module_acl();
my %global_access = &get_module_acl(undef, "");
$can_upload = $access{'upload'};
$can_download = $access{'download'};
$can_fetch = $access{'fetch'} && !&is_readonly_mode();
@@ -72,6 +75,9 @@ else {
$upload_user = $config{'user_'.$remote_user} || $config{'user'};
$upload_group = $config{'group_'.$remote_group} || $config{'group'};
$upload_max = $access{'max'};
$download_address_mode = $global_access{'download_address_mode'};
$download_allowed_addresses =
$global_access{'download_allowed_addresses'};
$download_user = $config{'duser_'.$remote_user} || $config{'duser'};
$download_group = $config{'dgroup_'.$remote_group} || $config{'dgroup'};
$fetch_file = $config{'fetch_'.$remote_user};
@@ -127,6 +133,24 @@ unlink("$downloads_dir/$_[0]->{'id'}.down");
sub do_download
{
local ($i, $error, $msg);
my $address_mode = $download_address_mode;
my $allowed_addresses = $download_allowed_addresses;
if (!$module_info{'usermin'} && $_[0]->{'webmin_user'}) {
my %download_access = &get_module_acl(
$_[0]->{'webmin_user'}, "");
$address_mode = $download_access{'download_address_mode'};
$allowed_addresses = $download_access{'download_allowed_addresses'};
}
my $tracker_callback = $_[1];
my $address_checker = &get_download_address_callback(
$address_mode, $allowed_addresses);
my $download_callback = sub {
if ($_[0] == 7 && defined($_[1]) && $address_checker) {
my $address_error = &$address_checker(undef, [ $_[1] ]);
&error(&html_escape($address_error)) if ($address_error);
}
&$tracker_callback(@_) if ($tracker_callback);
};
for($i=0; $_[0]->{"url_$i"}; $i++) {
$error = undef;
$progress_callback_url = $_[0]->{"url_$i"};
@@ -153,17 +177,17 @@ for($i=0; $_[0]->{"url_$i"}; $i++) {
$_[0]->{"page_$i"},
$path,
\$error,
$_[1],
$download_callback,
$_[0]->{"ssl_$i"},
$_[0]->{"user_$i"},
$_[0]->{"pass_$i"});
}
else {
&ftp_download($_[0]->{"host_$i"},
$_[0]->{"page_$i"},
$_[0]->{"page_$i"},
$path,
\$error,
$_[1],
$download_callback,
$_[0]->{"user_$i"},
$_[0]->{"pass_$i"});
}
+150 -5
View File
@@ -801,21 +801,165 @@ if (&check_ipaddress($ip)) {
return 1 if ($o[0] == 169 && $o[1] == 254);
return 1 if ($o[0] == 172 && $o[1] >= 16 && $o[1] <= 31);
return 1 if ($o[0] == 192 && $o[1] == 168);
return 1 if ($o[0] == 192 && $o[1] == 0 &&
($o[2] == 0 || $o[2] == 2));
return 1 if ($o[0] == 192 && $o[1] == 88 && $o[2] == 99);
return 1 if ($o[0] == 100 && $o[1] >= 64 && $o[1] <= 127);
return 1 if ($o[0] == 198 && ($o[1] == 18 || $o[1] == 19));
return 1 if ($o[0] == 198 && $o[1] == 51 && $o[2] == 100);
return 1 if ($o[0] == 203 && $o[1] == 0 && $o[2] == 113);
return 1 if ($o[0] >= 224);
}
elsif (&check_ip6address($ip)) {
my $l = lc($ip);
$l =~ s{/\d+$}{};
return 1 if ($l eq "::1" || $l eq "::");
return 1 if ($l =~ /^fe[89ab]/);
return 1 if ($l =~ /^fe[c-f]/);
return 1 if ($l =~ /^f[cd]/);
if ($l =~ /^::ffff:(\d+\.\d+\.\d+\.\d+)$/) {
return &is_non_public_ipaddress($1);
return 1 if ($l =~ /^ff/);
my $packed = eval { inet_pton(AF_INET6(), $l) };
if (defined($packed) &&
substr($packed, 0, 12) eq pack("H*", "0064ff9b0000000000000000")) {
my $ip4 = join(".", unpack("C4", substr($packed, 12, 4)));
return &is_non_public_ipaddress($ip4);
}
if (defined($packed) && substr($packed, 0, 2) eq "\x20\x02") {
my $ip4 = join(".", unpack("C4", substr($packed, 2, 4)));
return &is_non_public_ipaddress($ip4);
}
return 1 if (&ipaddress_matches_network($l, "64:ff9b:1::/48"));
return 1 if (&ipaddress_matches_network($l, "100::/64"));
return 1 if (&ipaddress_matches_network($l, "2001:db8::/32"));
if (defined($packed) &&
(substr($packed, 0, 12) eq "\0" x 12 ||
substr($packed, 0, 10) eq "\0" x 10 &&
substr($packed, 10, 2) eq "\xff" x 2)) {
my $ip4 = join(".", unpack("C4", substr($packed, 12, 4)));
return &is_non_public_ipaddress($ip4);
}
}
return 0;
}
=head2 ipaddress_matches_network(ip, address-or-network)
Returns 1 if an IPv4 or IPv6 address matches an exact address or CIDR network.
=cut
sub ipaddress_matches_network
{
my ($ip, $network) = @_;
return 0 if (!defined($ip) || !defined($network));
$network =~ s/^\s+|\s+$//g;
my ($base, $prefix) = split(/\//, $network, 2);
my ($family, $bits);
# Permit an IPv4 exception to match the equivalent mapped IPv6 destination.
if (&check_ip6address($ip) && &check_ipaddress($base)) {
my $packed = eval { inet_pton(AF_INET6(), $ip) };
if (defined($packed) &&
(substr($packed, 0, 12) eq "\0" x 12 ||
substr($packed, 0, 10) eq "\0" x 10 &&
substr($packed, 10, 2) eq "\xff" x 2)) {
my $ip4 = join(".", unpack("C4", substr($packed, 12, 4)));
return &ipaddress_matches_network(
$ip4, $base.(defined($prefix) ? "/$prefix" : ""));
}
}
if (&check_ipaddress($ip) && &check_ipaddress($base)) {
$family = AF_INET();
$bits = 32;
}
elsif (&check_ip6address($ip) && &check_ip6address($base)) {
$family = eval { AF_INET6() };
return 0 if (!defined($family));
$bits = 128;
}
else {
return 0;
}
$prefix = $bits if (!defined($prefix));
return 0 if ($prefix !~ /^\d+$/ || $prefix > $bits);
my ($packed_ip, $packed_base);
if ($family == AF_INET()) {
$packed_ip = inet_aton($ip);
$packed_base = inet_aton($base);
}
else {
$packed_ip = eval { inet_pton($family, $ip) };
$packed_base = eval { inet_pton($family, $base) };
}
return 0 if (!defined($packed_ip) || !defined($packed_base));
my $bytes = int($prefix / 8);
return 0 if (substr($packed_ip, 0, $bytes) ne
substr($packed_base, 0, $bytes));
my $remaining = $prefix % 8;
if ($remaining) {
my $mask = (0xff << (8-$remaining)) & 0xff;
return 0 if ((ord(substr($packed_ip, $bytes, 1)) & $mask) !=
(ord(substr($packed_base, $bytes, 1)) & $mask));
}
return 1;
}
=head2 get_download_address_callback(mode, [allowed-addresses])
Returns a callback for checking resolved download addresses against a policy.
The mode can be "public", "listed" or "all". In listed mode, non-public
addresses are allowed when they match a whitespace-separated list of IP
addresses or CIDR networks. Returns undef when no checks are required.
=cut
sub get_download_address_callback
{
my ($mode, $allowed) = @_;
return undef if (!defined($mode) || $mode eq "all");
$mode = 'public' if ($mode ne 'listed');
return sub {
my ($host, $addresses) = @_;
foreach my $ip (@$addresses) {
next if (!&is_non_public_ipaddress($ip));
if ($mode eq 'listed') {
my $matched = 0;
foreach my $network (split(/\s+/, $allowed || "")) {
if (&ipaddress_matches_network($ip, $network)) {
$matched = 1;
last;
}
}
next if ($matched);
}
return "Download from non-public IP address $ip is not allowed";
}
return undef;
};
}
=head2 check_download_address(host, [&callback], [&resolved-addresses])
Resolves a download hostname and passes the resulting IP addresses to an
optional callback. The callback receives the hostname and an array reference
of addresses, and returns an error message to reject them or undef to allow
them. Returns an error message when resolution or validation fails. If the
resolved-addresses array reference is set, it is populated with the checked
addresses.
=cut
sub check_download_address
{
my ($host, $callback, $resolved_addresses) = @_;
my @addresses = &to_ipaddress($host);
push(@addresses, &to_ip6address($host));
return "Failed to lookup IP address for $host" if (!@addresses);
if ($callback) {
my $error = &$callback($host, \@addresses);
return $error if ($error);
}
@$resolved_addresses = @addresses if ($resolved_addresses);
return undef;
}
=head2 generate_icon(image, title, link, [href], [width], [height], [before-title], [after-title])
Prints HTML for an icon image. The parameters are :
@@ -3191,7 +3335,7 @@ while(1) {
return $anyneg;
}
=head2 http_download(host, port, page, destfile, [&error], [&callback], [sslmode], [user], [pass], [timeout], [osdn-convert], [no-cache], [&headers])
=head2 http_download(host, port, page, destfile, [&error], [&callback], [sslmode], [user], [pass], [timeout], [osdn-convert], [no-cache], [&headers], [&response-headers])
Downloads data from a HTTP url to a local file or string. The parameters are :
@@ -3536,7 +3680,7 @@ if (!ref($h)) {
$headers, $ssl, $nocache, $timeout, $response_headers);
}
=head2 ftp_download(host, file, destfile, [&error], [&callback], [user, pass], [port], [no-cache])
=head2 ftp_download(host, file, destfile, [&error], [&callback], [user, pass], [port], [no-cache], [timeout])
Download data from an FTP site to a local file. The parameters are :
@@ -3563,7 +3707,8 @@ Download data from an FTP site to a local file. The parameters are :
=cut
sub ftp_download
{
my ($host, $file, $dest, $error, $cbfunc, $user, $pass, $port, $nocache, $timeout) = @_;
my ($host, $file, $dest, $error, $cbfunc, $user, $pass, $port, $nocache,
$timeout) = @_;
$port ||= 21;
$timeout = 60 if (!defined($timeout));
if ($gconfig{'debug_what_net'}) {