Compare commits

..

17 Commits

Author SHA1 Message Date
Jamie Cameron
01b988dbf1 Add missing escaping in shorewall modules
Some checks are pending
Tests / prove (push) Waiting to run
Package and upload artifacts / build (push) Waiting to run
https://github.com/webmin/webmin/issues/2908
2026-11-01 16:45:18 -08:00
Ilia Ross
3f8a6afb3e 2.671
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
2026-11-01 18:34:42 +01:00
Ilia Ross
c2019db310 Update tests
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
2026-10-31 18:59:43 +01:00
Ilia Ross
22ac790256 Fix HTML escaping in user module validation errors 2026-10-31 18:59:38 +01:00
Ilia Ross
e01f104fcb Fix HTML escaping in process signal errors 2026-10-31 18:59:19 +01:00
Ilia Ross
e3f36c5d39 Update tests 2026-10-31 18:39:30 +01:00
Ilia Ross
053a1b28a7 Fix preservation of restricted group modules 2026-10-31 18:39:22 +01:00
Ilia Ross
47d0ac01b0 Update tests 2026-10-31 18:04:27 +01:00
Ilia Ross
9924545759 Fix process output escaping 2026-10-31 18:04:20 +01:00
Ilia Ross
8fb194e5fb Fix HTML escaping in certificate issuance responses 2026-10-31 18:02:00 +01:00
Ilia Ross
be54ba38c3 Fix group module validation before saving membership 2026-10-31 18:00:46 +01:00
Jamie Cameron
c861e308f4 Apply same module check when saving groups as is done for users 2026-10-31 09:19:39 -07:00
Jamie Cameron
c08b60c395 Add missing escaping of input 2026-10-31 09:09:24 -07:00
Jamie Cameron
0c449e3417 Disallow invalid characters in cert fields 2026-10-31 09:06:22 -07:00
Jamie Cameron
f0c3ba1d3c Only include security updates
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
https://github.com/webmin/webmin/issues/2906
2026-10-30 21:05:06 -07:00
Ilia Ross
99dc3b6281 Add plain text size handling for dropdown labels in device input 2026-10-31 01:30:01 +01:00
Ilia Ross
d8347b2928 Fix French navigation translations
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
2026-10-29 21:08:11 +01:00
23 changed files with 335 additions and 250 deletions

View File

@@ -22,6 +22,7 @@ my $fh = "IN";
&open_tempfile($fh, ">$temp1"); &open_tempfile($fh, ">$temp1");
foreach my $k ("emailAddress", "organizationalUnitName", "organizationName", foreach my $k ("emailAddress", "organizationalUnitName", "organizationName",
"stateOrProvinceName", "countryName", "commonName") { "stateOrProvinceName", "countryName", "commonName") {
$in{$k} =~ /:|\n|\0/ && &error($text{'cert_echars'});
&print_tempfile($fh, "$k = $in{$k}\n"); &print_tempfile($fh, "$k = $in{$k}\n");
} }
$in{'key'} =~ s/\s//g; $in{'key'} =~ s/\s//g;
@@ -31,12 +32,12 @@ my $cmd = &get_ssleay();
my $ssleay = &backquote_logged("$cmd ca -spkac $temp1 -out $temp2 -config $module_config_directory/openssl.cnf -days 1095 2>&1"); my $ssleay = &backquote_logged("$cmd ca -spkac $temp1 -out $temp2 -config $module_config_directory/openssl.cnf -days 1095 2>&1");
&unlink_file($temp1); &unlink_file($temp1);
if ($?) { if ($?) {
&error("<pre>$ssleay</pre>"); &error("<pre>".&html_escape($ssleay)."</pre>");
} }
else { else {
# Display status and redirect to actual cert file # Display status and redirect to actual cert file
&ui_print_unbuffered_header(undef, $text{'cert_title'}, ""); &ui_print_unbuffered_header(undef, $text{'cert_title'}, "");
print &text('cert_done', $in{'commonName'}),"<p>\n"; print &text('cert_done', &html_escape($in{'commonName'})),"<p>\n";
print &text('cert_pickup', "cert_output.cgi?file=$temp2"),"<p>\n"; print &text('cert_pickup', "cert_output.cgi?file=$temp2"),"<p>\n";
&ui_print_footer("", $text{'index_return'}); &ui_print_footer("", $text{'index_return'});

View File

@@ -7,7 +7,7 @@ use warnings;
no warnings 'redefine'; no warnings 'redefine';
no warnings 'uninitialized'; no warnings 'uninitialized';
require './acl-lib.pl'; ## no critic require './acl-lib.pl'; ## no critic
our (%in, %text, %config, %access, $config_directory); our (%in, %text, %config, %access, $config_directory, $base_remote_user);
&ReadParse(); &ReadParse();
$access{'groups'} || &error($text{'gedit_ecannot'}); $access{'groups'} || &error($text{'gedit_ecannot'});
my $g; my $g;
@@ -95,6 +95,12 @@ print &ui_hidden_table_start($text{'edit_mods'}, "width=100%", 2, "mods");
# Show available modules, under categories # Show available modules, under categories
my @mlist = &list_module_infos(); my @mlist = &list_module_infos();
my %has = map { $_, 1 } @{$group{'modules'}}; my %has = map { $_, 1 } @{$group{'modules'}};
# Disable modules that this administrator cannot grant or remove.
my $me = $access{'mode'} == 1 ? &get_user($base_remote_user) : undef;
@mcan = $access{'mode'} == 1 ? @{$me->{'modules'} || []} :
$access{'mode'} == 2 ? split(/\s+/, $access{'mods'}) :
map { $_->{'dir'} } @mlist;
my %mcan = map { $_, 1 } @mcan;
my @links = ( &select_all_link("mod", 0, $text{'edit_selall'}), my @links = ( &select_all_link("mod", 0, $text{'edit_selall'}),
&select_invert_link("mod", 0, $text{'edit_invert'}) ); &select_invert_link("mod", 0, $text{'edit_invert'}) );
my @cats = &unique(map { $_->{'category'} || "" } @mlist); my @cats = &unique(map { $_->{'category'} || "" } @mlist);
@@ -119,7 +125,8 @@ foreach my $c (sort { $b cmp $a } @cats) {
else { else {
$label = $m->{'desc'}; $label = $m->{'desc'};
} }
push(@grid, &ui_checkbox("mod", $md, $label,$has{$md})); push(@grid, &ui_checkbox("mod", $md, $label, $has{$md},
undef, !$mcan{$md}));
} }
$grids .= &ui_grid_table(\@grid, 2, 100, [ "width=50%", "width=50%" ]); $grids .= &ui_grid_table(\@grid, 2, 100, [ "width=50%", "width=50%" ]);
} }

