Compare commits

..

1 Commits

Author SHA1 Message Date
Ilia Ross
301722b572 Fix to tighten privileged ACL checks
This PR addresses privately reported issue and tries to restricts root/UID 0 user mutations and Custom Commands definition editing to full, non-safe Webmin admins.

This closes privilege-escalation paths for restricted operators while preserving execution of pre-approved Custom Commands under existing cmds ACLs.
2026-06-28 00:05:21 +02:00
28 changed files with 114 additions and 69 deletions

View File

@@ -1,15 +1,7 @@
## Changelog
#### 2.652 (July, 2026)
* Fix to recognize hex numeric HTML entities to work in various elements
#### 2.651 (June 28, 2026)
* Fix Certbot-backed certificate requests and renewals to correctly parse PEM paths after issuance
* Fix live activation of Linux bond interfaces [#2777](https://github.com/webmin/webmin/pull/2777)
* Update the Authentic theme to the latest version with various improvements and fixes:
- Fix search-result all-items delete in File Manager
- Fix search-result delete ordering in File Manager
- Fix to speed up search-result deletion cleanup in File Manager
#### 2.650 (June 25, 2026)
* Add new Systemd Services and Units module

View File

@@ -7,9 +7,9 @@ my ($cgi) = @_;
my @cust = grep { &can_run_command($_) } &list_commands();
if ($cgi eq 'edit_cmd.cgi') {
# Custom command editor
return 'none' if (!&custom_can_edit_commands());
my ($cmd) = grep { !$_->{'edit'} && !$_->{'sql'} } @cust;
return $cmd ? 'id='.&urlize($cmd->{'id'}) :
$access{'edit'} ? 'new=1' : 'none';
return $cmd ? 'id='.&urlize($cmd->{'id'}) : 'new=1';
}
elsif ($cgi eq 'form.cgi') {
# Custom command form
@@ -18,9 +18,9 @@ elsif ($cgi eq 'form.cgi') {
}
elsif ($cgi eq 'edit_file.cgi') {
# File editor editor
return 'none' if (!&custom_can_edit_commands());
my ($cmd) = grep { $_->{'edit'} } @cust;
return $cmd ? 'id='.&urlize($cmd->{'id'}) :
$access{'edit'} ? 'new=1' : 'none';
return $cmd ? 'id='.&urlize($cmd->{'id'}) : 'new=1';
}
elsif ($cgi eq 'view.cgi') {
# Custom command form
@@ -29,9 +29,9 @@ elsif ($cgi eq 'view.cgi') {
}
elsif ($cgi eq 'edit_sql.cgi') {
# SQL query
return 'none' if (!&custom_can_edit_commands());
my ($cmd) = grep { $_->{'sql'} } @cust;
return $cmd ? 'id='.&urlize($cmd->{'id'}) :
$access{'edit'} ? 'new=1' : 'none';
return $cmd ? 'id='.&urlize($cmd->{'id'}) : 'new=1';
}
elsif ($cgi eq 'sqlform.cgi') {
# SQL query form

View File

@@ -6,6 +6,34 @@ use WebminCore;
&init_config();
%access = &get_module_acl();
# custom_has_full_webmin_access()
# Returns 1 if the current Webmin user has access to all modules.
sub custom_has_full_webmin_access
{
return $custom_full_webmin_access_cache
if (defined($custom_full_webmin_access_cache));
local %acl;
&read_acl(\%acl, undef, [ $base_remote_user ]);
local %global_access = &get_module_acl($base_remote_user, "");
return $custom_full_webmin_access_cache = 0
if ($global_access{'_safe'} || $global_access{'rpc'} == 0);
return $custom_full_webmin_access_cache = 1
if ($acl{$base_remote_user,'*'});
foreach my $m (&get_all_module_infos()) {
next if (!&check_os_support($m));
return $custom_full_webmin_access_cache = 0
if (!$acl{$base_remote_user,$m->{'dir'}});
}
return $custom_full_webmin_access_cache = 1;
}
# custom_can_edit_commands()
# Returns 1 if the current Webmin user can create or edit commands.
sub custom_can_edit_commands
{
return $access{'edit'} && &custom_has_full_webmin_access();
}
# list_commands()
# Returns a list of all custom commands
sub list_commands

View File

@@ -5,7 +5,7 @@
require './custom-lib.pl';
&ReadParse();
$access{'edit'} || &error($text{'edit_ecannot'});
&custom_can_edit_commands() || &error($text{'edit_ecannot'});
if ($in{'new'}) {
&ui_print_header(undef, $text{'create_title'}, "", "create");
if ($in{'clone'}) {

View File

@@ -5,7 +5,7 @@
require './custom-lib.pl';
&ReadParse();
$access{'edit'} || &error($text{'file_ecannot'});
&custom_can_edit_commands() || &error($text{'file_ecannot'});
if ($in{'new'}) {
&ui_print_header(undef, $text{'fcreate_title'}, "", "fcreate");
if ($in{'clone'}) {

View File

@@ -20,7 +20,7 @@ if (!@drivers) {
"../cpan/download.cgi?source=3&cpan=$pgneed&return=/$module_name/&returndesc=".&urlize($text{'index_return'})),"<p>\n";
}
$access{'edit'} || &error($text{'edit_ecannot'});
&custom_can_edit_commands() || &error($text{'edit_ecannot'});
if ($in{'new'}) {
&ui_print_header(undef, $text{'sql_title1'}, "");
if ($in{'clone'}) {

View File

@@ -10,7 +10,7 @@ require './custom-lib.pl';
# Build links
@links = ( );
if ($access{'edit'}) {
if (&custom_can_edit_commands()) {
push(@links,&ui_link("edit_cmd.cgi?new=1",$text{'index_create'}));
push(@links,&ui_link("edit_file.cgi?new=1",$text{'index_ecreate'}));
push(@links,&ui_link("edit_sql.cgi?new=1",$text{'index_screate'}));
@@ -66,7 +66,7 @@ elsif ($config{'display_mode'} == 0) {
$html .= &ui_table_row(&html_escape($a->{'desc'}),
&show_parameter_input($a, $formno));
}
if ($access{'edit'}) {
if (&custom_can_edit_commands()) {
if ($c->{'edit'}) {
$link = &ui_link("edit_file.cgi?id=$c->{'id'}",$text{'index_fedit'});
}
@@ -98,7 +98,7 @@ else {
foreach $c (@cust) {
@cols = ( );
local @links = ( );
if ($access{'edit'}) {
if (&custom_can_edit_commands()) {
local $e = $c->{'edit'} ? "edit_file.cgi" :
$c->{'sql'} ? "edit_sql.cgi" :
"edit_cmd.cgi";

View File

@@ -1,2 +0,0 @@
edit=0
cmds=*

View File

@@ -5,7 +5,7 @@
require './custom-lib.pl';
&ReadParse();
$access{'edit'} || &error($text{'save_ecannot'});
&custom_can_edit_commands() || &error($text{'save_ecannot'});
if ($in{'delete'}) {
$cmd = &get_command($in{'id'}, $in{'idx'});
&delete_command($cmd);

View File

@@ -5,7 +5,7 @@
require './custom-lib.pl';
&ReadParse();
$access{'edit'} || &error($text{'file_ecannot'});
&custom_can_edit_commands() || &error($text{'file_ecannot'});
if ($in{'delete'}) {
$edit = &get_command($in{'id'}, $in{'idx'});
&delete_command($edit);

View File

@@ -4,7 +4,7 @@
require './custom-lib.pl';
&ReadParse();
$access{'edit'} || &error($text{'save_ecannot'});
&custom_can_edit_commands() || &error($text{'save_ecannot'});
if ($in{'delete'}) {
$cmd = &get_command($in{'id'}, $in{'idx'});
&delete_command($cmd);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 B

View File

@@ -1542,25 +1542,16 @@ if ($headerhost) {
$headerhost = undef if (!&check_ipaddress($headerhost) &&
!&check_ip6address($headerhost));
}
# If trusted_proxies is configured, header-supplied client IP is only
# honored when the direct TCP peer is in that list. Proxied SSL client
# cert headers carry authentication identity, so only honor those from
# an explicitly trusted proxy.
my @trusted_proxies = split(/\s+/, $config{'trusted_proxies'} || "");
my $trusted_proxy = @trusted_proxies &&
&ip_match($acptip, $localip, @trusted_proxies);
my $trust_ssl_client_headers = $config{'trust_real_ip'} &&
!$config{'no_trust_ssl'} && $trusted_proxy;
if ($config{'trust_real_ip'} && @trusted_proxies && !$trusted_proxy) {
# If trusted_proxies is configured, header-supplied client IP and SSL
# client info are only honored when the direct TCP peer is in that list.
# Otherwise drop them so an attacker reaching miniserv directly cannot
# spoof X-Forwarded-For or X-SSL-Client-* to bypass auth.
if ($config{'trust_real_ip'} && $config{'trusted_proxies'} ne '' &&
!&ip_match($acptip, $localip,
split(/\s+/, $config{'trusted_proxies'}))) {
print DEBUG "handle_request: peer $acptip not in trusted_proxies; ".
"ignoring forwarding headers\n";
"ignoring forwarding and SSL client headers\n";
$headerhost = undef;
}
if (!$trust_ssl_client_headers) {
print DEBUG "handle_request: ignoring SSL client headers from ".
"peer $acptip\n"
if ($header{'x-ssl-client-dn'} ||
$header{'x-ssl-client-verify'});
delete $header{'x-ssl-client-dn'};
delete $header{'x-ssl-client-verify'};
}

View File

@@ -38,9 +38,6 @@ subtest 'html_escape' => sub {
is(main::html_escape('&amp;'), '&amp;amp;', 'default mode double-escapes &amp;');
is(main::html_escape('&amp;', 1), '&amp;', 'nodblamp preserves existing &amp;');
is(main::html_escape('&#65;', 1), '&#65;', 'nodblamp preserves numeric entity');
is(main::html_escape('&#x25E6;', 1), '&#x25E6;', 'nodblamp preserves hex numeric entity');
is(main::html_escape('&#X25E6;', 1), '&#X25E6;', 'nodblamp preserves uppercase hex numeric entity');
is(main::html_escape('&#xZZ;', 1), '&amp;#xZZ;', 'nodblamp escapes invalid hex numeric entity');
# Note: nodblamp's lookahead matches any &<letters>; as an entity, so
# made-up names like &x; are treated as entities and not re-escaped.
is(main::html_escape('&x;', 1), '&x;', 'nodblamp preserves arbitrary &word; shape');

View File

@@ -52,6 +52,7 @@ $newgid = int($config{'base_gid'} > $access{'lowgid'} ?
# Process the file
&batch_start() if ($in{'batch'});
&lock_user_files();
$full_webmin_access = &useradmin_has_full_webmin_access();
$lnum = $created = $modified = $deleted = 0;
print "<pre>\n";
$pft = &passfiles_type();
@@ -601,6 +602,15 @@ print "</pre>\n";
# Check access control restrictions for a user
sub check_user
{
if (!$full_webmin_access) {
if ($_[0]->{'user'} eq 'root') {
return $text{'usave_eedit'};
}
if ($_[0]->{'uid'} <= 0) {
return &text('usave_elowuid', 1);
}
}
# check if uid is within range
if ($access{'lowuid'} && $_[0]->{'uid'} < $access{'lowuid'}) {
return &text('usave_elowuid', $access{'lowuid'});

View File

@@ -58,6 +58,7 @@ $err = &check_username_restrictions($in{'user'});
&lock_user_files();
@ulist = &list_users();
@glist = &list_groups();
$full_webmin_access = &useradmin_has_full_webmin_access();
if ($in{'old'} ne "") {
# Get old user info
($ouser_hash) = grep { $_->{'user'} eq $in{'old'} } @ulist;
@@ -151,6 +152,12 @@ elsif ( $in{'uid_def'} eq '2' ) {
}
}
if (!$full_webmin_access && $in{'user'} eq 'root') {
&error($text{'usave_eedit'});
}
if (!$full_webmin_access && $in{'uid'} <= 0) {
&error(&text('usave_elowuid', 1));
}
$in{'real'} =~ /^[^:]*$/ || &error(&text('usave_ereal', $in{'real'}));
if ($in{'shell'} eq "*") { $in{'shell'} = $in{'othersh'}; }
if ($access{'shells'} ne "*") {

View File

@@ -30,6 +30,27 @@ do "md5-lib.pl";
@random_password_chars = ( 'a' .. 'z', 'A' .. 'Z', '0' .. '9' );
$disable_string = $config{'lock_prepend'} eq "" ? "!" : $config{'lock_prepend'};
# useradmin_has_full_webmin_access()
# Returns 1 if the current Webmin user has access to all modules.
sub useradmin_has_full_webmin_access
{
return $useradmin_full_webmin_access_cache
if (defined($useradmin_full_webmin_access_cache));
local %acl;
&read_acl(\%acl, undef, [ $base_remote_user ]);
local %global_access = &get_module_acl($base_remote_user, "");
return $useradmin_full_webmin_access_cache = 0
if ($global_access{'_safe'} || $global_access{'rpc'} == 0);
return $useradmin_full_webmin_access_cache = 1
if ($acl{$base_remote_user,'*'});
foreach my $m (&get_all_module_infos()) {
next if (!&check_os_support($m));
return $useradmin_full_webmin_access_cache = 0
if (!$acl{$base_remote_user,$m->{'dir'}});
}
return $useradmin_full_webmin_access_cache = 1;
}
# Search types
$match_modes = [ [ 0, $text{'index_equals'} ], [ 4, $text{'index_contains'} ],
[ 1, $text{'index_matches'} ], [ 2, $text{'index_nequals'} ],
@@ -1004,6 +1025,11 @@ control permissions for this module are in the acl parameter.
=cut
sub can_edit_user
{
if (!&useradmin_has_full_webmin_access() && defined($_[1]->{'user'}) &&
($_[1]->{'user'} eq 'root' ||
(defined($_[1]->{'uid'}) && $_[1]->{'uid'} <= 0))) {
return 0;
}
local $m = $_[0]->{'uedit_mode'};
local %u;
if ($m == 0) { return 1; }

View File

@@ -1 +1 @@
2.651
2.650

View File

@@ -302,7 +302,7 @@ if (!defined $tmp) {
};
# Before escaping ampersand use negative lookahead to see if occurrence
# is not an HTML entity already to prevent double escaping (optionally)
$tmp =~ s/&(?!([a-zA-Z]+|#\d+|#[xX][0-9A-Fa-f]+);)/&amp;/g if ($nodblamp);
$tmp =~ s/&(?!(([a-zA-Z]+)|(#|#x)\d+);)/&amp;/g if ($nodblamp);
# Always escape all ampersands by default
# to make sure they are displayed per se
$tmp =~ s/&/&amp;/g if (!$nodblamp);

View File

@@ -3,13 +3,13 @@ standard_url=URL of standard modules list,3,On webmin.com
third_url=URL of third party modules list,3,On webmin.com
cron_mode=Show update times as,1,0-Simple interface,1-Cron time selector
warn_days=Days before password expiry to warn users,0,5
line2=ACME provider configuration,11
letsencrypt_cmd=Full path to ACME client command,3,Find automatically
letsencrypt_directory_url=Custom ACME directory URL,3,Use default production server
line2=Let's Encrypt configuration,11
letsencrypt_cmd=Full path to Let's Encrypt client command,3,Find automatically
letsencrypt_directory_url=Custom ACME directory URL,3,Use Let's Encrypt production
letsencrypt_eab_kid=External Account Binding key ID,3,None
letsencrypt_eab_hmac=External Account Binding HMAC key,12
letsencrypt_algo=Encryption algorithm for certificate private key,1,rsa-RSA,ecdsa-ECC
letsencrypt_dns_wait=Seconds to wait for ACME DNS propagation,0,5
letsencrypt_before=Command to run before ACME certificate request,0,60
letsencrypt_after=Command to run after ACME certificate request,0,60
letsencrypt_reuse=Re-use existing certificate keys?,1,1-Yes,0-No
letsencrypt_algo=Encryption algorithm for Let's Encrypt private key,1,rsa-RSA,ecdsa-ECC
letsencrypt_dns_wait=Seconds to wait for Let's Encrypt DNS propagation,0,5
letsencrypt_before=Command to run before Let's Encrypt request,0,60
letsencrypt_after=Command to run after Let's Encrypt request,0,60
letsencrypt_reuse=Re-use existing Let's Encrypt keys?,1,1-Yes,0-No

View File

@@ -2,4 +2,4 @@ standard_url=URL de la llista de mòduls estàndard,3,A webmin.com
third_url=URL de la llista de mòduls de tercers,3,A webmin.com
cron_mode=Mostra els temps d'actualització com,1,0-Interfície simple,1-Selector de temps cron
warn_days=Dies abans de l'expiració de la contrasenya per avisar els usuaris,0,5
letsencrypt_cmd=Camí complet de l'ordre client ACME,3,Troba'l automàticament
letsencrypt_cmd=Camí complet de l'ordre client de Let's Encrypt,3,Troba'l automàticament

View File

@@ -2,4 +2,4 @@ standard_url=URL der Standardmodul&#45;Liste,3,Von webmin.com
third_url=URL der Drittanbieter&#45;Webmin&#45;Modulliste,3,Von webmin.com
cron_mode=Zeige Aktualisierungszeiten als,1,0-Einfache Anzeige,1-Cron&#45;Zeit&#45;Auswahl
warn_days=Tage vor Ablauf des Passworts zu warnen an Benutzer,0,5
letsencrypt_cmd=Voller Pfad zum ACME-Client&#45;Befehl,0
letsencrypt_cmd=Voller Pfad zum Let's Encrypt Client&#45;Befehl,0

View File

@@ -3,6 +3,6 @@ standard_url=URL de la liste des modules standard,3,webmin.com
third_url=URL de la liste des modules non-standard,3,webmin.com
cron_mode=Afficher les heures de mise à jour comme,1,0-Interface simple,1-Sélecteur de temps Cron
warn_days=Jours avant l'expiration du mot de passe pour avertir les utilisateurs,0,5
line2=Configuration du fournisseur ACME,11
letsencrypt_cmd=Chemin complet de la commande client ACME,3,Trouver automatiquement
letsencrypt_dns_wait=Quelques secondes à attendre pour la propagation DNS ACME,0,5
line2=Let's Encrypt configuration,11
letsencrypt_cmd=Chemin complet de la commande client Let's Encrypt,3,Trouver automatiquement
letsencrypt_dns_wait=Quelques secondes à attendre pour la propagation DNS de Let's Encrypt,0,5

View File

@@ -3,6 +3,6 @@ standard_url=標準モジュールリストに使用する URL,3,webmin.com
third_url=サードパーティ製モジュールリストに使用する URL,3,webmin.com
cron_mode=アップデート時刻の表示方法,1,0-シンプル,1-Cron 選択方式
warn_days=ユーザのパスワード期限切れの通知(日前),0,5
line2=ACME プロバイダーの設定,11
letsencrypt_cmd=ACME クライアントのパス,3,自動設定
letsencrypt_dns_wait=ACME DNS の更新待機時間(秒),0,5
line2=Let's Encrypt の設定,11
letsencrypt_cmd=Let's Encrypt クライアントのパス,3,自動設定
letsencrypt_dns_wait=Let's Encrypt DNS の更新待機時間(秒),0,5

View File

@@ -60,12 +60,8 @@ if (!-r $first_install_file || $miniserv{'login_script'} eq $record_login_cmd) {
$miniserv{'failed_script'} = $record_failed_cmd;
}
# Disable trusting SSL certs unless already enabled. Legacy configs with
# trust_real_ip but no trusted proxy cannot safely authenticate from
# proxied SSL client cert headers.
my @trusted_proxies = split(/\s+/, $miniserv{'trusted_proxies'} || "");
if ((!$miniserv{'trust_real_ip'} || !@trusted_proxies) &&
!defined($miniserv{'no_trust_ssl'})) {
# Disable trusting SSL certs unless already enabled
if (!$miniserv{'trust_real_ip'} && !defined($miniserv{'no_trust_ssl'})) {
$miniserv{'no_trust_ssl'} = 1;
}