Compare commits

..

2 Commits

Author SHA1 Message Date
Ilia Ross
27b49c6a29 Fix to use description for API-only accounts 2026-10-24 14:09:50 +02:00
Ilia Ross
04b47e5815 Add API-only access for Webmin accounts
This PR adds an `api_only` global permission that restricts a Webmin account to one supported API endpoint, such as virtual-server/remote.cgi.

External integrations need credentials that can call an API without also granting access to the Webmin interface or generic RPC. Existing module permissions do not provide this endpoint-level restriction. The new setting allows administrators to create dedicated integration accounts while Webmin continues performing authorized operations through its privileged backend.

The restriction is enforced in two places:
- MiniServ checks the authenticated account's policy before dispatching CGI, static-file, DAV, or WebSocket requests. Requests outside the configured endpoint receive HTTP 403.
- `init_config` requires the permitted CGI to explicitly opt in before initialization. This also protects Webmin-initializing CGIs hosted by another web server.

Generic RPC is denied for API-only accounts, even when an existing RPC permission would otherwise allow it. The endpoint's own module and operation permissions still apply. Selecting an endpoint does not grant permission to execute every API command.

Webmin Users gains an API-only setting for users and groups. Existing restrictions survive ordinary edits, resets, copies, cloning, renaming, group propagation, and cluster synchronization. Clearing a restriction requires an explicit permission edit. Group updates preserve restrictions already assigned to individual members.

MiniServ reads the policy on each request from the account's existing storage backend: local files, MySQL, PostgreSQL, or LDAP. Policy-read errors deny access instead of treating the account as unrestricted.

Validation passed with the four separately submitted supporting fixes (#2897, #2898, #2899, #2900) ,applied: 169 focused tests, syntax checks, and live Debian tests using file-backed and PostgreSQL accounts. Live checks covered account creation, lifecycle operations, both CGI modes, sessions, RPC, static files, DAV, and WebSockets.

The supporting fixes remain separate PRs. This API-only branch currently has seven lifecycle test failures that depend on the file-cache and group ACL cleanup fixes landing.
2026-10-23 22:06:12 +02:00
27 changed files with 1599 additions and 138 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1665,14 +1665,15 @@ foreach my $m (@$mems) {
=head2 copy_acl_files(from, to, &modules, [from-type], [to-type])
Copy all .acl files from some user to another user in a list of modules.
Copy ACLs for selected modules between users or groups. Global permissions
retain an existing destination API endpoint; otherwise they inherit the source.
The parameters are :
=item from - Source user or group name.
=item to - Destination user or group name.
=item modules - Array ref of module names.
=item modules - Array ref of module names; an empty name or . includes globals.
=item from-type - Either "user" or "group", defaults to "user"
@@ -1684,6 +1685,29 @@ 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
@@ -1821,7 +1845,9 @@ 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. Parameters are :
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 :
=item allusers - An array ref of Webmin users, as returned by list_users.
@@ -1844,7 +1870,16 @@ foreach my $m (@$members) {
if ($u) {
my $aclfile = "$config_directory/$mod/$u->{'name'}.acl";
&lock_file($aclfile);
&save_module_acl($access, $u->{'name'}, $mod, 1);
# 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);
chmod(0640, $aclfile) if (-r $aclfile);
&unlock_file($aclfile);
}
@@ -1857,11 +1892,22 @@ foreach my $m (@$members) {
my $aclfile =
"$config_directory/$mod/$g->{'name'}.gacl";
&lock_file($aclfile);
&save_group_module_acl($access, $g->{'name'}, $mod, 1);
# 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);
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'}, $access);
$g->{'members'}, \%updated);
}
}
}

View File

@@ -134,6 +134,12 @@ 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");

View File

