From 47d0ac01b044a59cd0f6ab9507a20ac338d23b58 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sat, 31 Oct 2026 18:04:27 +0100 Subject: [PATCH] Update tests --- acl/t/run-tests.t | 66 ++++++++++++++++++++++++++++ t/cert-proc-escaping.t | 99 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 t/cert-proc-escaping.t diff --git a/acl/t/run-tests.t b/acl/t/run-tests.t index e28dbe2fe..908f6e659 100644 --- a/acl/t/run-tests.t +++ b/acl/t/run-tests.t @@ -1650,6 +1650,72 @@ subtest 'save_group.cgi name validation' => sub { 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 = ''; + $r = run_cgi('save_group.cgi', { name => 'badmod', mod => $payload }); + like($r->{out}, qr/<script>/, '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'); +}; + subtest 'save_user.cgi self-lockout guard' => sub { # Editing yourself out of the acl module is refused — otherwise an # admin could lock themselves out of Webmin entirely. diff --git a/t/cert-proc-escaping.t b/t/cert-proc-escaping.t new file mode 100644 index 000000000..3f65915c1 --- /dev/null +++ b/t/cert-proc-escaping.t @@ -0,0 +1,99 @@ +#!/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 = ''; + 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/
Invalid subject <script>/,
+		'OpenSSL diagnostics retain preformatted markup and escape input');
+	unlike($error, qr/\Q$payload\E/, 'OpenSSL errors cannot introduce a script');
+	};
+
+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 = '';
+	($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<script>/, 'signal output is escaped');
+	like($html, qr/href="index_search.cgi\?"><script>/,
+		'return link cannot escape its quoted attribute');
+	};
+
+done_testing();