View File

@@ -175,6 +175,7 @@ cert_ekey=A new SSL key was not submitted by your browser - maybe it does not su
cert_eca=Failed to setup certificate authority : $1 cert_eca=Failed to setup certificate authority : $1
cert_already=Warning - you are already using the certificate $1. cert_already=Warning - you are already using the certificate $1.
cert_etempdir=Invalid certificate file cert_etempdir=Invalid certificate file
cert_echars=Invalid characters in cert field
acl_title=Module Access Control acl_title=Module Access Control
acl_title2=For $1 in $2 acl_title2=For $1 in $2

View File

@@ -7,7 +7,7 @@ use warnings;
no warnings 'redefine'; no warnings 'redefine';
no warnings 'uninitialized'; no warnings 'uninitialized';
require './acl-lib.pl'; ## no critic require './acl-lib.pl'; ## no critic
our (%in, %text, %config, %access, $config_directory); our (%in, %text, %config, %access, $config_directory, $base_remote_user);
&ReadParse(); &ReadParse();
$access{'groups'} || &error($text{'gedit_ecannot'}); $access{'groups'} || &error($text{'gedit_ecannot'});
@@ -21,6 +21,10 @@ elsif ($in{'but_delete'}) {
exit; exit;
} }
# Find logged-in webmin user
my @ulist = &list_users();
my ($me) = grep { $_->{'name'} eq $base_remote_user } @ulist;
my (%group, $old); my (%group, $old);
if ($in{'old'}) { if ($in{'old'}) {
# Get the original group # Get the original group
@@ -42,6 +46,21 @@ if (!$in{'old'} || $in{'old'} ne $in{'name'}) {
} }
$in{'desc'} !~ /:/ || &error($text{'gsave_edesc'}); $in{'desc'} !~ /:/ || &error($text{'gsave_edesc'});
# Check grantable modules before changing parent group membership.
$access{'mode'} == 1 && !$me && &error($text{'edit_egone'});
my @mcan = $access{'mode'} == 1 ? @{$me->{'modules'} || []} :
$access{'mode'} == 2 ? split(/\s+/, $access{'mods'}) :
&list_modules();
my %mcan = map { $_, 1 } @mcan;
# Keep existing modules that this administrator cannot change.
my %keep = map { $_, 1 } grep { !$mcan{$_} }
($old ? @{$old->{'modules'}} : ());
my @mods = split(/\0/, $in{'mod'});
foreach my $m (@mods) {
$mcan{$m} || $keep{$m} || &error(&text('save_emod', &html_escape($m)));
}
@mods = &unique(@mods, keys %keep);
# Find the current parent group # Find the current parent group
my $oldgroup; my $oldgroup;
if ($in{'old'}) { if ($in{'old'}) {
@@ -80,9 +99,6 @@ if (defined($in{'group'})) {
} }
} }
# Work out group modules
my @mods = split(/\0/, $in{'mod'});
if ($oldgroup) { if ($oldgroup) {
# Remove modules from the old parent group # Remove modules from the old parent group
@mods = grep { &indexof($_, @{$oldgroup->{'modules'}}) < 0 } @mods; @mods = grep { &indexof($_, @{$oldgroup->{'modules'}}) < 0 } @mods;

View File

@@ -103,12 +103,7 @@ if ($in{'temp'}) {
# Find logged-in webmin user # Find logged-in webmin user
my @ulist = &list_users(); my @ulist = &list_users();
my $me; my ($me) = grep { $_->{'name'} eq $base_remote_user } @ulist;
foreach my $u (@ulist) {
if ($u->{'name'} eq $base_remote_user) {
$me = $u;
}
}
# Find the current group # Find the current group
my $oldgroup = $in{'old'} ? &get_users_group($in{'old'}) : undef; my $oldgroup = $in{'old'} ? &get_users_group($in{'old'}) : undef;
@@ -158,7 +153,7 @@ my %mcan = map { $_, 1 } @mcan;
my @mods = split(/\0/, $in{'mod'}); my @mods = split(/\0/, $in{'mod'});
foreach my $m (@mods) { foreach my $m (@mods) {
$mcan{$m} || &error(&text('save_emod', $m)); $mcan{$m} || &error(&text('save_emod', &html_escape($m)));
} }
if ($in{'old'}) { if ($in{'old'}) {
# Add modules that this user already has, but were not # Add modules that this user already has, but were not

View File

@@ -1474,6 +1474,25 @@ subtest 'save_user.cgi create privilege' => sub {
delete_user('admin'); delete_user('admin');
}; };
subtest 'save_user.cgi rejected module escaping' => sub {
_reset_fixture();
_seed_user_acl('admin', { create => 1, mode => 2, mods => 'proc' });
create_user({ name => 'admin', pass => 'x', modules => ['acl', 'proc'] });
# Ordinary and hostile module names must remain text in the error page.
for my $module ('cron', '<script>alert(1)</script>') {
my $r = run_cgi('save_user.cgi', {
name => 'newbie', pass_def => 1, mod => $module,
logouttime_def => 1, minsize_def => 1 });
my $expected = text('save_emod', html_escape($module));
like($r->{out}, qr/\Q$expected\E/, 'rejected module is escaped');
unlike($r->{out}, qr/<script>alert\(1\)<\/script>/,
'module error cannot inject a script');
_clear_caches();
ok(!get_user('newbie'), 'rejected module does not create an account');
}
};
# Unix conversion must not bypass the privileged-name guard in save_user.cgi. # Unix conversion must not bypass the privileged-name guard in save_user.cgi.
# An ACL-capable administrator can still deliberately convert such a user. # An ACL-capable administrator can still deliberately convert such a user.
subtest 'convert.cgi privileged username gate' => sub { subtest 'convert.cgi privileged username gate' => sub {
@@ -1650,6 +1669,125 @@ subtest 'save_group.cgi name validation' => sub {
ok(!$g, 'group with colon-bearing description not created'); ok(!$g, 'group with colon-bearing description not created');
}; };
# Group module limits must reject requests before any membership is changed.
subtest 'save_group.cgi module limits' => sub {
_reset_fixture();
create_user({ name => 'admin', pass => 'x', modules => ['acl', 'proc'] });
create_group({ name => 'parent', members => [], modules => ['proc'] });
_seed_user_acl('admin', { mode => 2, mods => 'proc', groups => 1,
gassign => '*' });
my $r = run_cgi('save_group.cgi', {
name => 'child', desc => 'd', group => 'parent', mod => ['proc', 'cron'] });
like($r->{out}, qr/You cannot grant access to module 'cron'/,
'explicit module limit rejects an unauthorized grant');
_clear_caches();
ok(!get_group('child'), 'rejected group is not created');
is_deeply(get_group('parent')->{members}, [],
'rejected save does not change parent membership');
$r = run_cgi('save_group.cgi', {
name => 'child', desc => 'd', group => 'parent', mod => 'proc' });
is($r->{status}, 0, 'allowed group creation exits successfully');
_clear_caches();
is_deeply(get_group('child')->{modules}, ['proc'], 'allowed module is saved');
is_deeply(get_group('parent')->{members}, ['@child'],
'successful save updates parent membership');
$r = run_cgi('save_group.cgi', {
old => 'child', name => 'child', desc => 'changed', group => '', mod => 'cron' });
like($r->{out}, qr/You cannot grant access to module 'cron'/,
'editing an existing group cannot grant an unauthorized module');
_clear_caches();
is_deeply(get_group('parent')->{members}, ['@child'],
'rejected edit does not detach the existing group');
my $payload = '<script>alert(1)</script>';
$r = run_cgi('save_group.cgi', { name => 'badmod', mod => $payload });
like($r->{out}, qr/&lt;script&gt;/, 'rejected module name is escaped');
unlike($r->{out}, qr/\Q$payload\E/, 'module error cannot inject HTML');
# Mode 1 uses the effective user's modules, including mapped logins.
_seed_user_acl('admin', { mode => 1, groups => 1 });
$r = run_cgi('save_group.cgi', { name => 'ownmods', mod => 'proc' },
user => 'mapped-user', env => { BASE_REMOTE_USER => 'admin' });
_clear_caches();
is_deeply(get_group('ownmods')->{modules}, ['proc'],
'mapped login uses the base user module list');
$r = run_cgi('save_group.cgi', { name => 'denied', mod => 'cron' });
like($r->{out}, qr/You cannot grant access to module 'cron'/,
'mode 1 rejects modules the base user does not hold');
delete_user('admin');
_seed_user_acl('admin', { mode => 1, groups => 1 });
$r = run_cgi('save_group.cgi', { name => 'missing', mod => 'proc' });
like($r->{out}, qr/\Q$text{'edit_egone'}\E/, 'missing user returns a normal error');
unlike($r->{err}, qr/undefined value|compilation errors/,
'missing user does not cause a Perl exception');
_clear_caches();
ok(!get_group('missing'), 'missing user cannot create a group');
# Unrestricted mode does not require the mode-1 user record.
_seed_user_acl('admin', { mode => 0, groups => 1 });
$r = run_cgi('save_group.cgi', { name => 'unrestricted', mod => 'cron' });
_clear_caches();
is_deeply(get_group('unrestricted')->{modules}, ['cron'],
'unrestricted module grants remain supported');
};
# Editing unrelated fields must retain modules outside the admin's grant list.
subtest 'existing group module preservation' => sub {
for my $mode (1, 2, 0) {
_reset_fixture();
create_user({ name => 'admin', pass => 'x', modules => ['acl', 'proc'] });
create_user({ name => 'member', pass => 'x', modules => ['proc', 'cron'] });
create_group({ name => 'existing', members => ['member'],
modules => ['proc', 'cron'], desc => 'before' });
_seed_user_acl('admin', { mode => $mode, mods => 'proc', groups => 1 });
my $r = run_cgi('edit_group.cgi', { group => 'existing' });
my ($checkbox) = $r->{out} =~ /(<input\b[^>]*value="cron"[^>]*>)/;
ok($checkbox && $checkbox =~ /checked/, "mode $mode shows existing cron access");
is($checkbox && $checkbox =~ /disabled/ ? 1 : 0, $mode ? 1 : 0,
"mode $mode disables only restricted modules");
# Disabled controls are omitted by the browser.
$r = run_cgi('save_group.cgi', {
old => 'existing', name => 'existing', desc => 'after', mod => 'proc' });
_clear_caches();
my $group = get_group('existing');
is($group->{desc}, 'after', "mode $mode permits description edits");
my @expected = $mode ? qw(cron proc) : qw(proc);
is_deeply([sort @{$group->{modules}}], \@expected,
"mode $mode preserves restricted modules but allows authorized removal");
is_deeply([sort @{get_user('member')->{modules}}], \@expected,
"mode $mode preserves the correct member access");
next if !$mode;
# An old form can still submit the existing restricted selection.
$r = run_cgi('save_group.cgi', {
old => 'existing', name => 'existing', desc => 'old form',
mod => ['proc', 'cron'] });
_clear_caches();
is(get_group('existing')->{desc}, 'old form',
"mode $mode accepts an unchanged restricted selection");
$r = run_cgi('save_group.cgi', {
old => 'existing', name => 'existing', desc => 'forged',
mod => ['proc', 'cron', 'webmin'] });
like($r->{out}, qr/You cannot grant access to module 'webmin'/,
"mode $mode rejects a new unauthorized module");
_clear_caches();
is(get_group('existing')->{desc}, 'old form',
"mode $mode rejects before writing changes");
$r = run_cgi('save_group.cgi', {
name => 'cloned', clone => 'existing', mod => ['proc', 'cron'] });
like($r->{out}, qr/You cannot grant access to module 'cron'/,
"mode $mode cannot grant restricted modules by cloning");
}
};
subtest 'save_user.cgi self-lockout guard' => sub { subtest 'save_user.cgi self-lockout guard' => sub {
# Editing yourself out of the acl module is refused — otherwise an # Editing yourself out of the acl module is refused — otherwise an
# admin could lock themselves out of Webmin entirely. # admin could lock themselves out of Webmin entirely.

View File

@@ -0,0 +1 @@
desc_fr=Gestionnaire de Fichiers

View File

@@ -20,6 +20,7 @@ category_net=Réseau
category_syslet=Syslets category_syslet=Syslets
category_cluster=Cluster category_cluster=Cluster
category_info=Information category_info=Information
category_=Outils
longcategory_servers=Modules pour les configurations web, email, FTP et autres serveurs longcategory_servers=Modules pour les configurations web, email, FTP et autres serveurs
longcategory_hardware=Modules pour les imprimantes, disques et autres configurations de matériel longcategory_hardware=Modules pour les imprimantes, disques et autres configurations de matériel

View File

@@ -904,6 +904,9 @@ sub device_input
{ {
local (%used, $vg, $pv, $d, $p); local (%used, $vg, $pv, $d, $p);
# Dropdown labels need plain text sizes without theme HTML.
local $main::theme_forbid_nice_size = 1;
# Find partitions that are part of an LVM # Find partitions that are part of an LVM
foreach $vg (&list_volume_groups()) { foreach $vg (&list_volume_groups()) {
foreach $pv (&list_physical_volumes($vg->{'name'})) { foreach $pv (&list_physical_volumes($vg->{'name'})) {

View File

@@ -354,32 +354,13 @@ if ($iface->{'virtual'} ne '') {
&save_interface($parent, $boot); &save_interface($parent, $boot);
} }
else { else {
# Find current lines because earlier deletions may have shifted them. # Delete all the lines
&lock_file($iface->{'file'}); &lock_file($iface->{'file'});
&unflush_file_lines($iface->{'file'});
my $yaml = &read_yaml_file($iface->{'file'});
my @network = grep { $_->{'name'} eq 'network' } @$yaml;
my @groups = grep { $_->{'name'} eq 'ethernets' ||
$_->{'name'} eq 'bridges' }
map { @{$_->{'members'}} } @network;
my $entry;
foreach my $g (@groups) {
my ($e) = grep { $_->{'name'} eq $iface->{'fullname'} }
@{$g->{'members'}};
next if (!$e);
# Remove the group as well when deleting its last interface.
$entry = @{$g->{'members'}} == 1 ? $g : $e;
last;
}
$entry || &error("No interface named $iface->{'fullname'} exists");
# Delete the interface without changing neighboring definitions.
my $lref = &read_file_lines($iface->{'file'}); my $lref = &read_file_lines($iface->{'file'});
splice(@$lref, $entry->{'line'}, splice(@$lref, $iface->{'line'},
$entry->{'eline'} - $entry->{'line'} + 1); $iface->{'eline'} - $iface->{'line'} + 1);
&flush_file_lines($iface->{'file'}); &flush_file_lines($iface->{'file'});
if (&is_yaml_empty($iface->{'file'})) { if (&is_yaml_empty($iface->{'file'})) {
# Discard the file only when no settings or interfaces remain.
&unlink_file($iface->{'file'}); &unlink_file($iface->{'file'});
} }
&unlock_file($iface->{'file'}); &unlock_file($iface->{'file'});
@@ -387,22 +368,22 @@ else {
} }
# is_yaml_empty(file) # is_yaml_empty(file)
# Returns 1 if only the version and empty Ethernet or bridge groups remain # Return 1 if a YAML file contains only network and ethernets line, with no
# other interfaces
sub is_yaml_empty sub is_yaml_empty
{ {
my ($file) = @_; my ($file) = @_;
my $yaml = &read_yaml_file($file); my $yaml = &read_yaml_file($file);
return 1 if (!$yaml); return 1 if (!$yaml);
my @rest = grep { $_->{'name'} ne 'network' } @$yaml;
return 0 if (@rest);
foreach my $n (@$yaml) { foreach my $n (@$yaml) {
# Preserve unknown directives and inline network settings. my @rest = grep { $_->{'name'} ne 'ethernets' &&
return 0 if ($n->{'name'} ne 'network' || $_->{'name'} ne 'bridges' }
($n->{'value'} ne '' && $n->{'value'} ne '{}')); @{$network->{'members'}};
# Only the version and empty device groups are safe to discard. return 0 if (@rest);
foreach my $ens (@{$n->{'members'}}) { foreach my $ens (@{$network->{'members'}}) {
next if ($ens->{'name'} eq 'version'); return 0 if (@{$ens->{'members'}});
return 0 if (($ens->{'name'} ne 'ethernets' &&
$ens->{'name'} ne 'bridges') || @{$ens->{'members'}} ||
($ens->{'value'} ne '' && $ens->{'value'} ne '{}'));
} }
} }
return 1; return 1;

View File

@@ -5,7 +5,7 @@
require './proc-lib.pl'; require './proc-lib.pl';
&ReadParse(); &ReadParse();
&switch_acl_uid(); &switch_acl_uid();
&error_setup(&text('kill_err', $in{signal}, $in{pid})); &error_setup(&text('kill_err', &html_escape($in{signal}), &html_escape($in{pid})));
foreach $s ('KILL', 'TERM', 'HUP', 'STOP', 'CONT') { foreach $s ('KILL', 'TERM', 'HUP', 'STOP', 'CONT') {
$in{'signal'} = $s if ($in{$s}); $in{'signal'} = $s if ($in{$s});
} }

View File

@@ -14,10 +14,10 @@ foreach $s ('KILL', 'TERM', 'HUP', 'STOP', 'CONT') {
@pinfo = &list_processes(@pidlist); @pinfo = &list_processes(@pidlist);
for($i=0; $i<@pidlist; $i++) { for($i=0; $i<@pidlist; $i++) {
$in{"args$i"} = $pinfo[$i]->{'args'}; $in{"args$i"} = $pinfo[$i]->{'args'};
print "$text{'pid'} <tt>$pidlist[$i]</tt> ... \n"; print "$text{'pid'} <tt>".&html_escape($pidlist[$i])."</tt> ... \n";
if (&can_edit_process($pinfo[$i]->{'user'})) { if (&can_edit_process($pinfo[$i]->{'user'})) {
if (&kill_logged($in{signal}, $pidlist[$i])) { if (&kill_logged($in{signal}, $pidlist[$i])) {
print "SIG$in{signal} $text{'kill_sent'}<br>\n"; print "SIG".&html_escape($in{'signal'})." $text{'kill_sent'}<br>\n";
} }
else { else {
print "$!<br>\n"; print "$!<br>\n";
@@ -29,5 +29,6 @@ for($i=0; $i<@pidlist; $i++) {
} }
&webmin_log("kill", undef, undef, \%in); &webmin_log("kill", undef, undef, \%in);
print "<p>\n"; print "<p>\n";
&ui_print_footer("index_search.cgi?$in{'args'}", $text{'search_return'}); &ui_print_footer("index_search.cgi?".&html_escape($in{'args'}),
$text{'search_return'});

View File

@@ -435,6 +435,7 @@ comment_header=Table comment details
comment_msg=Message comment_msg=Message
comment_err=Failed to save comment comment_err=Failed to save comment
comment_enone=No message entered comment_enone=No message entered
comment_ereturn=Invalid message entered
log_create_table=Added entry to the $1 table log_create_table=Added entry to the $1 table
log_modify_table=Modified entry in the $1 table log_modify_table=Modified entry in the $1 table

View File

@@ -59,7 +59,7 @@ if (@table) {
push(@cols, &ui_link( push(@cols, &ui_link(
"editcmt.cgi?table=".&urlize($in{'table'}). "editcmt.cgi?table=".&urlize($in{'table'}).
"&idx=".&urlize($i), "&idx=".&urlize($i),
"<i>".join(" ", @t[1..$#t])."</i>")); "<i>".&html_escape(join(" ",@t[1..$#t]))."</i>"));
@tds = ( "width=5", "colspan=".scalar(@colnames) ); @tds = ( "width=5", "colspan=".scalar(@colnames) );
} }
else { else {
@@ -70,11 +70,12 @@ if (@table) {
for($j=0; $j<@colnames; $j++) { for($j=0; $j<@colnames; $j++) {
if ($j == 0) { if ($j == 0) {
$lnk = &ui_link( $lnk = &ui_link(
"edit.cgi?table=".&urlize($in{'table'}). "edit.cgi?table=".&urlize($in{'table'}).
"&idx=".&urlize($i), $t[$j]); "&idx=".&urlize($i),
&html_escape($t[$j]));
} }
else { else {
$lnk = $t[$j]; $lnk = &html_escape($t[$j]);
} }
push(@cols, $lnk); push(@cols, $lnk);
} }

View File

@@ -15,6 +15,7 @@ if ($in{'delete'}) {
else { else {
# Validate inputs # Validate inputs
$in{'msg'} =~ /\S/ || &error($text{'comment_enone'}); $in{'msg'} =~ /\S/ || &error($text{'comment_enone'});
$in{'msg'} =~ /\r|\0/ && &error($text{'comment_ereturn'});
$line = (&version_atleast(4, 5, 11) ? "?" : "") . "COMMENT $in{'msg'}"; $line = (&version_atleast(4, 5, 11) ? "?" : "") . "COMMENT $in{'msg'}";
# Update or add # Update or add

View File

@@ -421,6 +421,7 @@ comment_header=Table comment details
comment_msg=Message comment_msg=Message
comment_err=Failed to save comment comment_err=Failed to save comment
comment_enone=No message entered comment_enone=No message entered
comment_ereturn=Invalid message entered
log_create_table=Added entry to the $1 table log_create_table=Added entry to the $1 table
log_modify_table=Modified entry in the $1 table log_modify_table=Modified entry in the $1 table

View File

@@ -57,8 +57,10 @@ if (@table) {
local @tds; local @tds;
if ($t[0] =~ /\??COMMENT/) { if ($t[0] =~ /\??COMMENT/) {
# Special case - a comment line # Special case - a comment line
push(@cols, "<a href='editcmt.cgi?table=$in{'table'}&". push(@cols, "<a href='editcmt.cgi?table=".
"idx=$i'><i>".join(" ", @t[1..$#t]). &urlize($in{'table'})."&idx=".
&urlize($i)."'><i>".
&html_escape(join(" ", @t[1..$#t])).
"</i></a>" ); "</i></a>" );
@tds = ( "width=5", "colspan=".scalar(@colnames) ); @tds = ( "width=5", "colspan=".scalar(@colnames) );
} }
@@ -69,10 +71,13 @@ if (@table) {
} }
for($j=0; $j<@colnames; $j++) { for($j=0; $j<@colnames; $j++) {
if ($j == 0) { if ($j == 0) {
$lnk = &ui_link("edit.cgi?table=$in{'table'}&idx=$i",$t[$j]); $lnk = &ui_link(
"edit.cgi?table=".&urlize($in{'table'}).
"&idx=".&urlize($i),
&html_escape($t[$j]));
} }
else { else {
$lnk = $t[$j]; $lnk = &html_escape($t[$j]);
} }
push(@cols, $lnk); push(@cols, $lnk);
} }

View File

@@ -15,6 +15,7 @@ if ($in{'delete'}) {
else { else {
# Validate inputs # Validate inputs
$in{'msg'} =~ /\S/ || &error($text{'comment_enone'}); $in{'msg'} =~ /\S/ || &error($text{'comment_enone'});
$in{'msg'} =~ /\r|\0/ && &error($text{'comment_ereturn'});
$line = (&version_atleast(4, 5, 11) ? "?" : "") . "COMMENT $in{'msg'}"; $line = (&version_atleast(4, 5, 11) ? "?" : "") . "COMMENT $in{'msg'}";
# Update or add # Update or add

View File

@@ -523,8 +523,8 @@ close(PKG);
while(<PKG>) { while(<PKG>) {
s/\r|\n//g; s/\r|\n//g;
next if (/^(Loaded|updateinfo)/); next if (/^(Loaded|updateinfo)/);
if (/^\S+\s+\S+\s+(\S+?)\-([0-9]\S+)\.([^\.]+)$/) { if (/^\S+\s+(\S*[Ss]ec\S*)\s+(\S+?)\-([0-9]\S+)\.([^\.]+)$/) {
local ($name, $ver) = ($1, $2); local ($name, $ver) = ($2, $3);
if ($done->{$name}) { if ($done->{$name}) {
$done->{$name}->{'source'} ||= 'security'; $done->{$name}->{'source'} ||= 'security';
$done->{$name}->{'security'} = 1; $done->{$name}->{'security'} = 1;

118
t/cert-proc-escaping.t Normal file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/perl
# Exercise CGI escaping without issuing certificates or signaling processes.
use strict;
use warnings;
no warnings qw(once redefine uninitialized);
use Test::More;
use FindBin;
my $root = "$FindBin::Bin/..";
require "$root/web-lib-funcs.pl";
require "$root/ui-lib.pl";
$INC{'./acl-lib.pl'} = $INC{'./proc-lib.pl'} = 1;
*main::ReadParse = *main::switch_acl_uid = *main::webmin_log = sub { };
*main::error_setup = *main::get_miniserv_config = sub { };
*main::error = sub { die $_[0]."\n" };
*main::get_module_variable = sub { return \%main::text };
*main::ui_print_unbuffered_header = sub { };
*main::load_theme_library = sub { };
*main::get_module_name = sub { 'proc' };
*main::get_module_info = sub { return () };
*main::get_webprefix = sub { '' };
for my $file ('lang/en', 'acl/lang/en', 'proc/lang/en') {
open(my $fh, '<', "$root/$file") or die $!;
while (<$fh>) {
$main::text{$1} = $2 if /^(\S+)=(.*)$/;
}
}
# render_cgi(file, inputs) captures the real CGI and UI helpers.
sub render_cgi
{
my ($file, $inputs) = @_;
local %main::in = %$inputs;
my $html = '';
open(my $out, '>', \$html) or die $!;
local *STDOUT = $out;
do "$root/$file";
return ($html, $@);
}
subtest 'certificate success and error output' => sub {
# Stub only certificate I/O and account writes; preserve submitted values.
my ($saved, $openssl_error);
local *main::transname = local *main::tempname = sub { '/fixture/cert' };
local *main::open_tempfile = local *main::print_tempfile =
local *main::close_tempfile = local *main::unlink_file = sub { };
local *main::get_ssleay = sub { 'openssl' };
local *main::backquote_logged = sub {
$? = $openssl_error ? 256 : 0;
return $openssl_error || '';
};
local *main::list_users = sub { return {name => 'fixture'} };
# Stop after the tested account write, before the CGI's delay and restart.
local *main::modify_user = sub { $saved = $_[1]->{cert}; die "fixture done\n" };
local $main::base_remote_user = 'fixture';
my $payload = '<script>alert(1)</script>';
for my $name ('Review User', q{O'Reilly & Partners}, $payload) {
my ($html, $error) = render_cgi('acl/cert_issue.cgi',
{ key => 'fixture-key', commonName => $name });
is($error, "fixture done\n", 'successful issuance reaches account update');
like($html, qr/\Q@{[main::html_escape($name)]}\E/,
'common name appears as text');
unlike($html, qr/\Q$payload\E/, 'common name cannot introduce a script');
like($saved, qr/\Q\/CN=$name\/Email=\E/, 'stored DN retains the original name');
}
$openssl_error = "Invalid subject $payload\n";
my ($html, $error) = render_cgi('acl/cert_issue.cgi',
{ key => 'fixture-key', commonName => 'Review User' });
like($error, qr/<pre>Invalid subject &lt;script&gt;/,
'OpenSSL diagnostics retain preformatted markup and escape input');
unlike($error, qr/\Q$payload\E/, 'OpenSSL errors cannot introduce a script');
};
subtest 'single-process permission error heading' => sub {
my ($heading, $pid);
local *main::error_setup = sub { $heading = $_[0] };
local *main::process_info = sub { $pid = $_[0]; return (user => 'fixture') };
local *main::can_edit_process = sub { 0 };
local *main::kill_logged = sub { die "unexpected signal\n" };
my $payload = '<script>alert(1)</script>';
for my $inputs ({signal => 'TERM', pid => '12345'},
{signal => $payload, pid => $payload}) {
my ($html, $error) = render_cgi('proc/kill_proc.cgi', $inputs);
is($error, "$main::text{'kill_ecannot'}\n", 'signaling is denied');
is($heading, main::text('kill_err',
main::html_escape($inputs->{signal}),
main::html_escape($inputs->{pid})),
'error heading renders submitted values as text');
is($pid, $inputs->{pid}, 'process lookup receives the original PID');
}
};
subtest 'process output and return link' => sub {
my @signals;
local *main::list_processes = sub { return map { +{user => 'fixture'} } @_ };
local *main::can_edit_process = sub { 1 };
local *main::kill_logged = sub { push @signals, [@_]; return 1 };
my $query = 'search=worker%20name&match=1';
my ($html, $error) = render_cgi('proc/kill_proc_list.cgi',
{pidlist => '12345 12346', signal => 'TERM', args => $query});
is($error, '', 'ordinary request renders successfully');
is_deeply(\@signals, [['TERM', '12345'], ['TERM', '12346']],
'signal and PID arguments are unchanged');
$html =~ /href="(index_search\.cgi\?[^"]+)"/;
is(main::html_unescape($1), "index_search.cgi?$query",
'HTML escaping preserves the return query');
my $payload = '<script>alert(1)</script>';
($html, $error) = render_cgi('proc/kill_proc_list.cgi',
{pidlist => $payload, signal => $payload, args => '">'.$payload});
is($error, '', 'hostile values render successfully');
unlike($html, qr/\Q$payload\E/, 'PID, signal and return link cannot inject scripts');
like($html, qr/SIG&lt;script&gt;/, 'signal output is escaped');
like($html, qr/href="index_search.cgi\?&quot;&gt;&lt;script&gt;/,
'return link cannot escape its quoted attribute');
};
done_testing();

View File

@@ -1,123 +0,0 @@
#!/usr/bin/perl
# Run with WEBMIN_NETPLAN_VM_TEST=1 on a Linux debug VM with Netplan installed.
use strict;
use warnings;
no warnings 'once';
use Test::More;
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use FindBin;
use lib "$FindBin::Bin/../net";
plan skip_all => 'Set WEBMIN_NETPLAN_VM_TEST=1 on a Linux debug VM'
unless ($ENV{'WEBMIN_NETPLAN_VM_TEST'} || '') eq '1';
$^O eq 'linux' or die 'Linux VM required';
require "$FindBin::Bin/../web-lib-funcs.pl";
require "$FindBin::Bin/../net/netplan-lib.pl";
local %main::gconfig = ();
local $main::readonly_mode_cache = 0;
# Load real address helpers without initializing the VM's Webmin configuration.
my $net_lib = main::read_file_contents("$FindBin::Bin/../net/net-lib.pl");
foreach my $name (qw(prefix_to_mask mask_to_prefix log2)) {
my ($code) = $net_lib =~ /^(sub \Q$name\E\n\{.*?^\})/ms;
$code or die "Cannot find $name";
eval "no strict; $code";
die $@ if $@;
}
# Keep realistic addresses, DNS, routes, and comments in the surviving port.
my $header = "network:\n version: 2\n";
my $global = $header." renderer: networkd\n";
my $ethernet = <<'YAML';
ethernets:
eth0:
# Preserve the port's static settings.
addresses: [192.0.2.10/24, '2001:db8::10/64']
nameservers:
addresses: [192.0.2.53]
search: [example.test]
routes:
- to: default
via: 192.0.2.1
eth1:
dhcp4: true
YAML
my $bridge0 = " br0:\n interfaces: [eth0]\n";
my $bridge1 = " br1:\n interfaces: [eth1]\n";
my $standalone = " br0:\n interfaces: []\n dhcp4: true\n";
my $other_groups = <<'YAML';
vlans:
vlan10:
id: 10
link: eth0
bonds:
bond0:
interfaces: [eth1]
parameters:
mode: active-backup
YAML
my $inline = " ethernets: {eth0: {dhcp4: true}}\n";
my $bulk = $global." ethernets: {}\n bridges:\n".
" br0:\n dhcp4: false\n br1:\n dhcp4: true\n";
# Each case supplies valid input, deletion order, and exact expected contents.
foreach my $case (
['shared bridge', $header.$ethernet." bridges:\n".$bridge0,
['br0'], $header.$ethernet],
['remaining bridge', $header.$ethernet." bridges:\n".$bridge0.$bridge1,
['br0'], $header.$ethernet." bridges:\n".$bridge1],
['last Ethernet', $header." ethernets:\n eth0: {}\n",
['eth0'], undef],
['last bridge', $header." bridges:\n".$standalone,
['br0'], undef],
['VLAN and bond settings', $header.$ethernet.$other_groups.
" bridges:\n".$standalone, ['br0'], $header.$ethernet.$other_groups],
['inline Ethernet mapping', $header.$inline." bridges:\n".$bridge0,
['br0'], $header.$inline],
['bulk deletion preserves existing empty groups', $bulk, ['br1', 'br0'],
$global." ethernets: {}\n"],
['bulk deletion follows display order',
$global." ethernets:\n eth0: {}\n bridges:\n".$standalone,
['eth0', 'br0'], $global],
['virtual IPv4 address', $header." ethernets:\n eth0:\n".
" addresses: [192.0.2.10/24, 192.0.2.11/24]\n",
['eth0:0'], $header." ethernets:\n eth0:\n".
" addresses: ['192.0.2.10/24']\n"],
) {
subtest $case->[0] => sub {
# Generate only beneath this temporary root, without applying networking.
my $root = tempdir(CLEANUP => 1);
local $main::netplan_dir = "$root/etc/netplan";
make_path($main::netplan_dir);
my $file = "$main::netplan_dir/shared.yaml";
my $control = "$main::netplan_dir/00-renderer.yaml";
main::write_file_contents($file, $case->[1]);
main::write_file_contents($control, $global);
chmod(0600, $file, $control) == 2 or die "chmod: $!";
is(system('netplan', 'generate', '--root-dir', $root), 0,
'original configuration passes Netplan');
# Match the UI's reuse of one interface list throughout bulk deletion.
my @boot = main::boot_interfaces();
foreach my $name (@{$case->[2]}) {
my ($iface) = grep { $_->{'fullname'} eq $name } @boot;
$iface or die "Fixture interface $name not found";
main::delete_interface($iface);
}
if (defined($case->[3])) {
# Surviving settings and comments must retain their exact contents.
is(main::read_file_contents($file), $case->[3],
'remaining configuration is unchanged');
}
else {
# No interface or global setting remains in this file.
ok(!-e $file, 'empty file is removed');
}
is(main::read_file_contents($control), $global,
'another YAML file is untouched');
is(system('netplan', 'generate', '--root-dir', $root), 0,
'resulting configuration passes Netplan');
};
}
done_testing();

View File

@@ -1,65 +0,0 @@
#!/usr/bin/perl
# Exercise Netplan deletion using temporary files only, without host configuration.
use strict;
use warnings;
no warnings 'once';
use Test::More;
use File::Temp qw(tempdir);
use FindBin;
use lib "$FindBin::Bin/../net";
require "$FindBin::Bin/../web-lib-funcs.pl";
require "$FindBin::Bin/../net/netplan-lib.pl";
local %main::gconfig = ();
local $main::readonly_mode_cache = 0;
my $root = tempdir(CLEANUP => 1);
my $file = "$root/shared.yaml";
local $main::netplan_dir = $root;
# Empty detection must retain real interface entries and global settings.
foreach my $case (
["network:\n version: 2\n ethernets:\n bridges:\n", 1, 'empty interface groups'],
["network:\n version: 2\n ethernets:\n eth0: {}\n", 0, 'empty Ethernet definition'],
["network:\n version: 2\n bridges:\n br1:\n dhcp4: true\n", 0, 'another bridge'],
["network:\n version: 2\n renderer: networkd\n", 0, 'global renderer'],
["network:\n version: 2\n vlans:\n vlan10:\n id: 10\n link: eth0\n", 0, 'VLAN settings']) {
main::write_file_contents($file, $case->[0]);
main::unflush_file_lines($file);
is(main::is_yaml_empty($file), $case->[1], "$case->[2] handled correctly");
}
# Deleting the last bridge must retain its port and unrelated YAML byte for byte.
my $remaining = "network:\n version: 2\n ethernets:\n eth0:\n dhcp4: true\n".
" eth1:\n dhcp4: false\n";
my $original = $remaining." bridges:\n br0:\n interfaces: [eth0]\n";
main::write_file_contents($file, $original);
main::unflush_file_lines($file);
main::delete_interface((grep { $_->{'fullname'} eq 'br0' } main::boot_interfaces())[0]);
ok(-f $file, 'shared YAML survives bridge deletion');
is(main::read_file_contents($file), $remaining, 'empty group removed, Ethernet settings untouched');
# Removing one bridge must preserve the group and the remaining bridge.
my $second = " br1:\n interfaces: [eth1]\n";
main::write_file_contents($file, $original.$second);
main::unflush_file_lines($file);
main::delete_interface((grep { $_->{'fullname'} eq 'br0' } main::boot_interfaces())[0]);
is(main::read_file_contents($file), $remaining." bridges:\n".$second,
'another bridge and its group remain unchanged');
# A bridge-only file is removed once its last interface is gone.
main::write_file_contents($file, "network:\n version: 2\n bridges:\n br0:\n dhcp4: true\n");
main::unflush_file_lines($file);
main::delete_interface((grep { $_->{'fullname'} eq 'br0' } main::boot_interfaces())[0]);
ok(!-e $file, 'empty bridge-only file is removed');
# Bulk deletion reuses interface details after earlier deletions shift lines.
my $global = "network:\n version: 2\n renderer: networkd\n";
main::write_file_contents($file, $global." ethernets: {}\n bridges:\n".
" br0:\n dhcp4: false\n br1:\n dhcp4: true\n");
main::unflush_file_lines($file);
my @boot = main::boot_interfaces();
foreach my $iface (@boot) {
main::delete_interface($iface);
}
is(main::read_file_contents($file), $global." ethernets: {}\n",
'bulk deletion handles line shifts and preserves unrelated settings');
done_testing();

View File

@@ -1 +1 @@
2.670 2.671