Merge pull request #2789 from webmin/dev/download-target-check
Add ACL controls for URL download destinations
This commit is contained in:
@@ -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
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
|
||||
@@ -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'}));
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ readonly=0
|
||||
nodot=0
|
||||
fileunix=root
|
||||
webminsearch=1
|
||||
download_address_mode=public
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -3,3 +3,4 @@ fileunix=
|
||||
feedback=0
|
||||
rpc=0
|
||||
negative=0
|
||||
download_address_mode=public
|
||||
|
||||
+107
-12
@@ -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
|
||||
# (fe80–febf), 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();
|
||||
|
||||
@@ -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'};
|
||||
|
||||
@@ -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
@@ -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
@@ -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'}) {
|
||||
|
||||
Reference in New Issue
Block a user