@@ -43,6 +43,11 @@ 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;
@@ -164,8 +169,8 @@ print &ui_table_row($text{'edit_pass'},
"autocomplete=off").
($lockbox || $tempbox ? "<br>" : "").$lockbox.$tempbox.$expmsg);
# Real name
print &ui_table_row($text{'edit_real'},
# API-only accounts describe an integration rather than a person.
print &ui_table_row($text{$uaccess{'api_only'} ? 'edit_desc' : 'edit_real'},
&ui_textbox("real", $user{'real'}, 60));
# Contact email for recovery
@@ -385,6 +390,15 @@ 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

19
acl/help/api_only.html Normal file
View File

@@ -0,0 +1,19 @@
<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>

View File

@@ -36,6 +36,7 @@ edit_rights=Webmin user access rights
edit_user=Username
edit_cloneof=Cloning Webmin user
edit_real=Real name
edit_desc=Description
edit_email=Contact email
edit_group=Member of group
edit_pass=Password

View File

@@ -9,6 +9,10 @@ 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
@@ -134,6 +138,14 @@ 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();

View File

@@ -10,6 +10,10 @@ 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'}) {
@@ -56,6 +60,15 @@ 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)$/) {
@@ -194,11 +207,6 @@ if ($newgroup) {
}
@mods = &unique(@mods, @{$newgroup->{'modules'}});
$user{'ownmods'} = \@ownmods;
# Copy ACL files for group
my $name = $in{'old'} ? $in{'old'} : $in{'name'};
&copy_group_user_acl_files($in{'group'}, $name,
[ @{$newgroup->{'modules'}}, "" ]);
}
$user{'modules'} = \@mods;
@@ -376,16 +384,19 @@ 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) {
&copy_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(\%uaccess, $in{'name'}, "", 1);
&save_module_acl(\%global_access, $in{'name'}, "", 1);
&set_ownership_permissions(undef, undef, 0640, $aclfile);
&unlock_file($aclfile);
}

View File

@@ -112,10 +112,22 @@ print &ui_table_row($text{'acl_webminsearch'},
}
# acl_security_save(&options)
# Parse the form for global security options
# Parse global security fields from %in into the supplied options hash.
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'};

View File

