diff --git a/acl/t/run-tests.t b/acl/t/run-tests.t
index e1756d20f..023f34c83 100644
--- a/acl/t/run-tests.t
+++ b/acl/t/run-tests.t
@@ -1474,6 +1474,25 @@ subtest 'save_user.cgi create privilege' => sub {
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', '') {
+ 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/';
+ 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'} } @_ };