mirror of
https://github.com/webmin/webmin.git
synced 2026-10-24 05:00:40 +01:00
Compare commits
2 Commits
dev/add-ap
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a666c457 | ||
|
|
75800b27b9 |
File diff suppressed because one or more lines are too long
@@ -1665,15 +1665,14 @@ foreach my $m (@$mems) {
|
||||
|
||||
=head2 copy_acl_files(from, to, &modules, [from-type], [to-type])
|
||||
|
||||
Copy ACLs for selected modules between users or groups. Global permissions
|
||||
retain an existing destination API endpoint; otherwise they inherit the source.
|
||||
Copy all .acl files from some user to another user in a list of modules.
|
||||
The parameters are :
|
||||
|
||||
=item from - Source user or group name.
|
||||
|
||||
=item to - Destination user or group name.
|
||||
|
||||
=item modules - Array ref of module names; an empty name or . includes globals.
|
||||
=item modules - Array ref of module names.
|
||||
|
||||
=item from-type - Either "user" or "group", defaults to "user"
|
||||
|
||||
@@ -1685,29 +1684,6 @@ sub copy_acl_files
|
||||
my ($from, $to, $mods, $fromtype, $totype) = @_;
|
||||
$fromtype ||= "user";
|
||||
$totype ||= "user";
|
||||
if (grep { $_ eq '' || $_ eq '.' } @$mods) {
|
||||
# Keep the destination endpoint even when the source has a different
|
||||
# one.
|
||||
my %source = $fromtype eq 'user' ? &get_module_acl($from, '', 1, 1) :
|
||||
&get_group_module_acl($from, '', 1);
|
||||
my %dest = $totype eq 'user' ? &get_module_acl($to, '', 1, 1) :
|
||||
&get_group_module_acl($to, '', 1);
|
||||
$source{'api_only'} = $dest{'api_only'} if ($dest{'api_only'});
|
||||
if ($totype eq 'user') {
|
||||
# The user ACL writer supports both local files and user
|
||||
# databases.
|
||||
&save_module_acl(\%source, $to, '', 1);
|
||||
}
|
||||
else {
|
||||
# Group destinations use their own ACL writer and storage
|
||||
# format.
|
||||
&save_group_module_acl(\%source, $to, '', 1);
|
||||
}
|
||||
# Leave the caller's list intact and copy remaining module ACLs
|
||||
# normally.
|
||||
$mods = [ grep { $_ ne '' && $_ ne '.' } @$mods ];
|
||||
return if (!@$mods);
|
||||
}
|
||||
my ($dbh, $proto, $fromid, $toid);
|
||||
|
||||
# Check if the source user/group is in a DB
|
||||
@@ -1845,9 +1821,7 @@ my ($from, $to, $mods) = @_;
|
||||
=head2 set_acl_files(&allusers, &allgroups, module, &members, &access)
|
||||
|
||||
Recursively update the ACL for all sub-users and groups of a group, by copying
|
||||
detailed access control settings from the group down to users. Global copies
|
||||
retain each member's existing API endpoint and pass it to nested members.
|
||||
Parameters are :
|
||||
detailed access control settings from the group down to users. Parameters are :
|
||||
|
||||
=item allusers - An array ref of Webmin users, as returned by list_users.
|
||||
|
||||
@@ -1870,16 +1844,7 @@ foreach my $m (@$members) {
|
||||
if ($u) {
|
||||
my $aclfile = "$config_directory/$mod/$u->{'name'}.acl";
|
||||
&lock_file($aclfile);
|
||||
# Copy per member so a retained endpoint cannot leak to
|
||||
# its siblings.
|
||||
my %updated = %{$access || {}};
|
||||
if ($mod eq '' || $mod eq '.') {
|
||||
# Inheritance cannot replace or clear the
|
||||
# member's existing endpoint.
|
||||
my %old = &get_module_acl($u->{'name'}, $mod, 1, 1);
|
||||
$updated{'api_only'} = $old{'api_only'} if ($old{'api_only'});
|
||||
}
|
||||
&save_module_acl(\%updated, $u->{'name'}, $mod, 1);
|
||||
&save_module_acl($access, $u->{'name'}, $mod, 1);
|
||||
chmod(0640, $aclfile) if (-r $aclfile);
|
||||
&unlock_file($aclfile);
|
||||
}
|
||||
@@ -1892,22 +1857,11 @@ foreach my $m (@$members) {
|
||||
my $aclfile =
|
||||
"$config_directory/$mod/$g->{'name'}.gacl";
|
||||
&lock_file($aclfile);
|
||||
# Copy per member so a retained endpoint cannot leak to
|
||||
# its siblings.
|
||||
my %updated = %{$access || {}};
|
||||
if ($mod eq '' || $mod eq '.') {
|
||||
# Retain the subgroup endpoint even when the
|
||||
# parent has a different one.
|
||||
my %old = &get_group_module_acl($g->{'name'}, $mod, 1);
|
||||
$updated{'api_only'} = $old{'api_only'} if ($old{'api_only'});
|
||||
}
|
||||
&save_group_module_acl(\%updated, $g->{'name'}, $mod, 1);
|
||||
&save_group_module_acl($access, $g->{'name'}, $mod, 1);
|
||||
chmod(0640, $aclfile) if (-r $aclfile);
|
||||
&unlock_file($aclfile);
|
||||
# Descendants inherit the retained subgroup policy, not
|
||||
# the parent copy.
|
||||
&set_acl_files($allusers, $allgroups, $mod,
|
||||
$g->{'members'}, \%updated);
|
||||
$g->{'members'}, $access);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,12 +134,6 @@ if ($access{'acl'} && $in{'group'}) {
|
||||
"global", 0, [ "width=30%" ]);
|
||||
my %uaccess = &get_group_module_acl($in{'group'}, "");
|
||||
print &ui_hidden("acl_security_form", 1);
|
||||
# Store the endpoint on the group. Copy and propagation helpers retain
|
||||
# an existing member endpoint instead of replacing it with this value.
|
||||
print &ui_table_row(&hlink($text{'acl_api_only'}, 'api_only'),
|
||||
&ui_opt_textbox('api_only', $uaccess{'api_only'}, 40,
|
||||
$text{'acl_api_unrestricted'}, $text{'acl_api_endpoint'}).
|
||||
'<br>'.$text{'acl_api_only_desc'});
|
||||
&foreign_require("", "acl_security.pl");
|
||||
&foreign_call("", "acl_security_form", \%uaccess);
|
||||
print &ui_hidden_table_end("global");
|
||||
|
||||
@@ -43,11 +43,6 @@ else {
|
||||
}
|
||||
my $me = &get_user($base_remote_user);
|
||||
my %uaccess = &get_module_acl($in{'user'} || "", "", 1);
|
||||
if ($in{'clone'} && !$in{'user'}) {
|
||||
# The clone form must not silently clear its source's API restriction.
|
||||
my %source = &get_module_acl($in{'clone'}, '', 1, 1);
|
||||
$uaccess{'api_only'} = $source{'api_only'};
|
||||
}
|
||||
if (!$in{'user'} && $uaccess{'rpc'} == 2) {
|
||||
# Don't offer the confusing 'root' or 'admin' RPC option by default
|
||||
$uaccess{'rpc'} = 0;
|
||||
@@ -390,15 +385,6 @@ if ($access{'acl'} && !$safe) {
|
||||
[ 0, $text{'acl_rpc0'} ] ]));
|
||||
}
|
||||
|
||||
# Group members inherit global settings. Do not offer an editable field
|
||||
# that save_user.cgi cannot save while the account belongs to a group.
|
||||
if ($access{'acl'} && !$memg->{'name'} && !$safe) {
|
||||
print &ui_table_row(&hlink($text{'acl_api_only'}, 'api_only'),
|
||||
&ui_opt_textbox('api_only', $uaccess{'api_only'}, 40,
|
||||
$text{'acl_api_unrestricted'}, $text{'acl_api_endpoint'}).
|
||||
'<br>'.$text{'acl_api_only_desc'});
|
||||
}
|
||||
|
||||
print &ui_hidden_table_end("security");
|
||||
|
||||
# Work out which modules can be selected
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<header>API-only access</header>
|
||||
|
||||
Restricts an authenticated Webmin account to one API endpoint, given as a path
|
||||
relative to the Webmin root such as <tt>virtual-server/remote.cgi</tt>. The
|
||||
endpoint must explicitly support API-only accounts. Miniserv rejects other
|
||||
paths, including WebSocket and DAV requests, with HTTP 403. Generic RPC is
|
||||
disabled. The CGI also checks the restriction when run by another web server;
|
||||
that server must protect any routes that do not initialize Webmin. <p>
|
||||
|
||||
Use a new, dedicated account for each script or integration. The account
|
||||
still needs access to the endpoint's module, and the endpoint's own permission
|
||||
checks still apply. <p>
|
||||
|
||||
A group endpoint applies to existing and new members. Copying or changing a
|
||||
group never replaces a member's existing endpoint or clears its restriction.
|
||||
Remove a user from its group before changing that user's endpoint in this
|
||||
form. <p>
|
||||
|
||||
<footer>
|
||||
@@ -9,10 +9,6 @@ no warnings 'uninitialized';
|
||||
require './acl-lib.pl'; ## no critic
|
||||
our (%in, %text, %config, %access, $config_directory);
|
||||
&ReadParse();
|
||||
# Only permission editors may change global ACLs, including API-only access.
|
||||
if ($in{'acl_security_form'} && !$access{'acl'}) {
|
||||
&error($text{'acl_emod'});
|
||||
}
|
||||
$access{'groups'} || &error($text{'gedit_ecannot'});
|
||||
|
||||
# Check for special button clicks, and redirect
|
||||
@@ -138,14 +134,6 @@ if ($in{'old'} && $in{'acl_security_form'}) {
|
||||
&save_group_module_acl(\%uaccess, $in{'name'}, "", 1);
|
||||
&set_ownership_permissions(undef, undef, 0640, $aclfile);
|
||||
&unlock_file($aclfile);
|
||||
# Apply a newly selected endpoint to existing members as well as future
|
||||
# members. Keep any endpoint already set on a user or nested group.
|
||||
if ($uaccess{'api_only'}) {
|
||||
my @users = &list_users();
|
||||
my @groups = &list_groups();
|
||||
&set_acl_files(\@users, \@groups, '', $group{'members'},
|
||||
\%uaccess);
|
||||
}
|
||||
}
|
||||
|
||||
&reload_miniserv();
|
||||
|
||||
@@ -10,10 +10,6 @@ require './acl-lib.pl'; ## no critic
|
||||
our (%in, %text, %config, %access, $config_directory, $base_remote_user);
|
||||
&foreign_require("webmin", "webmin-lib.pl");
|
||||
&ReadParse();
|
||||
# Only permission editors may change global ACLs, including API-only access.
|
||||
if ($in{'acl_security_form'} && !$access{'acl'}) {
|
||||
&error($text{'acl_emod'});
|
||||
}
|
||||
|
||||
# Check for special button clicks, and redirect
|
||||
if ($in{'but_clone'}) {
|
||||
@@ -60,15 +56,6 @@ else {
|
||||
}
|
||||
&error_setup($text{'save_err'});
|
||||
|
||||
# Validate the endpoint before creating or changing any account. A form error
|
||||
# must not leave behind a new login without its requested restriction.
|
||||
my %global_access;
|
||||
if ($in{'acl_security_form'} && !$in{'safe'}) {
|
||||
&foreign_require("", "acl_security.pl");
|
||||
&foreign_call("", "acl_security_save", \%global_access, \%in);
|
||||
$global_access{'rpc'} = $in{'rpc'};
|
||||
}
|
||||
|
||||
if ((!$in{'old'} ||
|
||||
$in{'name'} ne $in{'old'}) &&
|
||||
$in{'name'} =~ /^(root|admin|sysadm)$/) {
|
||||
@@ -207,6 +194,11 @@ if ($newgroup) {
|
||||
}
|
||||
@mods = &unique(@mods, @{$newgroup->{'modules'}});
|
||||
$user{'ownmods'} = \@ownmods;
|
||||
|
||||
# Copy ACL files for group
|
||||
my $name = $in{'old'} ? $in{'old'} : $in{'name'};
|
||||
©_group_user_acl_files($in{'group'}, $name,
|
||||
[ @{$newgroup->{'modules'}}, "" ]);
|
||||
}
|
||||
$user{'modules'} = \@mods;
|
||||
|
||||
@@ -384,19 +376,16 @@ else {
|
||||
}
|
||||
}
|
||||
|
||||
# Database users need an ID before their inherited ACLs can be stored.
|
||||
# Copy after creation or rename so group restrictions reach the actual account.
|
||||
if ($newgroup) {
|
||||
©_group_user_acl_files($in{'group'}, $in{'name'},
|
||||
[ @{$newgroup->{'modules'}}, "" ]);
|
||||
}
|
||||
|
||||
my $aclfile = "$config_directory/$in{'name'}.acl";
|
||||
if ($in{'acl_security_form'} && !$newgroup && !$in{'safe'}) {
|
||||
# Update user's global ACL, and merge in RPC setting which has
|
||||
# been moved out of this form
|
||||
&foreign_require("", "acl_security.pl");
|
||||
my %uaccess;
|
||||
&foreign_call("", "acl_security_save", \%uaccess, \%in);
|
||||
$uaccess{'rpc'} = $in{'rpc'};
|
||||
&lock_file($aclfile);
|
||||
&save_module_acl(\%global_access, $in{'name'}, "", 1);
|
||||
&save_module_acl(\%uaccess, $in{'name'}, "", 1);
|
||||
&set_ownership_permissions(undef, undef, 0640, $aclfile);
|
||||
&unlock_file($aclfile);
|
||||
}
|
||||
|
||||
@@ -112,22 +112,10 @@ print &ui_table_row($text{'acl_webminsearch'},
|
||||
}
|
||||
|
||||
# acl_security_save(&options)
|
||||
# Parse global security fields from %in into the supplied options hash.
|
||||
# Parse the form for global security options
|
||||
sub acl_security_save
|
||||
{
|
||||
my ($o) = @_;
|
||||
# Update the endpoint only when the form includes this field. Older forms
|
||||
# omit it, leaving the saved restriction for the ACL writer to preserve.
|
||||
if (defined($in{'api_only_def'})) {
|
||||
$in{'api_only_def'} =~ /\A[01]\z/ || &error($text{'acl_eapi_only'});
|
||||
my $endpoint = $in{'api_only_def'} ? '' : $in{'api_only'};
|
||||
if (!$in{'api_only_def'}) {
|
||||
# Use a relative CGI path, without a query string or traversal.
|
||||
$endpoint =~ /\A(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.cgi\z/ ||
|
||||
&error($text{'acl_eapi_only'});
|
||||
}
|
||||
$o->{'api_only'} = $endpoint;
|
||||
}
|
||||
$o->{'root'} = $in{'root_def'} ? undef : $in{'root'};
|
||||
$o->{'otherdirs'} = join("\t", split(/\r?\n/, $in{'otherdirs'}));
|
||||
$o->{'nodot'} = $in{'nodot'};
|
||||
|
||||
@@ -69,10 +69,12 @@ foreach $h (@hosts) {
|
||||
exit;
|
||||
}
|
||||
|
||||
# The remote ACL writer preserves an endpoint omitted by the form.
|
||||
my $save = $in{'_acl_user'} ? 'save_module_acl' : 'save_group_module_acl';
|
||||
&remote_foreign_call($s->{'host'}, "acl", $save,
|
||||
\%maccess, $who, $in{'_acl_mod'}, 1);
|
||||
# Save the .acl file
|
||||
local $cd = &remote_eval($s->{'host'}, "acl",
|
||||
'$config_directory');
|
||||
$sfx = $in{'_acl_user'} ? "acl" : "gacl";
|
||||
&remote_foreign_call($s->{'host'}, "acl", "write_file",
|
||||
"$cd/$in{'_acl_mod'}/$who.$sfx", \%maccess);
|
||||
|
||||
# Recursively update the ACL for all member users and groups
|
||||
if ($in{'_acl_group'}) {
|
||||
|
||||
@@ -135,22 +135,19 @@ foreach $host (@phosts) {
|
||||
|
||||
&ui_print_footer("", $text{'index_return'});
|
||||
|
||||
# copy_acls_for(&account, &server, type)
|
||||
# Copy global and module ACLs from account->{source} to the destination server.
|
||||
# type is user or group and selects the matching remote ACL helpers.
|
||||
# copy_acls_for(&user|&group, &server)
|
||||
# Copy all .acl files from the server on which a user or group does exist
|
||||
# to the given server
|
||||
sub copy_acls_for
|
||||
{
|
||||
local $source = $_[0]->{'source'};
|
||||
&remote_foreign_require($source->{'host'}, "acl", "acl-lib.pl")
|
||||
if (!$done_require{$source->{'host'}}++);
|
||||
local $suffix = $_[2] eq "user" ? "module_acl" : "group_module_acl";
|
||||
# Global permissions include the account's API-only restriction.
|
||||
foreach $m (&unique('', @{$_[0]->{'modules'}})) {
|
||||
foreach $m (@{$_[0]->{'modules'}}) {
|
||||
local %acl = &remote_foreign_call($source->{'host'}, "acl",
|
||||
"get_".$suffix, $_[0]->{'name'},$m);
|
||||
if (%acl) {
|
||||
# Only populated source ACLs need a write on the new destination
|
||||
# account.
|
||||
&remote_foreign_call($_[1]->{'host'}, "acl", "save_".$suffix,
|
||||
\%acl, $_[0]->{'name'}, $m);
|
||||
}
|
||||
|
||||
5
lang/en
5
lang/en
@@ -197,11 +197,6 @@ login_notsecure=Not Secure
|
||||
login_notsecure_desc=This connection is not secure and could let a man-in-the-middle attack intercept your password or session cookie. Click here to switch to an HTTPS connection, unless you are on a trusted local network or behind a secure reverse proxy.
|
||||
login_notsecure_http_desc=This connection is not encrypted and could let a man-in-the-middle attack intercept your password or session cookie. HTTPS is not enabled in Webmin; enable it unless you are on a trusted local network or behind a secure reverse proxy.
|
||||
|
||||
acl_api_only=API-only access
|
||||
acl_api_unrestricted=Unrestricted
|
||||
acl_api_endpoint=Only this API endpoint
|
||||
acl_api_only_desc=Enter a supported API path relative to the Webmin root, such as <tt>virtual-server/remote.cgi</tt>. Generic RPC is disabled. See help for the scope of this restriction.
|
||||
acl_eapi_only=Enter a CGI path relative to the Webmin root, such as <tt>virtual-server/remote.cgi</tt>. Do not include a leading slash, query string or .. path component.
|
||||
acl_root=Root directory for file chooser
|
||||
acl_otherdirs=Other visible directories in file chooser
|
||||
acl_nodot=Hide dot files in file chooser?
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package miniserv;
|
||||
use Socket;
|
||||
use POSIX;
|
||||
use Errno qw(ENOENT);
|
||||
use Time::Local;
|
||||
eval "use Time::HiRes;";
|
||||
|
||||
@@ -1064,28 +1063,6 @@ if ($bogus) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
# Check the API endpoint before dispatching CGIs, static files, DAV or
|
||||
# WebSockets. The CGI must still opt in through init_config.
|
||||
if ($baseauthuser && $config{'env_WEBMIN_CONFIG'}) {
|
||||
my ($endpoint, $policy_error) = &get_api_only_endpoint($baseauthuser, $uinfo);
|
||||
if ($policy_error || $endpoint &&
|
||||
($simple ne "/$endpoint" || $davpath || $websocket_upgrade_request)) {
|
||||
# Send a plain denial directly. Custom error CGIs must not turn a
|
||||
# rejected request into another authenticated entry point.
|
||||
my $body = $policy_error ? "Cannot read this account's API policy\n" :
|
||||
"This account is restricted to its configured API endpoint\n";
|
||||
&write_data("HTTP/1.0 403 Forbidden\r\n");
|
||||
&write_data("Content-type: text/plain\r\n");
|
||||
&write_data("Content-length: ".length($body)."\r\n");
|
||||
&write_keep_alive(0);
|
||||
&write_data("\r\n");
|
||||
&write_data($body) if ($method ne 'HEAD');
|
||||
&log_request($loghost, $authuser, $reqline, 403,
|
||||
$method eq 'HEAD' ? 0 : length($body));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
# Check for a DAV request
|
||||
if ($davpath) {
|
||||
return &handle_dav_request($davpath);
|
||||
@@ -4114,79 +4091,6 @@ if ($config{'userdb'}) {
|
||||
}
|
||||
}
|
||||
|
||||
# get_api_only_endpoint(username, &user-details)
|
||||
# Read the base user's global endpoint without caching it across requests.
|
||||
# Returns (endpoint, error); storage failures deny access rather than falling
|
||||
# back to an unrestricted account. user-details comes from get_user_details.
|
||||
sub get_api_only_endpoint
|
||||
{
|
||||
my ($username, $user) = @_;
|
||||
my %acl;
|
||||
if ($user && $user->{'proto'}) {
|
||||
# Database logins must use their database policy, never a local fallback.
|
||||
my ($dbh, $proto) = &connect_userdb($config{'userdb'});
|
||||
return (undef, 1) if (!ref($dbh));
|
||||
my $ok = eval {
|
||||
if ($proto eq 'mysql' || $proto eq 'postgresql') {
|
||||
# Parameter binding keeps names and IDs out of SQL syntax.
|
||||
my $cmd = $dbh->prepare(
|
||||
"select attr,value from webmin_user_acl ".
|
||||
"where id = ? and module = ?");
|
||||
$cmd && $cmd->execute($user->{'id'}, '') || die "ACL query failed";
|
||||
while(my ($key, $value) = $cmd->fetchrow()) {
|
||||
$acl{$key} = $value;
|
||||
}
|
||||
$cmd->err && die "ACL fetch failed";
|
||||
$cmd->finish();
|
||||
}
|
||||
elsif ($proto eq 'ldap') {
|
||||
# The authenticated user's DN identifies the global ACL object.
|
||||
my $result = $dbh->search(base => $user->{'id'},
|
||||
filter => '(cn=global)', scope => 'one');
|
||||
$result && !$result->code || die "ACL search failed";
|
||||
my ($entry) = $result->all_entries;
|
||||
if ($entry) {
|
||||
# LDAP stores each ACL option as one name=value attribute.
|
||||
foreach my $pair ($entry->get_value('webminAclEntry')) {
|
||||
my ($key, $value) = split(/=/, $pair, 2);
|
||||
$acl{$key} = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# An unknown storage backend cannot establish an access policy.
|
||||
die "Unknown ACL backend";
|
||||
}
|
||||
1;
|
||||
};
|
||||
&disconnect_userdb($config{'userdb'}, $dbh);
|
||||
return (undef, 1) if (!$ok);
|
||||
}
|
||||
else {
|
||||
# Local users normally have no global ACL file. Other read failures
|
||||
# must not be mistaken for the absence of a restriction.
|
||||
my $file = "$config{'env_WEBMIN_CONFIG'}/$username.acl";
|
||||
if (open(my $fh, '<', $file)) {
|
||||
-f $fh || return (undef, 1);
|
||||
local $! = 0;
|
||||
while(my $line = <$fh>) {
|
||||
$line =~ s/[\r\n]//g;
|
||||
if ($line =~ /^api_only=(.*)$/) {
|
||||
# Match the global ACL reader's last-value-wins behavior.
|
||||
$acl{'api_only'} = $1;
|
||||
}
|
||||
}
|
||||
my $read_error = $!;
|
||||
close($fh) && !$read_error || return (undef, 1);
|
||||
}
|
||||
elsif ($! != ENOENT) {
|
||||
# A missing file is normal; an unreadable policy is not.
|
||||
return (undef, 1);
|
||||
}
|
||||
}
|
||||
return ($acl{'api_only'}, undef);
|
||||
}
|
||||
|
||||
# get_user_details(username, [original-username])
|
||||
# Returns a hash ref of user details, either from config files or the user DB
|
||||
sub get_user_details
|
||||
|
||||
18
t/README.md
18
t/README.md
@@ -33,24 +33,6 @@ The VM test uses temporary loopback servers for HTTP, HTTPS, CONNECT proxies, pa
|
||||
|
||||
By default, it tests `open_socket` from this checkout with the installed Webmin helpers. Set `WEBMIN_OPEN_SOCKET_SOURCE` to another `web-lib-funcs.pl` to compare versions.
|
||||
|
||||
## API-only account tests on a VM
|
||||
|
||||
`api-only-accounts.t`, `api-only-lifecycle.t`, `api-only-sql.t` and `miniserv-api-only.t` cover the `api_only` global permission without a server.
|
||||
|
||||
The live fixtures under `fixtures/` run as root on a disposable Linux guest with Webmin installed, never on the workstation. They read the Webmin root from `/etc/webmin/miniserv.conf`, so Debian and RPM layouts both work. Put the changed source files in `candidate.tar` inside a task directory under `/tmp`, then:
|
||||
|
||||
```sh
|
||||
python3 fixtures/api-only-sandbox.py /tmp/task # isolated copy on 127.0.0.1:18443
|
||||
python3 fixtures/api-only-create.py /tmp/task # new accounts through Webmin Users
|
||||
python3 fixtures/api-only-groups.py /tmp/task # inherited controls and group name reuse
|
||||
python3 /tmp/task/api-only-live.py /tmp/task # edits, resets, copies, clones, renames
|
||||
python3 fixtures/api-only-boundary.py /tmp/task # CGI modes, static, DAV, WebSocket, sessions
|
||||
python3 fixtures/api-only-userdb.py /tmp/task mysql setup # or postgresql; rerun the account tests
|
||||
python3 fixtures/api-only-userdb.py /tmp/task mysql cleanup
|
||||
```
|
||||
|
||||
`api-only-live.py` with `fixtures/api-only-account.pl` beside it also runs against the guest's real Webmin on port 10000. Stop the sandbox PID in `miniserv.pid` and remove the task directory afterwards; it holds copies of the guest configuration.
|
||||
|
||||
## Coverage reports
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
# Verify API-only credentials never acquire generic RPC through legacy flags.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings qw(once redefine uninitialized);
|
||||
use Test::More;
|
||||
use FindBin;
|
||||
require "$FindBin::Bin/../web-lib-funcs.pl";
|
||||
|
||||
my %policy;
|
||||
$main::base_remote_user = 'integration';
|
||||
# get_module_acl(user, module): supply only this test's global policy.
|
||||
*main::get_module_acl = sub {
|
||||
is($_[0], 'integration', 'policy belongs to authenticated base user');
|
||||
is($_[1], '', 'policy is global, not a module permission');
|
||||
return %policy;
|
||||
};
|
||||
|
||||
is(main::webmin_user_api_only(), undef, 'ordinary users have no endpoint restriction');
|
||||
# Endpoint restrictions override both unrestricted and RPC-only legacy modes.
|
||||
for my $mode (0, 1, 3) {
|
||||
%policy = (rpc => $mode, api_only => 'virtual-server/remote.cgi');
|
||||
is(main::webmin_user_api_only(), 'virtual-server/remote.cgi',
|
||||
'API endpoint returned independently of legacy RPC flag');
|
||||
is(main::webmin_user_can_rpc(), 0, "API-only account denies RPC mode $mode");
|
||||
}
|
||||
# Without an endpoint restriction, existing RPC mode behavior is unchanged.
|
||||
%policy = (rpc => 1);
|
||||
is(main::webmin_user_can_rpc(), 1, 'ordinary explicit RPC permission unchanged');
|
||||
%policy = (rpc => 3);
|
||||
is(main::webmin_user_can_rpc(), 2, 'legacy RPC-only account behavior unchanged');
|
||||
%policy = (rpc => 0);
|
||||
is(main::webmin_user_can_rpc(), 0, 'ordinary RPC denial unchanged');
|
||||
done_testing();
|
||||
@@ -1,216 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
# Exercise global ACL saves and copies in an isolated temporary configuration.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings qw(once redefine uninitialized);
|
||||
use Test::More;
|
||||
use File::Temp qw(tempdir);
|
||||
use FindBin;
|
||||
require "$FindBin::Bin/../web-lib-funcs.pl";
|
||||
our ($config_directory, $base_remote_user, %in, %text);
|
||||
$config_directory = tempdir(CLEANUP => 1);
|
||||
$base_remote_user = 'admin';
|
||||
# module_root_directory([module]): keep default-ACL reads in the fixture tree.
|
||||
*main::module_root_directory = sub { return $config_directory; };
|
||||
# get_userdb_string(): use local fixture files, never a user database.
|
||||
*main::get_userdb_string = sub { return ''; };
|
||||
# get_module_preferences_acl(module, key): disable unrelated preference checks.
|
||||
*main::get_module_preferences_acl = sub { return undef; };
|
||||
# error(message, ...): turn validation failures into catchable test exceptions.
|
||||
*main::error = sub { die join('', @_)."\n"; };
|
||||
|
||||
# load_routine(file, name)
|
||||
# Load just the real routine under test, without module initialization or
|
||||
# reading workstation configuration. Runtime IO stays in the temporary tree.
|
||||
sub load_routine
|
||||
{
|
||||
my ($file, $name) = @_;
|
||||
open(my $fh, '<', "$FindBin::Bin/../$file") or die $!;
|
||||
local $/;
|
||||
my $routine_source = <$fh>;
|
||||
$routine_source =~ /^(sub \Q$name\E\n\{.*?^\})/ms or die "Missing $name";
|
||||
eval "no strict; $1";
|
||||
die $@ if $@;
|
||||
}
|
||||
load_routine('acl_security.pl', 'acl_security_save');
|
||||
load_routine('acl/acl-lib.pl', $_) for qw(copy_acl_files set_acl_files);
|
||||
|
||||
my $endpoint = 'virtual-server/remote.cgi';
|
||||
# Run the same persistence checks through the user and group ACL writers.
|
||||
for my $group (0, 1) {
|
||||
my $save = $group ? \&save_group_module_acl : \&save_module_acl;
|
||||
my $get = $group ? \&get_group_module_acl : \&get_module_acl;
|
||||
my $kind = $group ? 'group' : 'user';
|
||||
$save->({api_only => $endpoint, rpc => 0}, 'integration', '', 1);
|
||||
my $input = {rpc => 1};
|
||||
my $same = $input;
|
||||
$save->($input, 'integration', '', 1);
|
||||
my %actual = $get->('integration', '');
|
||||
is($actual{api_only}, $endpoint, "$kind save retains omitted endpoint");
|
||||
is($actual{rpc}, 1, "$kind save still updates other settings");
|
||||
is($input, $same, "$kind save does not replace caller reference");
|
||||
ok(!exists($input->{api_only}), "$kind save does not mutate shared policy");
|
||||
# A reset removes ordinary fields but retains the endpoint restriction.
|
||||
$save->(undef, 'integration', '', 1);
|
||||
%actual = $get->('integration', '');
|
||||
is_deeply(\%actual, {api_only => $endpoint}, "$kind reset keeps only endpoint");
|
||||
$save->({}, 'integration', '.', 1);
|
||||
%actual = $get->('integration', '');
|
||||
is($actual{api_only}, $endpoint, "$kind dot-path global save retains endpoint");
|
||||
# Explicit clearing restores normal reset behavior.
|
||||
$save->({api_only => ''}, 'integration', '', 1);
|
||||
%actual = $get->('integration', '');
|
||||
is($actual{api_only}, '', "$kind explicit removal is honored");
|
||||
$save->(undef, 'integration', '', 1);
|
||||
%actual = $get->('integration', '');
|
||||
ok(!$actual{api_only} && !-e "$config_directory/integration.".($group ? 'gacl' : 'acl'),
|
||||
"$kind unrestricted reset removes ACL");
|
||||
# The retention rule applies only to global ACLs.
|
||||
$save->({api_only => $endpoint}, 'integration', 'example', 1);
|
||||
$save->({}, 'integration', 'example', 1);
|
||||
%actual = $get->('integration', 'example');
|
||||
ok(!exists($actual{api_only}), "$kind module ACL behavior is unchanged");
|
||||
}
|
||||
|
||||
# A database outage must not turn an API-only login into an unrestricted user,
|
||||
# including when a CGI is hosted outside miniserv.
|
||||
{
|
||||
local *main::get_userdb_string = sub { return 'postgresql://fixture'; };
|
||||
local *main::connect_userdb = sub { return 'fixture database unavailable'; };
|
||||
local $main::remote_user_proto = 'postgresql';
|
||||
eval { webmin_user_api_only(); };
|
||||
like($@, qr/Cannot read global ACL/, 'global policy read fails closed on database outage');
|
||||
}
|
||||
|
||||
# Current forms deliberately set or clear the endpoint. Old forms omit it.
|
||||
%text = (acl_eapi_only => 'Invalid API endpoint');
|
||||
for my $input (
|
||||
{api_only_def => 0, api_only => $endpoint},
|
||||
{api_only_def => 1},
|
||||
{},
|
||||
) {
|
||||
%in = %$input;
|
||||
my %policy;
|
||||
acl_security_save(\%policy);
|
||||
if (!exists($input->{api_only_def})) {
|
||||
# Older forms leave the setting for the ACL writer to preserve.
|
||||
ok(!exists($policy{api_only}), 'older form leaves endpoint unspecified');
|
||||
}
|
||||
else {
|
||||
# Current forms explicitly choose an endpoint or unrestricted access.
|
||||
is($policy{api_only}, $input->{api_only_def} ? '' : $endpoint,
|
||||
'form saves explicit endpoint choice');
|
||||
}
|
||||
}
|
||||
# Reject paths and values that could escape or corrupt the endpoint setting.
|
||||
for my $bad ('', '/virtual-server/remote.cgi', '../rpc.cgi',
|
||||
'virtual-server/../rpc.cgi', 'remote.cgi?x=1', "remote.cgi\nrpc=1") {
|
||||
%in = (api_only_def => 0, api_only => $bad);
|
||||
eval { acl_security_save({}); };
|
||||
like($@, qr/Invalid API endpoint/, 'invalid endpoint is rejected');
|
||||
}
|
||||
|
||||
# Global copying must work for every user/group direction and preserve an
|
||||
# existing destination endpoint even when the source explicitly has none.
|
||||
for my $fromtype ('user', 'group') {
|
||||
for my $totype ('user', 'group') {
|
||||
my $savefrom = $fromtype eq 'user' ? \&save_module_acl : \&save_group_module_acl;
|
||||
my $saveto = $totype eq 'user' ? \&save_module_acl : \&save_group_module_acl;
|
||||
my $getto = $totype eq 'user' ? \&get_module_acl : \&get_group_module_acl;
|
||||
$savefrom->({api_only => '', rpc => 1}, 'source', '', 1);
|
||||
$saveto->({api_only => $endpoint}, 'target', '', 1);
|
||||
my @mods = ('');
|
||||
copy_acl_files('source', 'target', \@mods, $fromtype, $totype);
|
||||
my %actual = $getto->('target', '');
|
||||
is($actual{api_only}, $endpoint, "$fromtype-to-$totype copy retains destination restriction");
|
||||
is($actual{rpc}, 1, "$fromtype-to-$totype copy updates ordinary fields");
|
||||
is_deeply(\@mods, [''], 'copy does not modify module list');
|
||||
# A destination without a restriction inherits the source endpoint.
|
||||
$savefrom->({api_only => $endpoint}, 'source', '', 1);
|
||||
$saveto->({api_only => ''}, 'newtarget', '', 1);
|
||||
$saveto->(undef, 'newtarget', '', 1);
|
||||
copy_acl_files('source', 'newtarget', [''], $fromtype, $totype);
|
||||
%actual = $getto->('newtarget', '');
|
||||
is($actual{api_only}, $endpoint, "$fromtype-to-$totype copy propagates source restriction");
|
||||
# Deleting an unrestricted source's global ACL earlier in the same
|
||||
# process must not leave cached data for the copy to reuse.
|
||||
$savefrom->({api_only => '', rpc => 1}, 'source', '', 1);
|
||||
$savefrom->(undef, 'source', '', 1);
|
||||
copy_acl_files('source', 'staletarget', [''], $fromtype, $totype);
|
||||
%actual = $getto->('staletarget', '');
|
||||
ok(!exists($actual{rpc}), "$fromtype-to-$totype copy does not reuse deleted source data");
|
||||
}
|
||||
}
|
||||
|
||||
# A shared input must never acquire one member's private restriction, and a
|
||||
# retained subgroup endpoint must also reach its otherwise unrestricted users.
|
||||
save_module_acl({api_only => $endpoint}, 'restricted', '', 1);
|
||||
save_group_module_acl({api_only => 'other/api.cgi'}, 'subgroup', '', 1);
|
||||
my $shared = {rpc => 1, api_only => ''};
|
||||
set_acl_files([map { {name => $_} } qw(restricted ordinary nested)],
|
||||
[{name => 'subgroup', members => ['nested']}], '',
|
||||
['restricted', 'ordinary', '@subgroup'], $shared);
|
||||
my %actual = get_module_acl('restricted', '');
|
||||
is($actual{api_only}, $endpoint, 'group update retains user restriction');
|
||||
%actual = get_module_acl('ordinary', '');
|
||||
is($actual{api_only}, '', 'restriction does not leak to a sibling user');
|
||||
%actual = get_group_module_acl('subgroup', '');
|
||||
is($actual{api_only}, 'other/api.cgi', 'group update retains subgroup restriction');
|
||||
%actual = get_module_acl('nested', '');
|
||||
is($actual{api_only}, 'other/api.cgi', 'subgroup restriction reaches nested member');
|
||||
is_deeply($shared, {rpc => 1, api_only => ''}, 'group propagation does not mutate source ACL');
|
||||
# Deleting and recreating a local group must not revive its global policy.
|
||||
load_routine('acl/acl-lib.pl', $_) for qw(create_group delete_group group_line);
|
||||
{
|
||||
# Keep account storage and module enumeration inside the temporary fixture.
|
||||
local *main::get_miniserv_config = sub { %{$_[0]} = (); };
|
||||
local *main::list_modules = sub { return ('example'); };
|
||||
create_group({name => 'reused', modules => ['example'], members => []});
|
||||
save_group_module_acl({api_only => $endpoint, rpc => 1}, 'reused', '', 1);
|
||||
save_group_module_acl({fixture => 1}, 'reused', 'example', 1);
|
||||
my %before = get_group_module_acl('reused', '', 1);
|
||||
is($before{api_only}, $endpoint, 'group endpoint is cached before deletion');
|
||||
delete_group('reused');
|
||||
ok(!-e "$config_directory/reused.gacl", 'group deletion removes global ACL');
|
||||
ok(!-e "$config_directory/example/reused.gacl", 'group deletion removes module ACL');
|
||||
create_group({name => 'reused', modules => ['example'], members => []});
|
||||
my %after = get_group_module_acl('reused', '', 1);
|
||||
is_deeply(\%after, {}, 'recreated group has no old global permissions');
|
||||
copy_acl_files('reused', 'freshmember', [''], 'group', 'user');
|
||||
my %member = get_module_acl('freshmember', '', 1, 1);
|
||||
is_deeply(\%member, {}, 'new member does not inherit deleted group permissions');
|
||||
delete_group('reused');
|
||||
}
|
||||
|
||||
# Cluster synchronization must include global permissions on the destination.
|
||||
load_routine('web-lib.pl', 'unique');
|
||||
load_routine('cluster-webmin/sync.cgi', 'copy_acls_for');
|
||||
{
|
||||
my @saved;
|
||||
# remote_foreign_require(host, module, library): skip actual server loading.
|
||||
local *main::remote_foreign_require = sub { };
|
||||
# remote_foreign_call(host, module, function, ...): emulate reads, record
|
||||
# saves.
|
||||
local *main::remote_foreign_call = sub {
|
||||
my ($host, $module, $function, @args) = @_;
|
||||
if ($function =~ /^get_/) {
|
||||
# Only the simulated global ACL carries an endpoint restriction.
|
||||
return $args[1] eq '' ? (api_only => $endpoint) : (ordinary => 1);
|
||||
}
|
||||
# Record writes so the test can check both their policy and target.
|
||||
push(@saved, [$function, @args]);
|
||||
};
|
||||
for my $kind ('user', 'group') {
|
||||
@saved = ();
|
||||
copy_acls_for({name => 'integration', source => {host => 'source'},
|
||||
modules => ['acl']}, {host => 'destination'}, $kind);
|
||||
my ($global) = grep { $_->[3] eq '' } @saved;
|
||||
ok($global, "$kind cluster sync includes global permissions");
|
||||
is($global->[1]->{api_only}, $endpoint,
|
||||
"$kind cluster sync carries the API-only endpoint");
|
||||
is($global->[0], $kind eq 'user' ? 'save_module_acl' : 'save_group_module_acl',
|
||||
"$kind cluster sync uses the protected ACL writer");
|
||||
}
|
||||
}
|
||||
done_testing();
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
# Exercise SQL policy replacement with real statements and failing triggers.
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings qw(once redefine uninitialized);
|
||||
use Test::More;
|
||||
use File::Temp qw(tempdir);
|
||||
use FindBin;
|
||||
BEGIN {
|
||||
eval { require DBI; require DBD::SQLite; 1 }
|
||||
or plan skip_all => 'DBI and DBD::SQLite are required';
|
||||
}
|
||||
require "$FindBin::Bin/../web-lib-funcs.pl";
|
||||
$main::config_directory = tempdir(CLEANUP => 1);
|
||||
$main::base_remote_user = 'admin';
|
||||
my $db = DBI->connect('dbi:SQLite:dbname=:memory:', '', '',
|
||||
{RaiseError => 0, PrintError => 0, AutoCommit => 1});
|
||||
# Use a real SQL store, while keeping Webmin configuration in the temp tree.
|
||||
*main::get_userdb_string = sub { return 'mysql://fixture'; };
|
||||
*main::connect_userdb = sub { return ($db, 'mysql'); };
|
||||
*main::disconnect_userdb = sub {};
|
||||
*main::module_root_directory = sub { return $main::config_directory; };
|
||||
*main::get_module_preferences_acl = sub { return undef; };
|
||||
*main::error = sub { die join('', @_)."\n"; };
|
||||
*main::text = sub { return join(': ', @_); };
|
||||
for my $kind ('user', 'group') {
|
||||
my $table = "webmin_${kind}_acl";
|
||||
$db->do("create table webmin_$kind (id integer primary key, name text)");
|
||||
$db->do("insert into webmin_$kind values (1, 'integration')");
|
||||
$db->do("create table $table (id integer, module text, attr text, value text, primary key(id,module,attr))");
|
||||
my $save = $kind eq 'user' ? \&save_module_acl : \&save_group_module_acl;
|
||||
my $get = $kind eq 'user' ? \&get_module_acl : \&get_group_module_acl;
|
||||
$save->({api_only => 'virtual-server/remote.cgi', rpc => 0}, 'integration', '', 1);
|
||||
# A failed ordinary-field insertion must not remove the endpoint row.
|
||||
$db->do("create trigger fail_insert before insert on $table begin select raise(abort, 'fixture insert failure'); end");
|
||||
eval { $save->({rpc => 1}, 'integration', '', 1); };
|
||||
like($@, qr/fixture insert failure/, "$kind reports failed SQL save");
|
||||
my %actual = $get->('integration', '', 1, 1);
|
||||
is($actual{api_only}, 'virtual-server/remote.cgi', "$kind failed SQL save retains endpoint");
|
||||
eval { $save->({api_only => '', rpc => 1}, 'integration', '', 1); };
|
||||
like($@, qr/fixture insert failure/, "$kind reports failed clearing request");
|
||||
%actual = $get->('integration', '', 1, 1);
|
||||
is($actual{api_only}, 'virtual-server/remote.cgi', "$kind failed clearing request retains endpoint");
|
||||
$db->do('drop trigger fail_insert');
|
||||
# Check the policy is present while other fields are being replaced.
|
||||
$db->do("create trigger require_policy before insert on $table when new.attr <> 'api_only' and not exists (select 1 from $table where id = new.id and module = '' and attr = 'api_only' and value <> '') begin select raise(abort, 'policy disappeared during save'); end");
|
||||
eval { $save->({rpc => 1}, 'integration', '.', 1); };
|
||||
is($@, '', "$kind restriction stays present throughout SQL replacement");
|
||||
%actual = $get->('integration', '', 1, 1);
|
||||
is($actual{rpc}, 1, "$kind dot-path writes the real global SQL ACL");
|
||||
$db->do('drop trigger require_policy');
|
||||
# Replacing an endpoint must be an UPDATE that leaves the old value on error.
|
||||
$db->do("create trigger fail_update before update on $table begin select raise(abort, 'fixture update failure'); end");
|
||||
eval { $save->({api_only => 'other/api.cgi'}, 'integration', '', 1); };
|
||||
like($@, qr/fixture update failure/, "$kind reports failed endpoint update");
|
||||
%actual = $get->('integration', '', 1, 1);
|
||||
is($actual{api_only}, 'virtual-server/remote.cgi', "$kind failed update retains original endpoint");
|
||||
$db->do('drop trigger fail_update');
|
||||
$save->(undef, 'integration', '', 1);
|
||||
%actual = $get->('integration', '', 1, 1);
|
||||
is_deeply(\%actual, {api_only => 'virtual-server/remote.cgi'}, "$kind SQL reset retains only endpoint");
|
||||
$save->({api_only => ''}, 'integration', '', 1);
|
||||
%actual = $get->('integration', '', 1, 1);
|
||||
is($actual{api_only}, '', "$kind explicit SQL clearing works");
|
||||
}
|
||||
$db->disconnect();
|
||||
done_testing();
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
# api-only-account.pl(mode, fixture-file)
|
||||
# Create and inspect disposable Webmin fixtures. Passwords never leave the VM.
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings 'uninitialized';
|
||||
use JSON::PP;
|
||||
# Debian and RPM packages install Webmin under different roots. Read the
|
||||
# guest's root before the library is loaded at compile time.
|
||||
my $webmin_root;
|
||||
BEGIN {
|
||||
$webmin_root = '/usr/share/webmin';
|
||||
if (open(my $cfh, '<', '/etc/webmin/miniserv.conf')) {
|
||||
while (my $line = <$cfh>) {
|
||||
$webmin_root = $1 if ($line =~ /^root=(\S+)/);
|
||||
}
|
||||
close($cfh);
|
||||
}
|
||||
}
|
||||
use lib $webmin_root;
|
||||
use WebminCore;
|
||||
my ($mode, $file) = @ARGV;
|
||||
# Load only the VM-local fixture file supplied by the test driver.
|
||||
open(my $fh, '<', $file) or die $!;
|
||||
my $f = decode_json(do { local $/; <$fh> });
|
||||
close($fh);
|
||||
# Initialize the installed guest instance; this helper must not run locally.
|
||||
$ENV{'WEBMIN_CONFIG'} = '/etc/webmin';
|
||||
$ENV{'WEBMIN_VAR'} = '/var/webmin';
|
||||
$0 = "$webmin_root/api-only-account.pl";
|
||||
&init_config();
|
||||
&foreign_require('acl', 'acl-lib.pl');
|
||||
|
||||
if ($mode eq 'setup') {
|
||||
# Only generated fixture logins are modified by this helper.
|
||||
foreach my $kind ('admin', 'user', 'editor') {
|
||||
&acl::get_user($f->{$kind}) && die 'Fixture name clash';
|
||||
&acl::create_user({name => $f->{$kind}, modules => ['acl'], nochange => 1,
|
||||
pass => &acl::encrypt_password($f->{$kind.'_password'}, '$6$fixture$')});
|
||||
&save_module_acl({rpc => 0}, $f->{$kind}, '', 1);
|
||||
}
|
||||
# Give the restricted fixture a conflicting RPC grant to test precedence.
|
||||
&save_module_acl({api_only => 'virtual-server/remote.cgi', rpc => 1},
|
||||
$f->{user}, '', 1);
|
||||
# The delegated editor can edit accounts but cannot change their ACLs.
|
||||
&save_module_acl({users => $f->{user}, acl => 0, create => 0, delete => 0,
|
||||
rename => 0, groups => 1, pass => 0}, $f->{editor}, 'acl', 1);
|
||||
&save_module_acl({users => '', acl => 0, create => 0, delete => 0,
|
||||
rename => 0, groups => 0}, $f->{user}, 'acl', 1);
|
||||
# An unrestricted group exercises copying without an inherited endpoint.
|
||||
&acl::create_group({name => $f->{group}, desc => 'API lifecycle fixture',
|
||||
modules => [], members => []});
|
||||
&save_group_module_acl({api_only => ''}, $f->{group}, '', 1);
|
||||
&reload_miniserv();
|
||||
}
|
||||
elsif ($mode eq 'inspect') {
|
||||
# Return policy fields only, never credentials or hashes.
|
||||
my %result;
|
||||
foreach my $key ('user', 'renamed', 'clone') {
|
||||
my %a = &get_module_acl($f->{$key}, '', 1, 1);
|
||||
$result{$key} = {exists => &acl::get_user($f->{$key}) ? 1 : 0,
|
||||
api_only => $a{api_only}, rpc => $a{rpc}};
|
||||
}
|
||||
my %g = &get_group_module_acl($f->{group}, '', 1);
|
||||
$result{group} = {exists => &acl::get_group($f->{group}) ? 1 : 0,
|
||||
api_only => $g{api_only}, rpc => $g{rpc}, nodot => $g{nodot}};
|
||||
print encode_json(\%result);
|
||||
}
|
||||
elsif ($mode eq 'copy') {
|
||||
# Copying a group with no endpoint restriction must preserve the target
|
||||
# endpoint, using the same helper as group assignment.
|
||||
&acl::copy_group_user_acl_files($f->{group}, $f->{user}, ['']);
|
||||
}
|
||||
elsif ($mode eq 'propagate') {
|
||||
# A group update must preserve the fixture user's current endpoint.
|
||||
my @users = &acl::list_users();
|
||||
my @groups = &acl::list_groups();
|
||||
&acl::set_acl_files(\@users, \@groups, '', [$f->{user}], {api_only => '', rpc => 1});
|
||||
}
|
||||
elsif ($mode eq 'group-editor') {
|
||||
# Exercise inherited fields for an ACL editor without group assignment.
|
||||
&save_module_acl({users => $f->{user}, acl => 1, gassign => '', create => 0,
|
||||
delete => 0, rename => 0, groups => 0, pass => 0}, $f->{editor}, 'acl', 1);
|
||||
}
|
||||
elsif ($mode eq 'cleanup') {
|
||||
# Remove every possible lifecycle name, including clones and renamed users.
|
||||
foreach my $key ('user', 'renamed', 'clone', 'editor', 'admin') {
|
||||
&acl::delete_user($f->{$key}) if (&acl::get_user($f->{$key}));
|
||||
}
|
||||
&acl::delete_group($f->{group}) if (&acl::get_group($f->{group}));
|
||||
# Older group deletion leaves the global ACL file behind.
|
||||
&unlink_file("/etc/webmin/$f->{group}.gacl");
|
||||
&reload_miniserv();
|
||||
foreach my $key ('user', 'renamed', 'clone', 'editor', 'admin') {
|
||||
&acl::get_user($f->{$key}) && die 'Fixture account remains';
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Reject unknown operations instead of leaving ambiguous fixture state.
|
||||
die 'Unknown fixture operation';
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Test API-only request boundaries on an isolated Linux guest installation.
|
||||
|
||||
Usage: python3 api-only-boundary.py /tmp/task-directory
|
||||
Prepare the directory with api-only-sandbox.py first. The test temporarily
|
||||
changes only that copy's miniserv settings and removes its fixture accounts.
|
||||
Run on a disposable guest, never against a workstation or production server.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import http.client as httpclient
|
||||
import os, signal, socket, sys, threading, time
|
||||
# Load the repository fixture's real form client, without its test sequence.
|
||||
if sys.platform != 'linux' or os.geteuid() != 0:
|
||||
raise RuntimeError('Run only as root inside a disposable Linux test guest')
|
||||
p=Path(sys.argv[1]).resolve()
|
||||
if not any(base in p.parents for base in (Path('/tmp'),Path('/var/tmp'))):
|
||||
raise RuntimeError('Use the isolated task directory under /tmp or /var/tmp')
|
||||
if not (p/'miniserv.pid').is_file() or not (p/'root/miniserv.pl').is_file():
|
||||
raise RuntimeError('Prepare an isolated server with api-only-sandbox.py first')
|
||||
exec(compile((p/'api-only-live.py').read_text().split('\ntry:\n',1)[0],str(p/'api-only-live.py'),'exec'))
|
||||
root=p/'root'; conf=p/'config'; failures=[]
|
||||
def verify(ok, label, detail=''):
|
||||
"""verify(ok, label, [detail]): report an assertion with redacted details."""
|
||||
print(('PASS: ' if ok else 'FAIL: ')+label,flush=True)
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
if detail: print(redact(str(detail))[:500],flush=True)
|
||||
def configure(**kwargs):
|
||||
"""configure(**settings): reload only the isolated miniserv configuration."""
|
||||
f=conf/'miniserv.conf'
|
||||
d=dict(x.split('=',1) for x in f.read_text().splitlines() if '=' in x)
|
||||
d.update(kwargs); f.write_text(''.join(k+'='+str(v)+'\n' for k,v in d.items()))
|
||||
os.kill(int((p/'miniserv.pid').read_text()),signal.SIGUSR1)
|
||||
time.sleep(.3)
|
||||
def http(target, who='user', method='GET', data=None, extra=None, cookie=None):
|
||||
"""http(target, [who], [method], [data], [extra], [cookie]): make a request.
|
||||
|
||||
Use fixture Basic auth by default, or the supplied session cookie. Return
|
||||
the status, headers and body without printing credentials.
|
||||
"""
|
||||
c=httpclient.HTTPConnection('127.0.0.1',18443,timeout=15)
|
||||
headers={'User-Agent':'Webmin deep test','Referer':base+'acl/edit_user.cgi'}
|
||||
if cookie:
|
||||
headers['Cookie']=cookie; headers['User-Agent']='Mozilla/5.0'
|
||||
elif who: headers['Authorization']='Basic '+base64.b64encode((fixture[who]+':'+fixture[who+'_password']).encode()).decode()
|
||||
if data is not None:
|
||||
data=urllib.parse.urlencode(data); headers['Content-Type']='application/x-www-form-urlencoded'
|
||||
headers.update(extra or {})
|
||||
c.request(method,'/'+target,data,headers); r=c.getresponse(); result=(r.status,dict(r.getheaders()),r.read().decode(errors='replace')); c.close(); return result
|
||||
try:
|
||||
helper('setup')
|
||||
userform='acl/edit_user.cgi?user='+fixture['user']
|
||||
groupform='acl/edit_group.cgi?group='+fixture['group']
|
||||
# Confirm group edits actually affect existing members, rather than only the
|
||||
# stored group record.
|
||||
edit(userform,'save_user.cgi',{'api_only_def':'1'})
|
||||
edit(userform,'save_user.cgi',{'group':fixture['group']})
|
||||
edit(groupform,'save_group.cgi',{'api_only_def':'0','api_only':endpoint})
|
||||
verify(state()['user']['api_only']==endpoint,'group restriction reaches existing members',state())
|
||||
status,page=request(userform)
|
||||
verify('name="api_only"' not in page,'grouped-user endpoint is not offered as editable')
|
||||
edit(userform,'save_user.cgi',{'group':''})
|
||||
edit(userform,'save_user.cgi',{'api_only_def':'0','api_only':'deep-api.cgi'})
|
||||
# Approved endpoint, non-opted-in CGI, wrong opt-in and CGI without Webmin
|
||||
# initialization distinguish policy decisions from generic authentication.
|
||||
scripts={
|
||||
'deep-api.cgi':"$main::allow_api_only='deep-api.cgi'; use WebminCore; &init_config(); print \"Content-type: text/plain\\n\\nDEEP_ALLOWED\\n\";",
|
||||
'deep-wrong.cgi':"$main::allow_api_only='deep-wrong.cgi'; use WebminCore; &init_config(); print \"Content-type: text/plain\\n\\nDEEP_WRONG\\n\";",
|
||||
'deep-plain.cgi':"use WebminCore; &init_config(); print \"Content-type: text/plain\\n\\nDEEP_PLAIN\\n\";",
|
||||
'deep-noinit.cgi':"print \"Content-type: text/plain\\n\\nDEEP_NOINIT\\n\";"}
|
||||
for name,source in scripts.items():
|
||||
f=root/name; f.write_text('#!/usr/bin/perl\nuse lib '+repr(str(root))+';\n'+source+'\n'); f.chmod(0o755)
|
||||
for fork in ('0','1'):
|
||||
configure(forkcgis=fork)
|
||||
for name in scripts:
|
||||
status,headers,body=http(name)
|
||||
verify(status==200 and 'DEEP_ALLOWED' in body if name=='deep-api.cgi' else status==403 and 'DEEP_' not in body,f'fork={fork} {name} status={status}',body)
|
||||
for target in ('index.cgi','rpc.cgi','fastrpc.cgi','xmlrpc.cgi','acl/index.cgi','deep-api.cgi/../rpc.cgi','%72pc.cgi','//rpc.cgi'):
|
||||
status,headers,body=http(target)
|
||||
verify(status in (400,403,404) and 'DEEP_ALLOWED' not in body,f'fork={fork} deny {target} status={status}',body)
|
||||
for method in ('POST','HEAD'):
|
||||
status,headers,body=http('deep-plain.cgi',method=method,data={'allow_api_only':'deep-api.cgi','api_only':'deep-api.cgi'} if method=='POST' else None,extra={'Allow-Api-Only':'deep-api.cgi'})
|
||||
verify(status==403 and 'DEEP_PLAIN' not in body,f'fork={fork} {method} cannot supply opt-in status={status}',body)
|
||||
# The configured path must still opt in. This reaches the CGI guard rather
|
||||
# than being rejected by the earlier server path check.
|
||||
edit(userform,'save_user.cgi',{'api_only_def':'0','api_only':'deep-plain.cgi'})
|
||||
for fork in ('0','1'):
|
||||
configure(forkcgis=fork)
|
||||
status,headers,body=http('deep-plain.cgi',method='POST',data={'allow_api_only':'deep-plain.cgi'})
|
||||
verify(status==403 and 'DEEP_PLAIN' not in body,f'fork={fork} configured CGI without opt-in is denied',body)
|
||||
edit(userform,'save_user.cgi',{'api_only_def':'0','api_only':'deep-api.cgi'})
|
||||
configure(forkcgis='0')
|
||||
# Static files and DAV are also outside the single permitted endpoint.
|
||||
(root/'deep-private.txt').write_text('DEEP_STATIC')
|
||||
status,headers,body=http('deep-private.txt')
|
||||
verify(status==403 and 'DEEP_STATIC' not in body,'authenticated static route is denied',body)
|
||||
configure(davpaths='/deep-dav',dav_root=str(p),dav_remoteuser='0')
|
||||
status,headers,body=http('deep-dav/',method='PROPFIND')
|
||||
verify(status==403,'DAV route is denied before its handler',body)
|
||||
configure(davpaths='')
|
||||
# The server handles session login before dispatching the CGI. Retain the
|
||||
# cookie entirely within the guest and test every access path with it.
|
||||
status,headers,body=http('session_login.cgi',who=None,method='POST',data={'user':fixture['user'],'pass':fixture['user_password']},extra={'Cookie':'testing=1','User-Agent':'Mozilla/5.0'})
|
||||
cookie=headers.get('Set-Cookie','').split(';')[0]
|
||||
verify(cookie.startswith('sid='),'real session login creates a cookie',{'status':status,'body':body[:200]})
|
||||
if cookie.startswith('sid='):
|
||||
for name in ('deep-api.cgi','index.cgi','rpc.cgi','deep-noinit.cgi'):
|
||||
status,headers,body=http(name,cookie=cookie)
|
||||
verify(status==200 and 'DEEP_ALLOWED' in body if name=='deep-api.cgi' else status==403 and 'DEEP_' not in body,'session '+name+f' status={status}',body)
|
||||
edit(userform,'save_user.cgi',{'api_only_def':'1'})
|
||||
status,headers,body=http('index.cgi',cookie=cookie)
|
||||
verify(status==200 and 'restricted to its configured API' not in body,'existing session sees explicit policy clearing',body)
|
||||
edit(userform,'save_user.cgi',{'api_only_def':'0','api_only':'deep-api.cgi'})
|
||||
status,headers,body=http('index.cgi',cookie=cookie)
|
||||
verify(status==403,'existing session sees newly applied API restriction',body)
|
||||
# A configured WebSocket route with no user list accepts any authenticated
|
||||
# login today. A harmless backend records whether API-only credentials reach it.
|
||||
listener=socket.socket(); listener.bind(('127.0.0.1',0)); listener.listen(); listener.settimeout(3)
|
||||
reached=[]
|
||||
def backend():
|
||||
"""backend(): record any request forwarded past the WebSocket guard."""
|
||||
try:
|
||||
c,_=listener.accept(); reached.append(True); c.recv(8192); c.sendall(b'HTTP/1.1 400 Test backend reached\r\nContent-Length: 0\r\n\r\n'); c.close()
|
||||
except socket.timeout: pass
|
||||
configure(**{'websockets_/deep-socket':f'host=127.0.0.1 port={listener.getsockname()[1]} path=/deep-socket'})
|
||||
t=threading.Thread(target=backend); t.start()
|
||||
status,headers,body=http('deep-socket',extra={'Upgrade':'websocket','Connection':'Upgrade','Origin':base.rstrip('/'),'Sec-WebSocket-Key':'dGhlIHNhbXBsZSBub25jZQ==','Sec-WebSocket-Version':'13'})
|
||||
t.join(); listener.close()
|
||||
verify(status==403 and not reached,'WebSocket denied before reaching backend',{'status':status,'backend_reached':bool(reached)})
|
||||
# Ordinary users remain able to render existing administration pages.
|
||||
for target in ('index.cgi','acl/index.cgi',userform,groupform):
|
||||
status,headers,body=http(target,who='admin')
|
||||
verify(status==200 and 'restricted to its configured API' not in body,'ordinary admin '+target.split('?')[0],body)
|
||||
finally:
|
||||
configure(forkcgis='0',davpaths='',**{'websockets_/deep-socket':''})
|
||||
helper('cleanup'); path.unlink(missing_ok=True)
|
||||
for name in ('deep-api.cgi','deep-wrong.cgi','deep-plain.cgi','deep-noinit.cgi','deep-private.txt'): (root/name).unlink(missing_ok=True)
|
||||
print('Deep checks finished; failures:',len(failures),flush=True)
|
||||
if failures: raise SystemExit(1)
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Test new API account creation through Webmin Users on an isolated guest.
|
||||
|
||||
Usage: python3 api-only-create.py /tmp/task-directory
|
||||
Prepare the directory with api-only-sandbox.py. Never run on the workstation.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os, signal, sys, time
|
||||
if sys.platform != 'linux' or os.geteuid() != 0:
|
||||
raise RuntimeError('Run only inside a disposable Linux test guest')
|
||||
task=Path(sys.argv[1]).resolve()
|
||||
if not any(base in task.parents for base in (Path('/tmp'),Path('/var/tmp'))):
|
||||
raise RuntimeError('Use the isolated task directory')
|
||||
exec(compile((task/'api-only-live.py').read_text().split('\ntry:\n',1)[0],str(task/'api-only-live.py'),'exec'))
|
||||
root=task/'root'
|
||||
cgi=root/'new-api.cgi'
|
||||
failures=[]
|
||||
def verify(ok,message,details=''):
|
||||
"""verify(ok, message, [details]): record a failure without exposing secrets."""
|
||||
print(('PASS: ' if ok else 'FAIL: ')+message,flush=True)
|
||||
if not ok:
|
||||
failures.append(message)
|
||||
if details: print(redact(str(details))[:500],flush=True)
|
||||
try:
|
||||
helper('setup')
|
||||
cgi.write_text('#!/usr/bin/perl\nuse lib '+repr(str(root))+";\n$main::allow_api_only='new-api.cgi'; use WebminCore; &init_config(); print \"Content-type: text/plain\\n\\nNEW_API_OK\\n\";\n")
|
||||
cgi.chmod(0o755)
|
||||
fields={'name':fixture['clone'],'pass_def':'0','pass':fixture['user_password'],
|
||||
'mod':'acl','rpc':'1','group':'','api_only_def':'0','api_only':'new-api.cgi'}
|
||||
status,page=edit('acl/edit_user.cgi','save_user.cgi',fields)
|
||||
verify(state()['clone']['exists'] and state()['clone']['api_only']=='new-api.cgi','new API user is created with its restriction',page)
|
||||
status,page=request('new-api.cgi',who='user',username=fixture['clone'])
|
||||
verify(status==200 and 'NEW_API_OK' in page,'new API credential works immediately after creation',page)
|
||||
for target in ('index.cgi','acl/index.cgi','rpc.cgi','fastrpc.cgi','xmlrpc.cgi'):
|
||||
status,page=request(target,who='user',username=fixture['clone'])
|
||||
verify(status==403,'new API account cannot open '+target,page)
|
||||
edit('acl/edit_user.cgi?user='+fixture['clone'],'save_user.cgi',{'real':'New API account edited'})
|
||||
verify(state()['clone']['api_only']=='new-api.cgi','normal edit preserves a newly created API account')
|
||||
status,page=request('new-api.cgi',who='user',username=fixture['clone'])
|
||||
verify(status==200 and 'NEW_API_OK' in page,'API remains usable after normal edit',page)
|
||||
# A mistyped endpoint must be rejected before creating a usable login.
|
||||
fields.update(name=fixture['renamed'],api_only='../rpc.cgi')
|
||||
status,page=edit('acl/edit_user.cgi','save_user.cgi',fields)
|
||||
verify(not state()['renamed']['exists'],'invalid endpoint does not leave an unrestricted new account',page)
|
||||
# New members of an API-only group must inherit the restriction too.
|
||||
edit('acl/edit_group.cgi?group='+fixture['group'],'save_group.cgi',{'api_only_def':'0','api_only':'new-api.cgi'})
|
||||
fields.update(name=fixture['renamed'],group=fixture['group'],api_only='new-api.cgi')
|
||||
status,page=edit('acl/edit_user.cgi','save_user.cgi',fields)
|
||||
verify(state()['renamed']['exists'],'new group member is created',page)
|
||||
status,page=request('new-api.cgi',who='user',username=fixture['renamed'])
|
||||
verify(status==200 and 'NEW_API_OK' in page,'new API group member can use the endpoint',page)
|
||||
status,page=request('index.cgi',who='user',username=fixture['renamed'])
|
||||
verify(status==403,'new API group member cannot open the UI',page)
|
||||
# Restart only the isolated instance and verify saved credentials and policy.
|
||||
os.kill(int((task/'miniserv.pid').read_text()),signal.SIGHUP)
|
||||
for attempt in range(50):
|
||||
time.sleep(.1)
|
||||
try:
|
||||
status,page=request('new-api.cgi',who='user',username=fixture['clone'])
|
||||
if status==200 and 'NEW_API_OK' in page: break
|
||||
except OSError: pass
|
||||
else: raise RuntimeError('Isolated API endpoint did not recover after restart')
|
||||
verify(status==200 and 'NEW_API_OK' in page,'new API account works after Webmin restart',page)
|
||||
status,page=request('index.cgi',who='user',username=fixture['clone'])
|
||||
verify(status==403,'new API account remains restricted after restart',page)
|
||||
finally:
|
||||
helper('cleanup');path.unlink(missing_ok=True);cgi.unlink(missing_ok=True)
|
||||
print('New-account failures:',len(failures),flush=True)
|
||||
if failures: raise SystemExit(1)
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Test inherited controls and group deletion through an isolated Webmin UI.
|
||||
|
||||
Usage: python3 api-only-groups.py /tmp/task-directory
|
||||
Prepare the sandbox with api-only-sandbox.py. Never run on the workstation.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform != 'linux' or os.geteuid() != 0:
|
||||
raise RuntimeError('Run only inside a disposable Linux test guest')
|
||||
task = Path(sys.argv[1]).resolve()
|
||||
if not any(base in task.parents for base in (Path('/tmp'), Path('/var/tmp'))):
|
||||
raise RuntimeError('Use the isolated task directory')
|
||||
# Reuse guest-only credentials, HTTP requests and form parsing from the driver.
|
||||
exec(compile((task / 'api-only-live.py').read_text().split('\ntry:\n', 1)[0],
|
||||
str(task / 'api-only-live.py'), 'exec'))
|
||||
|
||||
|
||||
def controls(target, who='admin'):
|
||||
"""controls(target, [who]): return editable field names from a user form."""
|
||||
status, page = request(target, who=who)
|
||||
form = Form(page, 'save_user.cgi')
|
||||
check(status == 200 and form.found, 'user form is available to ' + who, page)
|
||||
return {name for name, value in form.fields}
|
||||
|
||||
|
||||
try:
|
||||
helper('setup')
|
||||
userform = 'acl/edit_user.cgi?user=' + fixture['user']
|
||||
groupform = 'acl/edit_group.cgi?group=' + fixture['group']
|
||||
global_fields = {'api_only', 'rpc', 'acl_security_form', 'root_def', 'nodot'}
|
||||
check(global_fields <= controls(userform), 'ungrouped user can edit global permissions')
|
||||
|
||||
# Membership hides inherited fields regardless of where ACLs are stored.
|
||||
edit(userform, 'save_user.cgi', {'group': fixture['group']})
|
||||
check(not global_fields & controls(userform), 'group member has no editable global permissions')
|
||||
check(not global_fields & controls('acl/edit_user.cgi?clone=' + fixture['user']),
|
||||
'grouped clone also inherits global permissions')
|
||||
helper('group-editor')
|
||||
check(not (global_fields | {'group'}) & controls(userform, 'editor'),
|
||||
'editor without group assignment still sees inherited fields as unavailable')
|
||||
edit(userform, 'save_user.cgi', {'group': ''})
|
||||
check(global_fields <= controls(userform), 'detached member can edit global permissions again')
|
||||
|
||||
# Delete an empty group through the real handler, then reuse its name.
|
||||
edit(groupform, 'save_group.cgi', {'api_only_def': '0', 'api_only': endpoint, 'nodot': '1'})
|
||||
check(state()['group']['api_only'] == endpoint and state()['group']['nodot'] == '1',
|
||||
'group stores endpoint and ordinary global permissions before deletion')
|
||||
request('acl/delete_group.cgi', {'group': fixture['group']})
|
||||
check(not state()['group']['exists'], 'group deletion removes the group record')
|
||||
check(not (task / 'config' / (fixture['group'] + '.gacl')).exists(),
|
||||
'group deletion leaves no global ACL file')
|
||||
edit('acl/edit_group.cgi', 'save_group.cgi',
|
||||
{'name': fixture['group'], 'desc': 'Recreated fixture group', 'mod': ''})
|
||||
group = state()['group']
|
||||
check(group['exists'] and not any(group[key] for key in ('api_only', 'rpc', 'nodot')),
|
||||
'recreated group has no old global permissions')
|
||||
|
||||
# A group without any global policy still owns its members' global fields.
|
||||
edit(userform, 'save_user.cgi', {'api_only_def': '1'})
|
||||
edit(userform, 'save_user.cgi', {'group': fixture['group']})
|
||||
check(not global_fields & controls(userform), 'group without a global ACL still hides inherited fields')
|
||||
check(not state()['user']['api_only'], 'new member does not inherit the deleted endpoint')
|
||||
status, page = request('index.cgi', who='user')
|
||||
check(status == 200 and 'restricted to its configured API' not in page,
|
||||
'deleted group policy does not block the new member', page)
|
||||
|
||||
# Confirmed deletion also cleans up a populated API group and its member.
|
||||
edit(groupform, 'save_group.cgi', {'api_only_def': '0', 'api_only': endpoint})
|
||||
restricted('recreated group policy')
|
||||
request('acl/delete_group.cgi', {'group': fixture['group'], 'confirm': '1'})
|
||||
result = state()
|
||||
check(not result['group']['exists'] and not result['user']['exists'],
|
||||
'confirmed group deletion removes its member')
|
||||
check(not (task / 'config' / (fixture['group'] + '.gacl')).exists(),
|
||||
'populated group deletion also removes its global ACL')
|
||||
print('All group lifecycle tests passed.', flush=True)
|
||||
finally:
|
||||
helper('cleanup')
|
||||
path.unlink(missing_ok=True)
|
||||
@@ -1,270 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test API-only account editing on a disposable Webmin VM, never locally.
|
||||
|
||||
Run with a task directory containing api-only-account.pl. Generated fixture
|
||||
credentials stay in a mode-0600 file on that VM and are removed on completion.
|
||||
"""
|
||||
import base64
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# Generate isolated fixture names and credentials inside the guest task
|
||||
# directory.
|
||||
task = Path(sys.argv[1])
|
||||
path = task / 'api-only-credentials.json'
|
||||
fixture = {key: 'apilife' + secrets.token_hex(5)
|
||||
for key in ('admin', 'user', 'editor', 'renamed', 'clone', 'group')}
|
||||
fixture.update({key + '_password': secrets.token_urlsafe(32)
|
||||
for key in ('admin', 'user', 'editor')})
|
||||
os.umask(0o077)
|
||||
path.write_text(json.dumps(fixture))
|
||||
# Requests target the installed guest instance, never the development
|
||||
# workstation.
|
||||
base = 'https://127.0.0.1:10000/'
|
||||
context = ssl._create_unverified_context() # Guest loopback fixture certificate
|
||||
endpoint = 'virtual-server/remote.cgi'
|
||||
|
||||
|
||||
def redact(value):
|
||||
"""redact(value): remove fixture passwords from diagnostic text."""
|
||||
for key, password in fixture.items():
|
||||
if key.endswith('_password'):
|
||||
# Mask credentials while leaving fixture names useful for diagnosis.
|
||||
value = value.replace(password, '[redacted]')
|
||||
return value
|
||||
|
||||
|
||||
def helper(mode):
|
||||
"""helper(mode): run a fixture operation and return its captured output."""
|
||||
result = subprocess.run(['perl', str(task / 'api-only-account.pl'), mode, str(path)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
# Preserve failure details without exposing generated passwords.
|
||||
raise RuntimeError(redact(result.stdout + result.stderr))
|
||||
return result.stdout
|
||||
|
||||
|
||||
def request(target, params=None, who='admin', username=None):
|
||||
"""request(target, [params], [who], [username]): return HTTP status and body.
|
||||
|
||||
params selects POST instead of GET. who defaults to admin and selects a
|
||||
fixture password; username can override its login name for rename tests.
|
||||
"""
|
||||
auth = base64.b64encode((
|
||||
(username or fixture[who]) + ':' + fixture[who + '_password']).encode()).decode()
|
||||
req = urllib.request.Request(base + target,
|
||||
headers={'Authorization': 'Basic ' + auth, 'User-Agent': 'Webmin API lifecycle test',
|
||||
'Referer': base + 'acl/edit_user.cgi'},
|
||||
data=urllib.parse.urlencode(params).encode() if params is not None else None)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=context, timeout=60) as response:
|
||||
return response.status, response.read().decode(errors='replace')
|
||||
except urllib.error.HTTPError as error:
|
||||
# Denials are expected test responses, not transport failures.
|
||||
return error.code, error.read().decode(errors='replace')
|
||||
|
||||
|
||||
def check(ok, message, output=''):
|
||||
"""check(ok, message, [output]): report an assertion; redact optional output."""
|
||||
if not ok:
|
||||
# Bound failure output and remove credentials before raising.
|
||||
raise AssertionError(message + ': ' + redact(output[:1500]))
|
||||
print('PASS: ' + message, flush=True)
|
||||
|
||||
|
||||
class Form(HTMLParser):
|
||||
"""Read input values from the matching Webmin edit form."""
|
||||
def __init__(self, page, action):
|
||||
"""Form(page, action): parse controls for the matching submit action."""
|
||||
super().__init__()
|
||||
self.action, self.active, self.found = action, False, False
|
||||
self.fields, self.select, self.textarea = [], None, None
|
||||
self.feed(page)
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
"""handle_starttag(tag, attrs): collect enabled controls in the form."""
|
||||
attrs = dict(attrs)
|
||||
if tag == 'form':
|
||||
# Ignore unrelated forms, including search and navigation controls.
|
||||
self.active = attrs.get('action', '').split('?')[0].endswith(self.action)
|
||||
self.found |= self.active
|
||||
if not self.active:
|
||||
# Only controls within the target form belong in its submission.
|
||||
return
|
||||
name = attrs.get('name')
|
||||
if tag == 'input' and name and 'disabled' not in attrs:
|
||||
# Follow browser rules for controls included in form submission.
|
||||
kind = attrs.get('type', 'text').lower()
|
||||
if kind in ('submit', 'button', 'reset', 'file', 'image'):
|
||||
# The driver does not click a button or upload a file.
|
||||
return
|
||||
if kind in ('checkbox', 'radio') and 'checked' not in attrs:
|
||||
# Unselected choices contribute no submitted value.
|
||||
return
|
||||
self.fields.append((name, attrs.get('value', '')))
|
||||
elif tag == 'select' and name:
|
||||
# Defer selection until all options have been read.
|
||||
self.select = [name, 'multiple' in attrs, []]
|
||||
elif tag == 'option' and self.select:
|
||||
# Keep explicit selections and the first-option fallback in order.
|
||||
self.select[2].append([attrs.get('value', ''), 'selected' in attrs])
|
||||
elif tag == 'textarea' and name:
|
||||
# Textarea contents arrive through handle_data.
|
||||
self.textarea = [name, '']
|
||||
|
||||
def handle_data(self, data):
|
||||
"""handle_data(data): append text only while inside a textarea."""
|
||||
if self.textarea:
|
||||
# Preserve multiline contents as one submitted field.
|
||||
self.textarea[1] += data
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
"""handle_endtag(tag): finish control values or close the target form."""
|
||||
if tag == 'select' and self.select:
|
||||
# Submit selected options, using the browser default when needed.
|
||||
name, multiple, options = self.select
|
||||
chosen = [value for value, selected in options if selected]
|
||||
if not chosen and not multiple and options:
|
||||
# A single select defaults to its first option.
|
||||
chosen = [options[0][0]]
|
||||
self.fields.extend((name, value) for value in chosen)
|
||||
self.select = None
|
||||
elif tag == 'textarea' and self.textarea:
|
||||
# Save the complete value once the textarea closes.
|
||||
self.fields.append(tuple(self.textarea))
|
||||
self.textarea = None
|
||||
elif tag == 'form':
|
||||
# Stop collecting controls beyond this form.
|
||||
self.active = False
|
||||
|
||||
|
||||
def edit(target, action, changes=None, omit=()):
|
||||
"""edit(target, action, [changes], [omit]): submit the rendered edit form.
|
||||
|
||||
changes defaults to no overrides; omit defaults to no removed controls.
|
||||
All other submitted settings retain their rendered values.
|
||||
"""
|
||||
status, page = request(target)
|
||||
form = Form(page, action)
|
||||
check(status == 200 and form.found, 'render ' + action, page)
|
||||
changes = changes or {}
|
||||
fields = [(key, value) for key, value in form.fields
|
||||
if key not in changes and key not in omit]
|
||||
fields.extend(changes.items())
|
||||
return request('acl/' + action, fields)
|
||||
|
||||
|
||||
def state():
|
||||
"""state(): return stored fixture ACLs independently of rendered HTML."""
|
||||
return json.loads(helper('inspect'))
|
||||
|
||||
|
||||
def restricted(label, username=None):
|
||||
"""restricted(label, [username]): check CGI denials with the fixture password.
|
||||
|
||||
username defaults to the original login; clones and renames override it.
|
||||
Both in-process and forked CGI modes must return HTTP 403.
|
||||
"""
|
||||
for target in ('index.cgi', 'acl/index.cgi', 'rpc.cgi', 'fastrpc.cgi', 'xmlrpc.cgi'):
|
||||
status, page = request(target, who='user', username=username)
|
||||
check(status == 403 and 'restricted' in page.lower(), label + ': ' + target, page)
|
||||
|
||||
|
||||
try:
|
||||
helper('setup')
|
||||
userform = 'acl/edit_user.cgi?user=' + fixture['user']
|
||||
status, page = request(userform)
|
||||
check('name="api_only"' in page and endpoint in page,
|
||||
'Webmin Users shows the configured API-only endpoint', page)
|
||||
restricted('initial policy')
|
||||
# Ordinary user edits must preserve the endpoint and all CGI denials.
|
||||
edit(userform, 'save_user.cgi', {'real': 'API lifecycle edited'})
|
||||
check(state()['user']['api_only'] == endpoint, 'normal user edit preserves API restriction')
|
||||
restricted('after normal user edit')
|
||||
|
||||
# Older clients omit the new controls entirely.
|
||||
edit(userform, 'save_user.cgi', {'real': 'older client'}, omit=('api_only', 'api_only_def'))
|
||||
check(state()['user']['api_only'] == endpoint, 'older form cannot discard API restriction')
|
||||
|
||||
# A delegated account editor must not clear the policy by submitting a
|
||||
# global-permissions form that its own UI does not offer.
|
||||
status, page = request('acl/save_user.cgi', {
|
||||
'old': fixture['user'], 'name': fixture['user'], 'acl_security_form': '1',
|
||||
'api_only_def': '1'}, who='editor')
|
||||
check('not allowed' in page.lower() and state()['user']['api_only'] == endpoint,
|
||||
'editor without ACL permission cannot clear restriction', page)
|
||||
|
||||
# Reset and group-copy helpers must retain an existing user restriction.
|
||||
request('acl/save_acl.cgi', {'_acl_user': fixture['user'], '_acl_mod': '', 'reset': '1'})
|
||||
check(state()['user']['api_only'] == endpoint, 'resetting global ACL retains restriction')
|
||||
helper('copy')
|
||||
check(state()['user']['api_only'] == endpoint, 'copying unrestricted group retains restriction')
|
||||
helper('propagate')
|
||||
check(state()['user']['api_only'] == endpoint, 'group propagation retains restriction')
|
||||
|
||||
# Exercise real group assignment, then detach before editing global fields.
|
||||
edit(userform, 'save_user.cgi', {'group': fixture['group']})
|
||||
check(state()['user']['api_only'] == endpoint, 'assigning group in UI retains restriction')
|
||||
edit(userform, 'save_user.cgi', {'group': ''})
|
||||
|
||||
# An endpoint selected on a group must reach existing unrestricted members.
|
||||
edit(userform, 'save_user.cgi', {'api_only_def': '1'})
|
||||
edit(userform, 'save_user.cgi', {'group': fixture['group']})
|
||||
status, page = request(userform)
|
||||
check(status == 200 and 'name="api_only"' not in page,
|
||||
'grouped users do not get an endpoint control that cannot be saved')
|
||||
|
||||
# Group fields need the same validation and permission checks as user
|
||||
# fields.
|
||||
groupform = 'acl/edit_group.cgi?group=' + fixture['group']
|
||||
edit(groupform, 'save_group.cgi', {'api_only_def': '0', 'api_only': endpoint})
|
||||
check(state()['group']['api_only'] == endpoint, 'group UI saves explicit API restriction')
|
||||
check(state()['user']['api_only'] == endpoint, 'group restriction reaches existing members')
|
||||
restricted('after setting group endpoint')
|
||||
edit(userform, 'save_user.cgi', {'group': ''})
|
||||
status, page = request('acl/save_group.cgi', {
|
||||
'old': fixture['group'], 'name': fixture['group'], 'acl_security_form': '1',
|
||||
'api_only_def': '1'}, who='editor')
|
||||
check('not allowed' in page.lower() and state()['group']['api_only'] == endpoint,
|
||||
'group editor without ACL permission cannot clear restriction', page)
|
||||
# Older group forms and resets must also retain the endpoint.
|
||||
edit(groupform, 'save_group.cgi', {'desc': 'group edited'}, omit=('api_only', 'api_only_def'))
|
||||
check(state()['group']['api_only'] == endpoint, 'older group form preserves restriction')
|
||||
request('acl/save_acl.cgi', {'_acl_group': fixture['group'], '_acl_mod': '', 'reset': '1'})
|
||||
check(state()['group']['api_only'] == endpoint, 'group global ACL reset preserves restriction')
|
||||
|
||||
# Bad paths must be rejected; explicit clearing must restore ordinary
|
||||
# access.
|
||||
edit(userform, 'save_user.cgi', {'api_only_def': '0', 'api_only': '../rpc.cgi'})
|
||||
check(state()['user']['api_only'] == endpoint, 'malformed endpoint cannot replace saved restriction')
|
||||
edit(userform, 'save_user.cgi', {'api_only_def': '1'})
|
||||
check(not state()['user']['api_only'], 'Unrestricted explicitly clears endpoint')
|
||||
status, page = request('index.cgi', who='user')
|
||||
check(status == 200 and 'restricted to its configured API' not in page,
|
||||
'explicit clearing restores normal UI access', page)
|
||||
edit(userform, 'save_user.cgi', {'api_only_def': '0', 'api_only': endpoint})
|
||||
restricted('after re-enabling restriction')
|
||||
|
||||
# Cloning and renaming must retain both the stored endpoint and its denials.
|
||||
edit('acl/edit_user.cgi?clone=' + fixture['user'], 'save_user.cgi', {
|
||||
'name': fixture['clone'], 'pass_def': '0', 'pass': fixture['user_password']})
|
||||
check(state()['clone']['api_only'] == endpoint, 'cloning account preserves restriction')
|
||||
restricted('cloned account', fixture['clone'])
|
||||
edit(userform, 'save_user.cgi', {'name': fixture['renamed']})
|
||||
check(state()['renamed']['api_only'] == endpoint and not state()['user']['exists'],
|
||||
'renaming account preserves restriction')
|
||||
restricted('renamed account', fixture['renamed'])
|
||||
print('All authenticated API-only lifecycle tests passed.', flush=True)
|
||||
finally:
|
||||
# Cleanup uses the guest helper independently of the restricted login.
|
||||
helper('cleanup')
|
||||
path.unlink(missing_ok=True)
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Create a loopback Webmin copy on a disposable Linux guest, never locally.
|
||||
|
||||
Usage: python3 api-only-sandbox.py /tmp/task-directory
|
||||
The directory must contain candidate.tar with the source files being tested.
|
||||
The guest needs an installed Webmin with its config in /etc/webmin; the
|
||||
Webmin root is read from that miniserv.conf, so Debian and RPM layouts work.
|
||||
|
||||
This starts a separate server on 127.0.0.1:18443 and prepares the lifecycle
|
||||
fixtures in the task directory. Run api-only-live.py and api-only-boundary.py
|
||||
with that directory as their argument. Stop the PID in miniserv.pid before
|
||||
removing the task directory; it contains copies of the guest's configuration.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os, shutil, subprocess, sys, tarfile
|
||||
if sys.platform != 'linux' or os.geteuid() != 0:
|
||||
raise RuntimeError('Run only as root inside a disposable Linux test guest')
|
||||
p=Path(sys.argv[1]).resolve()
|
||||
if not any(base in p.parents for base in (Path('/tmp'),Path('/var/tmp'))):
|
||||
raise RuntimeError('Use a task directory under /tmp or /var/tmp')
|
||||
os.umask(0o077)
|
||||
# Debian and RPM packages install Webmin under different roots.
|
||||
installed=dict(line.split('=',1) for line in Path('/etc/webmin/miniserv.conf').read_text().splitlines() if '=' in line).get('root','/usr/share/webmin')
|
||||
root=p/'root'; conf=p/'config'; var=p/'var'
|
||||
if root.exists():
|
||||
raise RuntimeError('Refusing to replace an existing fixture installation')
|
||||
subprocess.run(['cp','-a',installed,str(root)],check=True)
|
||||
subprocess.run(['cp','-a','/etc/webmin',str(conf)],check=True)
|
||||
var.mkdir()
|
||||
# Isolate daemon state, credentials and module configuration in the fixture.
|
||||
for f in conf.rglob('*'):
|
||||
if f.is_file() and not f.is_symlink():
|
||||
data=f.read_bytes()
|
||||
data=data.replace(b'/etc/webmin',str(conf).encode()).replace(b'/var/webmin',str(var).encode()).replace(installed.encode(),str(root).encode())
|
||||
f.write_bytes(data)
|
||||
with tarfile.open(p/'candidate.tar') as tar:
|
||||
for item in tar.getmembers():
|
||||
assert item.isfile() and not Path(item.name).is_absolute() and '..' not in Path(item.name).parts
|
||||
dest=root/item.name
|
||||
assert root.resolve() in dest.resolve().parents
|
||||
dest.parent.mkdir(parents=True,exist_ok=True)
|
||||
dest.write_bytes(tar.extractfile(item).read()); dest.chmod(item.mode)
|
||||
# Keep only fixture authentication data. Never copy guest login credentials out.
|
||||
(conf/'miniserv.users').write_text('')
|
||||
(conf/'webmin.acl').write_text('')
|
||||
(conf/'webmin.groups').write_text('')
|
||||
values=dict(line.split('=',1) for line in (conf/'miniserv.conf').read_text().splitlines() if '=' in line)
|
||||
for k in list(values):
|
||||
if k.startswith(('websockets_','preroot_')) or k in ('pam','pamany','userdb','unixauth','sudo','certfile','keyfile','extracas','extracas_','ca','logout','inetd','ipv6','listen','passdelay','blockhost_failures','blockuser_failures','blockhost_time','blockuser_time'):
|
||||
values.pop(k,None)
|
||||
values.update(port='18443',bind='127.0.0.1',ssl='0',root=str(root),pidfile=str(p/'miniserv.pid'),logfile=str(p/'miniserv.log'),errorlog=str(p/'miniserv.error'),sessiondb=str(p/'sessions'),env_WEBMIN_CONFIG=str(conf),env_WEBMIN_VAR=str(var),userfile=str(conf/'miniserv.users'),forkcgis='0',internalcgis='0',session='1',nofork='0',log='1',logclear='0',syslog='0',passdelay='0',blockhost_failures='0',blockuser_failures='0',allow='127.0.0.1',ssl_hsts='0')
|
||||
(conf/'miniserv.conf').write_text(''.join(k+'='+v+'\n' for k,v in values.items()))
|
||||
# Reuse real lifecycle fixtures with only installation paths and URL adapted.
|
||||
for name in ('api-only-account.pl','api-only-live.py'):
|
||||
s=(root/'t/fixtures'/name).read_text()
|
||||
s=s.replace('/usr/share/webmin',str(root)).replace(installed,str(root)).replace('/etc/webmin',str(conf)).replace('/var/webmin',str(var)).replace('https://127.0.0.1:10000/','http://127.0.0.1:18443/')
|
||||
(p/name).write_text(s)
|
||||
# Avoid scripts with a build-host Perl path when testing forked CGI execution.
|
||||
for f in root.rglob('*.cgi'):
|
||||
if f.is_file() and not f.is_symlink():
|
||||
data=f.read_bytes()
|
||||
if data.startswith(b'#!/usr/local/bin/perl'):
|
||||
f.write_bytes(data.replace(b'#!/usr/local/bin/perl',b'#!/usr/bin/perl',1))
|
||||
print('Isolated source, configuration and lifecycle fixtures prepared')
|
||||
subprocess.run(['perl',str(root/'miniserv.pl'),str(conf/'miniserv.conf')],check=True,timeout=30)
|
||||
print('Fixture miniserv started on guest loopback port 18443')
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Point an isolated sandbox at a throwaway SQL user database on a Linux guest.
|
||||
|
||||
Usage: python3 api-only-userdb.py /tmp/task-directory mysql|postgresql setup|cleanup
|
||||
Prepare the directory with api-only-sandbox.py first. setup creates a database
|
||||
and login named after the task directory, installs Webmin's user tables, sets
|
||||
userdb in the sandbox's miniserv.conf and reloads only that server. Rerun the
|
||||
account fixtures afterwards to exercise database-backed logins. cleanup removes
|
||||
the sandbox setting and drops the database and login. The guest's own Webmin
|
||||
configuration is never changed. Never run on the workstation.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os, secrets, signal, subprocess, sys, time
|
||||
if sys.platform != 'linux' or os.geteuid() != 0:
|
||||
raise RuntimeError('Run only as root inside a disposable Linux test guest')
|
||||
if len(sys.argv) != 4 or sys.argv[2] not in ('mysql','postgresql') or sys.argv[3] not in ('setup','cleanup'):
|
||||
raise RuntimeError('Usage: api-only-userdb.py TASK-DIRECTORY mysql|postgresql setup|cleanup')
|
||||
task=Path(sys.argv[1]).resolve(); kind=sys.argv[2]; mode=sys.argv[3]
|
||||
if not any(base in task.parents for base in (Path('/tmp'),Path('/var/tmp'))):
|
||||
raise RuntimeError('Use the isolated task directory under /tmp or /var/tmp')
|
||||
conf=task/'config'/'miniserv.conf'
|
||||
if not (task/'miniserv.pid').is_file() or not conf.is_file():
|
||||
raise RuntimeError('Prepare an isolated server with api-only-sandbox.py first')
|
||||
os.umask(0o077)
|
||||
# The database and its login are named after the task directory so parallel
|
||||
# sandboxes never share state.
|
||||
name='webmin_apitest_'+''.join(c for c in task.name.lower() if c.isalnum())[-8:]
|
||||
secret=task/'userdb-password'
|
||||
# Webmin's own schema for each backend, from userdb_table_sql in acl/acl-lib.pl.
|
||||
if kind=='mysql':
|
||||
schema=["create table webmin_user (id int(20) not null primary key auto_increment, name varchar(255) not null, pass varchar(255))",
|
||||
"create table webmin_group (id int(20) not null primary key auto_increment, name varchar(255) not null, description varchar(255))",
|
||||
"create table webmin_user_attr (id int(20) not null, attr varchar(255) not null, value varchar(4096), primary key(id, attr))",
|
||||
"create table webmin_group_attr (id int(20) not null, attr varchar(255) not null, value varchar(4096), primary key(id, attr))",
|
||||
"create table webmin_user_acl (id int(20) not null, module varchar(32) not null, attr varchar(32) not null, value varchar(255), primary key(id, module, attr))",
|
||||
"create table webmin_group_acl (id int(20) not null, module varchar(32) not null, attr varchar(32) not null, value varchar(255), primary key(id, module, attr))"]
|
||||
else:
|
||||
schema=["create table webmin_user (id serial not null primary key, name varchar(255), pass varchar(255))",
|
||||
"create table webmin_group (id serial not null primary key, name varchar(255), description varchar(255))",
|
||||
"create table webmin_user_attr (id int8 not null, attr varchar(255) not null, value varchar(4096), primary key(id, attr))",
|
||||
"create table webmin_group_attr (id int8 not null, attr varchar(255) not null, value varchar(4096), primary key(id, attr))",
|
||||
"create table webmin_user_acl (id int8 not null, module varchar(255) not null, attr varchar(255) not null, value varchar(255), primary key(id, module, attr))",
|
||||
"create table webmin_group_acl (id int8 not null, module varchar(255) not null, attr varchar(255) not null, value varchar(255), primary key(id, module, attr))"]
|
||||
mysql_defaults=None
|
||||
def mysql_command():
|
||||
"""mysql_command(): return a mysql client command that logs in as the administrator.
|
||||
|
||||
Root's Unix socket login is tried first. Otherwise the guest Webmin MySQL
|
||||
module's saved administrator login is written to a mode-0600 defaults file in
|
||||
the task directory, so the password never appears on a command line.
|
||||
"""
|
||||
global mysql_defaults
|
||||
if mysql_defaults is None:
|
||||
probe=subprocess.run(['mysql','--batch','-e','select 1'],capture_output=True,text=True)
|
||||
if probe.returncode==0:
|
||||
mysql_defaults=''
|
||||
else:
|
||||
module=dict(line.split('=',1) for line in Path('/etc/webmin/mysql/config').read_text().splitlines() if '=' in line)
|
||||
path=task/'mysql-defaults.cnf'
|
||||
path.write_text('[client]\nuser='+module.get('login','root')+'\npassword='+module.get('pass','')+'\n')
|
||||
mysql_defaults='--defaults-extra-file='+str(path)
|
||||
return ['mysql']+([mysql_defaults] if mysql_defaults else [])+['--batch']
|
||||
def sql(statements, database=None):
|
||||
"""sql(statements, [database]): run SQL as the local database superuser.
|
||||
|
||||
database defaults to the server's administrative database. Statements,
|
||||
including any generated password, travel on stdin and never on a command line.
|
||||
"""
|
||||
text=';\n'.join(statements)+';\n'
|
||||
if kind=='mysql':
|
||||
cmd=mysql_command()+([database] if database else [])
|
||||
else:
|
||||
cmd=['runuser','-u','postgres','--','psql','-v','ON_ERROR_STOP=1','-q','-d',database or 'postgres']
|
||||
result=subprocess.run(cmd,input=text,text=True,capture_output=True)
|
||||
if result.returncode:
|
||||
# Report the server's message without echoing the submitted statements.
|
||||
raise RuntimeError(kind+' statement failed: '+result.stderr.strip()[-500:])
|
||||
def configure(userdb):
|
||||
"""configure(userdb): set or remove the sandbox userdb and reload that server."""
|
||||
lines=[line for line in conf.read_text().splitlines() if not line.startswith('userdb=')]
|
||||
if userdb is not None:
|
||||
lines.append('userdb='+userdb)
|
||||
conf.write_text(''.join(line+'\n' for line in lines))
|
||||
os.kill(int((task/'miniserv.pid').read_text()),signal.SIGUSR1)
|
||||
time.sleep(.5)
|
||||
if mode=='setup':
|
||||
password=secrets.token_urlsafe(24) # URL-safe characters keep the userdb string parseable
|
||||
secret.write_text(password)
|
||||
if kind=='mysql':
|
||||
sql(['create database '+name,"create user '"+name+"'@'localhost' identified by '"+password+"'",
|
||||
'grant all on '+name+'.* to '+"'"+name+"'@'localhost'",'flush privileges'])
|
||||
sql(schema,name)
|
||||
else:
|
||||
sql(['create user '+name+" with password '"+password+"'",'create database '+name+' owner '+name])
|
||||
# Tables belong to the superuser; the login needs both table and sequence rights.
|
||||
sql(schema+['grant all on all tables in schema public to '+name,
|
||||
'grant all on all sequences in schema public to '+name],name)
|
||||
configure(kind+'://'+name+':'+password+'@localhost/'+name)
|
||||
print('Sandbox now authenticates against '+kind+' database '+name)
|
||||
else:
|
||||
configure(None)
|
||||
if kind=='mysql':
|
||||
sql(['drop database if exists '+name,"drop user if exists '"+name+"'@'localhost'"])
|
||||
else:
|
||||
sql(['drop database if exists '+name,'drop user if exists '+name])
|
||||
secret.unlink(missing_ok=True)
|
||||
(task/'mysql-defaults.cnf').unlink(missing_ok=True)
|
||||
print('Sandbox restored to file-based accounts; dropped '+kind+' database '+name)
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
# Test uncached API policies and failures when reading account restrictions.
|
||||
use strict;
|
||||
use warnings;
|
||||
no warnings qw(once redefine uninitialized);
|
||||
use Test::More;
|
||||
use File::Temp qw(tempdir);
|
||||
use FindBin;
|
||||
require "$FindBin::Bin/../miniserv-lib.pl";
|
||||
my $dir = tempdir(CLEANUP => 1);
|
||||
$miniserv::config{'env_WEBMIN_CONFIG'} = $dir;
|
||||
# policy([endpoint]): replace the fixture ACL, or remove it when omitted.
|
||||
sub policy {
|
||||
my ($endpoint) = @_;
|
||||
unlink("$dir/integration.acl");
|
||||
if (defined($endpoint)) {
|
||||
open(my $fh, '>', "$dir/integration.acl") or die $!;
|
||||
print $fh "api_only=$endpoint\n";
|
||||
close($fh) or die $!;
|
||||
}
|
||||
}
|
||||
is_deeply([miniserv::get_api_only_endpoint('integration', {})], [undef, undef],
|
||||
'ordinary local account has no API policy');
|
||||
policy('virtual-server/remote.cgi');
|
||||
is_deeply([miniserv::get_api_only_endpoint('integration', {})],
|
||||
['virtual-server/remote.cgi', undef], 'local policy is enforced');
|
||||
policy('another/api.cgi');
|
||||
is_deeply([miniserv::get_api_only_endpoint('integration', {})],
|
||||
['another/api.cgi', undef], 'subsequent requests read changed policy');
|
||||
policy('');
|
||||
is_deeply([miniserv::get_api_only_endpoint('integration', {})], ['', undef],
|
||||
'explicit clearing takes effect without a cached restriction');
|
||||
policy();
|
||||
mkdir("$dir/integration.acl") or die $!;
|
||||
ok((miniserv::get_api_only_endpoint('integration', {}))[1],
|
||||
'invalid policy file is an error, not unrestricted access');
|
||||
rmdir("$dir/integration.acl");
|
||||
{
|
||||
# A failed database read must never use an unrestricted local policy.
|
||||
local *miniserv::connect_userdb = sub { return 'database unavailable'; };
|
||||
ok((miniserv::get_api_only_endpoint('integration', {proto => 'postgresql', id => 1}))[1],
|
||||
'database outage fails closed');
|
||||
}
|
||||
done_testing();
|
||||
@@ -173,6 +173,20 @@ subtest 'Data::Dumper format' => sub {
|
||||
require Data::Dumper;
|
||||
require JSON::PP;
|
||||
|
||||
# Newer peers emit native booleans as !!1 and !!0, including RPC results.
|
||||
for my $value (0, 1) {
|
||||
is(main::unserialise_variable("\$VAR1 = !!$value;"), !!$value,
|
||||
"literal !!$value retains Perl boolean semantics");
|
||||
is_deeply(main::unserialise_variable(
|
||||
"\$VAR1 = {'rv' => !!$value, 'status' => 1};"),
|
||||
{ 'rv' => !!$value, 'status' => 1 },
|
||||
"fast RPC response accepts !!$value");
|
||||
}
|
||||
is_deeply(main::unserialise_variable(q{$VAR1 = [!!1, !!0, \!!0];}),
|
||||
[ !!1, !!0, \!!0 ], 'booleans work in arrays and scalar references');
|
||||
is_deeply(main::unserialise_variable(q{$VAR1 = ['!!1', "!!0"];}),
|
||||
[ '!!1', '!!0' ], 'quoted boolean text remains unchanged');
|
||||
|
||||
# Spot-check the supported scalar, reference, object and container forms.
|
||||
my $str = 'x';
|
||||
my $shared = [ 1 ];
|
||||
@@ -182,7 +196,7 @@ subtest 'Data::Dumper format' => sub {
|
||||
'sref' => \$str, 'rref' => \\'y', 'a' => $shared, 'b' => $shared,
|
||||
'obj' => bless({ 'x' => 1 }, 'RPC::Untrusted'),
|
||||
'sobj' => bless(\$str, 'RPC::Untrusted'),
|
||||
'empty' => [], 'eh' => {} };
|
||||
'empty' => [], 'eh' => {}, 'true' => !!1, 'false' => !!0 };
|
||||
$v->{'self'} = $v;
|
||||
# Data::Dumper cannot preserve a reference to a hash value if it sees that
|
||||
# reference first. Sorting keys makes this test deterministic.
|
||||
@@ -263,6 +277,15 @@ subtest 'Data::Dumper format' => sub {
|
||||
# Reject hostile input without executing it.
|
||||
our $ran = 0;
|
||||
my @hostile = (
|
||||
q{$VAR1 = !!2;},
|
||||
q{$VAR1 = !!10;},
|
||||
q{$VAR1 = !!01;},
|
||||
q{$VAR1 = !!1e0;},
|
||||
q{$VAR1 = !!0.0;},
|
||||
q{$VAR1 = !!1foo;},
|
||||
q{$VAR1 = !!($main::ran = 1);},
|
||||
q{$VAR1 = !!0 || do { $main::ran = 1 };},
|
||||
q{$VAR1 = !!1; $main::ran = 1;},
|
||||
q{$VAR1 = {}; $main::ran = 1;},
|
||||
q{$VAR1 = "@{[ $main::ran = 1 ]}";},
|
||||
q{$VAR1 = "${\ ($main::ran = 1)}";},
|
||||
|
||||
@@ -281,7 +281,9 @@ body:has(.ui_page[data-ui-scheme="dark"]) {
|
||||
.ui_field, .ui_field * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ui_page h1, .ui_page h2, .ui_page p, .ui_page dl, .ui_page dd { margin: 0; }
|
||||
/* Reset widget paragraphs without removing spacing from help and other prose. */
|
||||
.ui_page h1, .ui_page h2, .ui_page p.ui--p,
|
||||
.ui_page dl, .ui_page dd { margin: 0; }
|
||||
.ui_page svg.ui_svg_icon { flex-shrink: 0; vertical-align: -0.18em; }
|
||||
|
||||
/* Underline plain text links, but keep links styled as buttons undecorated. */
|
||||
|
||||
@@ -56,6 +56,10 @@ if ($$s =~ /\G'/gc) {
|
||||
elsif ($$s =~ /\G"/gc) {
|
||||
return _parse_double($s);
|
||||
}
|
||||
elsif ($$s =~ /\G!!([01])(?![A-Za-z0-9_.])/gc) {
|
||||
# Read native boolean literals without evaluating arbitrary expressions.
|
||||
return $1 eq '1';
|
||||
}
|
||||
elsif ($$s =~ /\G(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?)(?![A-Za-z0-9_.])/gc) {
|
||||
# Return an unquoted number as a numeric scalar.
|
||||
return $1 + 0;
|
||||
|
||||
140
web-lib-funcs.pl
140
web-lib-funcs.pl
@@ -5074,7 +5074,6 @@ sub get_module_acl
|
||||
{
|
||||
my $u = defined($_[0]) ? $_[0] : $base_remote_user;
|
||||
my $m = defined($_[1]) ? $_[1] : &get_module_name();
|
||||
$m = '' if ($m eq '.'); # The global ACL has one database key
|
||||
my $norbac = $_[2];
|
||||
my $nodef = $_[3];
|
||||
$m ||= "";
|
||||
@@ -5104,9 +5103,6 @@ if ($u ne '') {
|
||||
# and if the user might be in the DB
|
||||
my ($dbh, $proto, $prefix, $args) = &connect_userdb($userdb);
|
||||
if (!ref($dbh)) {
|
||||
# A global ACL may contain an API-only restriction. Do not
|
||||
# fall back to local defaults when its database is unavailable.
|
||||
die "Cannot read global ACL from user database\n" if ($m eq '');
|
||||
print STDERR "Failed to connect to user database : ".
|
||||
$dbh."\n";
|
||||
}
|
||||
@@ -5219,7 +5215,6 @@ sub get_group_module_acl
|
||||
{
|
||||
my $g = $_[0];
|
||||
my $m = defined($_[1]) ? $_[1] : &get_module_name();
|
||||
$m = '' if ($m eq '.'); # The global ACL has one database key
|
||||
my $mdir = &module_root_directory($m);
|
||||
my %rv;
|
||||
if (!$_[2]) {
|
||||
@@ -5319,17 +5314,11 @@ Updates the acl hash for some user and module. The parameters are :
|
||||
|
||||
=item never-update-group - Never update the user's group's ACL
|
||||
|
||||
For global permissions, an omitted api_only setting is preserved, including
|
||||
when resetting the ACL. Pass api_only => '' to explicitly remove the restriction.
|
||||
|
||||
=cut
|
||||
sub save_module_acl
|
||||
{
|
||||
# Replacing the ACL argument must not replace the caller's hash reference.
|
||||
local @_ = @_;
|
||||
my $u = defined($_[1]) ? $_[1] : $base_remote_user;
|
||||
my $m = defined($_[2]) ? $_[2] : &get_module_name();
|
||||
$m = '' if ($m eq '.'); # The global ACL has one database key
|
||||
$u eq "webmin" && &error("Invalid username webmin for ACL");
|
||||
if (!$_[3] && &foreign_check("acl")) {
|
||||
# Check if this user is a member of a group, and if he gets the
|
||||
@@ -5348,17 +5337,6 @@ if (!$_[3] && &foreign_check("acl")) {
|
||||
}
|
||||
}
|
||||
|
||||
# Global ACL editors and resets must explicitly clear an API restriction.
|
||||
# Copy the input before retaining it, since group updates reuse the same hash.
|
||||
if (($m eq '' || $m eq '.') && (!$_[0] || !exists($_[0]->{'api_only'}))) {
|
||||
my %old = &get_module_acl($u, $m, 1, 1);
|
||||
if ($old{'api_only'}) {
|
||||
# Retain only an existing restriction; explicit empty values
|
||||
# still clear it.
|
||||
$_[0] = { %{$_[0] || {}}, 'api_only' => $old{'api_only'} };
|
||||
}
|
||||
}
|
||||
|
||||
my $userdb = &get_userdb_string();
|
||||
my $foundindb = 0;
|
||||
if ($userdb && ($u ne $base_remote_user || $remote_user_proto)) {
|
||||
@@ -5380,8 +5358,7 @@ if ($userdb && ($u ne $base_remote_user || $remote_user_proto)) {
|
||||
# Replace ACLs for user
|
||||
if ($foundindb) {
|
||||
my $cmd = $dbh->prepare("delete from webmin_user_acl ".
|
||||
"where id = ? and module = ?".
|
||||
($m eq '' ? " and attr <> 'api_only'" : ""));
|
||||
"where id = ? and module = ?");
|
||||
if (!$cmd || !$cmd->execute($id, $m)) {
|
||||
&disconnect_userdb($userdb, $dbh);
|
||||
&error(&text('euserdbacl', $dbh->errstr));
|
||||
@@ -5394,8 +5371,6 @@ if ($userdb && ($u ne $base_remote_user || $remote_user_proto)) {
|
||||
$cmd || &error(&text('euserdbacl2',
|
||||
$dbh->errstr));
|
||||
foreach my $a (keys %{$_[0]}) {
|
||||
# Keep the endpoint row present during ACL replacement.
|
||||
next if ($m eq '' && $a eq 'api_only');
|
||||
if (!$cmd->execute($id,$m,$a,
|
||||
$_[0]->{$a})) {
|
||||
&disconnect_userdb(
|
||||
@@ -5406,15 +5381,6 @@ if ($userdb && ($u ne $base_remote_user || $remote_user_proto)) {
|
||||
$cmd->finish();
|
||||
}
|
||||
}
|
||||
if ($m eq '' && $_[0] && exists($_[0]->{'api_only'})) {
|
||||
# A failed edit must leave the previous restriction in place.
|
||||
my $err = &save_userdb_api_only($dbh, 'webmin_user_acl',
|
||||
$id, $_[0]->{'api_only'});
|
||||
if ($err) {
|
||||
&disconnect_userdb($userdb, $dbh);
|
||||
&error(&text('euserdbacl2', $err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
elsif ($proto eq "ldap") {
|
||||
@@ -5499,17 +5465,11 @@ Updates the acl hash for some group and module. The parameters are :
|
||||
|
||||
=item never-update-group - Never update the parent group's ACL
|
||||
|
||||
For global permissions, an omitted api_only setting is preserved, including
|
||||
when resetting the ACL. Pass api_only => '' to explicitly remove the restriction.
|
||||
|
||||
=cut
|
||||
sub save_group_module_acl
|
||||
{
|
||||
# Replacing the ACL argument must not replace the caller's hash reference.
|
||||
local @_ = @_;
|
||||
my $g = $_[1];
|
||||
my $m = defined($_[2]) ? $_[2] : &get_module_name();
|
||||
$m = '' if ($m eq '.'); # The global ACL has one database key
|
||||
if (!$_[3] && &foreign_check("acl")) {
|
||||
# Check if this group is a member of a group, and if it gets the
|
||||
# module from a group. If so, update the parent ACL as well
|
||||
@@ -5527,17 +5487,6 @@ if (!$_[3] && &foreign_check("acl")) {
|
||||
}
|
||||
}
|
||||
|
||||
# Global ACL editors and resets must explicitly clear an API restriction.
|
||||
# Copy the input before retaining it, since group updates reuse the same hash.
|
||||
if (($m eq '' || $m eq '.') && (!$_[0] || !exists($_[0]->{'api_only'}))) {
|
||||
my %old = &get_group_module_acl($g, $m, 1);
|
||||
if ($old{'api_only'}) {
|
||||
# Retain only an existing restriction; explicit empty values
|
||||
# still clear it.
|
||||
$_[0] = { %{$_[0] || {}}, 'api_only' => $old{'api_only'} };
|
||||
}
|
||||
}
|
||||
|
||||
my $userdb = &get_userdb_string();
|
||||
my $foundindb = 0;
|
||||
if ($userdb) {
|
||||
@@ -5559,8 +5508,7 @@ if ($userdb) {
|
||||
# Replace ACLs for group
|
||||
if ($foundindb) {
|
||||
my $cmd = $dbh->prepare("delete from webmin_group_acl ".
|
||||
"where id = ? and module = ?".
|
||||
($m eq '' ? " and attr <> 'api_only'" : ""));
|
||||
"where id = ? and module = ?");
|
||||
if (!$cmd || !$cmd->execute($id, $m)) {
|
||||
&disconnect_userdb($userdb, $dbh);
|
||||
&error(&text('egroupdbacl', $dbh->errstr));
|
||||
@@ -5576,8 +5524,6 @@ if ($userdb) {
|
||||
$dbh->errstr));
|
||||
}
|
||||
foreach my $a (keys %{$_[0]}) {
|
||||
# Keep the endpoint row present during ACL replacement.
|
||||
next if ($m eq '' && $a eq 'api_only');
|
||||
if (!$cmd->execute($id,$m,$a,
|
||||
$_[0]->{$a})) {
|
||||
&disconnect_userdb(
|
||||
@@ -5588,15 +5534,6 @@ if ($userdb) {
|
||||
$cmd->finish();
|
||||
}
|
||||
}
|
||||
if ($m eq '' && $_[0] && exists($_[0]->{'api_only'})) {
|
||||
# A failed edit must leave the previous restriction in place.
|
||||
my $err = &save_userdb_api_only($dbh, 'webmin_group_acl',
|
||||
$id, $_[0]->{'api_only'});
|
||||
if ($err) {
|
||||
&disconnect_userdb($userdb, $dbh);
|
||||
&error(&text('egroupdbacl2', $err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
elsif ($proto eq "ldap") {
|
||||
@@ -5669,41 +5606,6 @@ if (!$foundindb) {
|
||||
}
|
||||
}
|
||||
|
||||
# save_userdb_api_only(&database, table, id, endpoint)
|
||||
# Update a SQL account's global endpoint without deleting its policy row.
|
||||
# table is webmin_user_acl or webmin_group_acl; an empty endpoint explicitly
|
||||
# clears the restriction. Returns an error string, or undef on success.
|
||||
sub save_userdb_api_only
|
||||
{
|
||||
my ($dbh, $table, $id, $endpoint) = @_;
|
||||
$table =~ /\Awebmin_(?:user|group)_acl\z/ || return 'Invalid ACL table';
|
||||
my $select = $dbh->prepare("select value from $table ".
|
||||
"where id = ? and module = '' and attr = 'api_only'");
|
||||
$select && $select->execute($id) || return $dbh->errstr;
|
||||
my @old = $select->fetchrow();
|
||||
my $err = $select->errstr if ($select->err);
|
||||
$select->finish();
|
||||
return $err if ($err);
|
||||
if (@old && $old[0] eq $endpoint) {
|
||||
# Routine saves need not rewrite an unchanged restriction.
|
||||
return undef;
|
||||
}
|
||||
my $sql;
|
||||
if (@old) {
|
||||
# One UPDATE keeps the old restriction visible until the new one is saved.
|
||||
$sql = "update $table set value = ? ".
|
||||
"where id = ? and module = '' and attr = 'api_only'";
|
||||
}
|
||||
else {
|
||||
# A new restriction has no existing row to replace.
|
||||
$sql = "insert into $table (value,id,module,attr) values (?,?,'','api_only')";
|
||||
}
|
||||
my $write = $dbh->prepare($sql);
|
||||
$write && $write->execute($endpoint, $id) || return $dbh->errstr;
|
||||
$write->finish();
|
||||
return undef;
|
||||
}
|
||||
|
||||
=head2 init_config
|
||||
|
||||
This function must be called by all Webmin CGI scripts, either directly or
|
||||
@@ -6122,28 +6024,6 @@ if ($module_name) {
|
||||
$module_root_directory = &module_root_directory($module_name);
|
||||
}
|
||||
|
||||
# Require a trusted CGI to opt in even after miniserv has checked its path.
|
||||
# This also enforces the restriction when another web server runs the CGI.
|
||||
if ($main::webmin_script_type eq 'web') {
|
||||
my $endpoint = &webmin_user_api_only();
|
||||
if ($endpoint && $main::allow_api_only ne $endpoint) {
|
||||
# Only trusted endpoint code can opt in before initialization.
|
||||
# Do not render a theme here: it may load modules whose own
|
||||
# initialization has not finished, causing recursive access checks.
|
||||
# miniserv builds in-process status lines from these fields.
|
||||
# Other CGI hosts use the Status header below.
|
||||
if ($miniserv::doing_cgi_eval) {
|
||||
# Set the status used by miniserv's in-process response
|
||||
# writer.
|
||||
$miniserv::ok_code = 403;
|
||||
$miniserv::ok_message = 'Forbidden';
|
||||
}
|
||||
print "Status: 403 Forbidden\r\nContent-type: text/plain\r\n\r\n";
|
||||
print "This account is restricted to its configured API endpoint\n";
|
||||
CORE::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$main::allow_rpc_only &&
|
||||
$main::webmin_script_type eq 'web' &&
|
||||
!$main::no_acl_check &&
|
||||
@@ -14472,25 +14352,13 @@ my ($variable, $scope) = @_;
|
||||
return &globals('delete', $variable, $scope);
|
||||
}
|
||||
|
||||
# webmin_user_api_only()
|
||||
# Returns the authenticated base user's endpoint, or a false value if unset.
|
||||
# Endpoint scripts set main::allow_api_only to the same value before
|
||||
# init_config.
|
||||
sub webmin_user_api_only
|
||||
{
|
||||
my %access = &get_module_acl($base_remote_user, "");
|
||||
return $access{'api_only'};
|
||||
}
|
||||
|
||||
# webmin_user_can_rpc()
|
||||
# Returns the base user's generic RPC access: 0 for none, 1 for RPC and UI,
|
||||
# or 2 for RPC only. An API-only endpoint restriction always denies generic RPC.
|
||||
# Returns 1 if the given user can receive remote calls, 0 if not, or 2 if the
|
||||
# user can receive remote calls but not use the UI
|
||||
sub webmin_user_can_rpc
|
||||
{
|
||||
my $u = $base_remote_user;
|
||||
my %access = &get_module_acl($u, "");
|
||||
# Endpoint credentials must not inherit generic RPC from a legacy flag.
|
||||
return 0 if ($access{'api_only'}); # Scoped APIs never grant generic RPC
|
||||
return 1 if ($access{'rpc'} == 1); # Can make arbitrary RPC calls
|
||||
return 2 if ($access{'rpc'} == 3); # Can only make RPC calls
|
||||
return 0 if ($access{'rpc'} == 0); # Cannot make RPCs
|
||||
|
||||
Reference in New Issue
Block a user