@@ -69,12 +69,10 @@ foreach $h (@hosts) {
exit;
}
# 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);
# 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);
# Recursively update the ACL for all member users and groups
if ($in{'_acl_group'}) {

View File

@@ -135,19 +135,22 @@ foreach $host (@phosts) {
&ui_print_footer("", $text{'index_return'});
# 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
# 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.
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";
foreach $m (@{$_[0]->{'modules'}}) {
# Global permissions include the account's API-only restriction.
foreach $m (&unique('', @{$_[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);
}

View File

@@ -197,6 +197,11 @@ 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?

View File

@@ -4,6 +4,7 @@
package miniserv;
use Socket;
use POSIX;
use Errno qw(ENOENT);
use Time::Local;
eval "use Time::HiRes;";
@@ -1063,6 +1064,28 @@ 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);
@@ -4091,6 +4114,79 @@ 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

View File

@@ -33,6 +33,24 @@ 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

35
t/api-only-accounts.t Normal file
View File

@@ -0,0 +1,35 @@
#!/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();

216
t/api-only-lifecycle.t Normal file
View File

@@ -0,0 +1,216 @@
#!/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();

67
t/api-only-sql.t Normal file
View File

@@ -0,0 +1,67 @@
#!/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();

View File

@@ -0,0 +1,101 @@
#!/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';
}

View File

@@ -0,0 +1,138 @@
"""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)

View File

@@ -0,0 +1,68 @@
"""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)

View File

@@ -0,0 +1,81 @@
"""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)

270
t/fixtures/api-only-live.py Normal file
View File

@@ -0,0 +1,270 @@
#!/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)

View File

@@ -0,0 +1,65 @@
"""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')

View File

@@ -0,0 +1,107 @@
"""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)

44
t/miniserv-api-only.t Normal file
View File

@@ -0,0 +1,44 @@
#!/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();

View File

@@ -1,97 +0,0 @@
#!/usr/bin/perl
# Exercise unlink_file and cached reads using only temporary fixture files.
use strict;
use warnings;
no warnings 'once';
use Test::More;
use File::Temp qw(tempdir);
use FindBin;
require "$FindBin::Bin/../web-lib-funcs.pl";
# Avoid loading workstation configuration or writing Webmin debug logs.
local %main::gconfig = ();
local $main::readonly_mode_cache = 0;
local @main::filename_callbacks = ();
my $root = tempdir(CLEANUP => 1);
# write_fixture(file, contents): write directly without updating Webmin caches.
sub write_fixture
{
my ($file, $contents) = @_;
open(my $fh, '>', $file) or die "Create fixture $file: $!";
print $fh $contents or die "Write fixture $file: $!";
close($fh) or die "Close fixture $file: $!";
}
# Both readers and deletion must use the translated path as their cache key.
foreach my $translated (0, 1) {
foreach my $recreate (0, 1) {
my $label = ($translated ? 'translated' : 'direct').
($recreate ? ' recreated' : ' deleted');
my $file = "$root/$translated-$recreate.conf";
my $requested = $translated ? "$root/alias.conf" : $file;
# Map the logical name to a real file entirely within the fixture tree.
local @main::filename_callbacks = ([undef, sub {
return $_[0] eq $requested ? $file : $_[0];
}, []]);
write_fixture($file, "value=old\nremoved=1\n");
my %before;
ok(main::read_file_cached($requested, \%before), "$label: initial read");
is($before{value}, 'old', "$label: initial contents");
ok(main::unlink_file($requested), "$label: deletion succeeds");
ok(!-e $file, "$label: file is gone");
my %after;
if ($recreate) {
# A new file at the same path must not inherit the old cached keys.
write_fixture($file, "value=new\n");
ok(main::read_file_cached($requested, \%after), "$label: new read");
is_deeply(\%after, {value => 'new'}, "$label: only new contents");
}
else {
# A successful cached read must not survive removal of its file.
ok(!main::read_file_cached($requested, \%after), "$label: read fails");
is_deeply(\%after, {}, "$label: no deleted contents returned");
}
}
}
# External writers can create a file after Webmin has cached its absence.
my $missing = "$root/missing.conf";
my %data;
ok(!main::read_file_cached($missing, \%data), 'cache a missing file');
write_fixture($missing, "value=temporary\n");
ok(main::unlink_file($missing), 'delete a file previously cached as missing');
write_fixture($missing, "value=recreated\n");
ok(main::read_file_cached($missing, \%data), 'recreated file is readable');
is_deeply(\%data, {value => 'recreated'}, 'old missing-file flag is cleared');
# A failed deletion must still report its error and process later arguments.
my @files = map { "$root/batch-$_.conf" } (1, 2);
foreach my $file (@files) {
write_fixture($file, "value=cached\n");
my %cached;
ok(main::read_file_cached($file, \%cached), 'cache a batch file');
}
my ($ok, $err) = main::unlink_file("$root/absent.conf", @files);
ok(!$ok, 'batch reports a failed deletion');
ok($err, 'batch returns the deletion error');
foreach my $file (@files) {
my %after;
ok(!-e $file, 'batch still deletes later files');
ok(!main::read_file_cached($file, \%after), 'later file cache is invalidated');
}
# Read-only mode must leave both the file and its cached contents available.
my $readonly = "$root/readonly.conf";
write_fixture($readonly, "value=retained\n");
my %before;
ok(main::read_file_cached($readonly, \%before), 'cache the read-only fixture');
{
local $main::readonly_mode_cache = 1;
ok(main::unlink_file($readonly), 'read-only call retains its success return');
}
ok(-e $readonly, 'read-only call does not delete the file');
my %after;
ok(main::read_file_cached($readonly, \%after), 'read-only file remains readable');
is_deeply(\%after, \%before, 'read-only contents remain unchanged');
done_testing();

View File

@@ -5074,6 +5074,7 @@ 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 ||= "";
@@ -5103,6 +5104,9 @@ 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";
}
@@ -5215,6 +5219,7 @@ 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]) {
@@ -5314,11 +5319,17 @@ 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
@@ -5337,6 +5348,17 @@ 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)) {
@@ -5358,7 +5380,8 @@ 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 = ?");
"where id = ? and module = ?".
($m eq '' ? " and attr <> 'api_only'" : ""));
if (!$cmd || !$cmd->execute($id, $m)) {
&disconnect_userdb($userdb, $dbh);
&error(&text('euserdbacl', $dbh->errstr));
@@ -5371,6 +5394,8 @@ 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(
@@ -5381,6 +5406,15 @@ 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") {
@@ -5465,11 +5499,17 @@ 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
@@ -5487,6 +5527,17 @@ 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) {
@@ -5508,7 +5559,8 @@ if ($userdb) {
# Replace ACLs for group
if ($foundindb) {
my $cmd = $dbh->prepare("delete from webmin_group_acl ".
"where id = ? and module = ?");
"where id = ? and module = ?".
($m eq '' ? " and attr <> 'api_only'" : ""));
if (!$cmd || !$cmd->execute($id, $m)) {
&disconnect_userdb($userdb, $dbh);
&error(&text('egroupdbacl', $dbh->errstr));
@@ -5524,6 +5576,8 @@ 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(
@@ -5534,6 +5588,15 @@ 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") {
@@ -5606,6 +5669,41 @@ 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
@@ -6024,6 +6122,28 @@ 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 &&
@@ -8586,13 +8706,6 @@ foreach my $f (@_) {
$rv = 0;
$err = $!;
}
else {
# Cached contents must not outlive the file, or a later
# read_file_cached call in this process returns stale
# data.
delete($main::read_file_cache{$realf});
delete($main::read_file_missing{$realf});
}
}
}
return wantarray ? ($rv, $err) : $rv;
@@ -14359,13 +14472,25 @@ 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 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
# 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.
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