mirror of
https://github.com/webmin/webmin.git
synced 2026-06-10 23:00:31 +01:00
426 lines
18 KiB
Perl
426 lines
18 KiB
Perl
#!/usr/bin/perl
|
|
# Functional tests for the xterm module.
|
|
#
|
|
# Loads xterm-lib.pl (and acl_security.pl) as libraries. shellserver.pl is
|
|
# guarded by `unless (caller)` so requiring it has no side effects, and the
|
|
# helpers it relies on (verify_websocket_key, parse_resize_message,
|
|
# resolve_shell_user) live in xterm-lib.pl so we can test them in isolation.
|
|
|
|
use strict;
|
|
use warnings;
|
|
use Test::More;
|
|
use Cwd qw(abs_path);
|
|
use File::Temp qw(tempdir);
|
|
# MIME::Base64 is loaded lazily inside the verify_websocket_key subtest;
|
|
# loading it before WebminCore triggers a prototype-mismatch warning when
|
|
# WebminCore re-declares encode_base64/decode_base64 with no prototype.
|
|
|
|
sub script_dir
|
|
{
|
|
my $path = $0;
|
|
if ($path =~ m{^/}) {
|
|
$path =~ s{/[^/]+$}{};
|
|
return $path;
|
|
}
|
|
my $cwd = `pwd`;
|
|
chomp($cwd);
|
|
if ($path =~ m{/}) {
|
|
$path =~ s{/[^/]+$}{};
|
|
return $cwd.'/'.$path;
|
|
}
|
|
return $cwd;
|
|
}
|
|
|
|
my $bindir = script_dir();
|
|
my $rootdir = abs_path("$bindir/../..") or die "rootdir: $!";
|
|
|
|
my $confdir = tempdir(CLEANUP => 1);
|
|
my $vardir = tempdir(CLEANUP => 1);
|
|
open(my $cfh, ">", "$confdir/config") or die "config: $!";
|
|
print $cfh "os_type=linux\nos_version=0\n";
|
|
close($cfh);
|
|
open(my $vfh, ">", "$confdir/var-path") or die "var-path: $!";
|
|
print $vfh "$vardir\n";
|
|
close($vfh);
|
|
$ENV{'WEBMIN_CONFIG'} = $confdir;
|
|
$ENV{'WEBMIN_VAR'} = $vardir;
|
|
$ENV{'FOREIGN_MODULE_NAME'} = 'xterm';
|
|
$ENV{'FOREIGN_ROOT_DIRECTORY'} = $rootdir;
|
|
|
|
chdir("$bindir/..") or die "chdir: $!";
|
|
|
|
# Each xterm script pulls in xterm-lib.pl via a different path string —
|
|
# `require 'xterm-lib.pl'` from acl_security.pl, `require './xterm-lib.pl'`
|
|
# from shellserver.pl. %INC keys on the literal path, so multiple distinct
|
|
# paths all map to the same file and Perl would helpfully re-load it,
|
|
# producing "Subroutine ... redefined" warnings.
|
|
#
|
|
# Fix: pre-populate %INC for every path the in-tree code will use, then
|
|
# `do` the file ourselves (load once, all subsequent `require`s become
|
|
# no-ops because %INC already shows the file as loaded). Test cwd is the
|
|
# module dir, so '.' is on the search path for the do() to find it.
|
|
push @INC, '.';
|
|
{
|
|
my $file = "$bindir/../xterm-lib.pl";
|
|
do $file or die "load xterm-lib.pl: $@ $!";
|
|
$INC{$_} = $file for ('xterm-lib.pl', './xterm-lib.pl', $file);
|
|
}
|
|
|
|
require "./acl_security.pl";
|
|
|
|
# shellserver.pl pulls in Net::WebSocket::Server which is an optional CPAN
|
|
# dep — on stripped CI images it may not be installed. Required helpers all
|
|
# live in xterm-lib.pl, so a missing module only costs us the require side-
|
|
# effect check at the bottom of the file.
|
|
my $shellserver_loaded = eval {
|
|
require "./shellserver.pl";
|
|
1;
|
|
};
|
|
|
|
our (%in, %access);
|
|
|
|
# config_pre_load —
|
|
# `size` only makes sense in old themes that don't drive a JS-side resize.
|
|
# Authentic (XMLHttpRequest) ships its own resize, so the option must be
|
|
# stripped from the config-editor listing in that branch and left alone
|
|
# otherwise. We exercise both paths plus the degenerate-arg cases that
|
|
# the production callsite can hand us.
|
|
subtest 'config_pre_load' => sub {
|
|
# XHR branch: size is removed from both the info hash and the order array.
|
|
{
|
|
local $ENV{'HTTP_X_REQUESTED_WITH'} = 'XMLHttpRequest';
|
|
my %info = (size => {}, fontsize => {}, locale => {});
|
|
my @order = qw(size fontsize locale);
|
|
config_pre_load(\%info, \@order);
|
|
ok(!exists $info{'size'}, 'XHR: size removed from info hash');
|
|
ok( exists $info{'fontsize'}, 'XHR: unrelated keys preserved');
|
|
is_deeply(\@order, [qw(fontsize locale)],
|
|
'XHR: size removed from order array');
|
|
}
|
|
|
|
# Non-XHR branch: nothing is touched.
|
|
{
|
|
local $ENV{'HTTP_X_REQUESTED_WITH'} = '';
|
|
my %info = (size => {}, fontsize => {});
|
|
my @order = qw(size fontsize);
|
|
config_pre_load(\%info, \@order);
|
|
ok(exists $info{'size'}, 'non-XHR: size preserved');
|
|
is_deeply(\@order, [qw(size fontsize)],
|
|
'non-XHR: order array preserved');
|
|
}
|
|
|
|
# Header unset behaves like non-XHR (no uninit warning).
|
|
{
|
|
local %ENV = %ENV;
|
|
delete $ENV{'HTTP_X_REQUESTED_WITH'};
|
|
my %info = (size => {});
|
|
my @warnings;
|
|
local $SIG{__WARN__} = sub { push @warnings, $_[0]; };
|
|
config_pre_load(\%info, undef);
|
|
ok(exists $info{'size'}, 'unset header: size preserved');
|
|
is(scalar @warnings, 0, 'unset header: no uninit warning');
|
|
}
|
|
|
|
# Order arg is optional / may be a non-arrayref — must not crash.
|
|
{
|
|
local $ENV{'HTTP_X_REQUESTED_WITH'} = 'XMLHttpRequest';
|
|
my %info = (size => {});
|
|
eval { config_pre_load(\%info, undef); };
|
|
is($@, '', 'XHR with undef order arg does not die');
|
|
ok(!exists $info{'size'}, 'XHR with undef order arg still strips size');
|
|
}
|
|
};
|
|
|
|
# verify_websocket_key — handshake auth
|
|
#
|
|
# miniserv.pl rewrites the inbound Sec-WebSocket-Key to base64(session_id)
|
|
# before forwarding to the shellserver. Equality of the rewritten key with
|
|
# our base64-encoded local copy of session_id proves the connection came
|
|
# through the Webmin proxy and is bound to this user's session. Anything
|
|
# else (missing, empty, mismatched, only whitespace) must reject.
|
|
subtest 'verify_websocket_key' => sub {
|
|
# require (not use) MIME::Base64 to avoid importing its prototyped
|
|
# encode_base64 into main:: where it would clash with WebminCore's.
|
|
require MIME::Base64;
|
|
my $sid = 'abcdef0123456789' x 2;
|
|
my $b64 = MIME::Base64::encode_base64($sid);
|
|
|
|
is(verify_websocket_key($b64, $sid), 1,
|
|
'base64(session_id) matches');
|
|
(my $stripped = $b64) =~ s/\s//g;
|
|
is(verify_websocket_key($stripped, $sid), 1,
|
|
'whitespace-stripped key still matches (encode_base64 wraps lines)');
|
|
|
|
is(verify_websocket_key('wrong-key', $sid), 0,
|
|
'arbitrary key rejected');
|
|
is(verify_websocket_key($b64, 'different-session'), 0,
|
|
'right key but wrong session rejected');
|
|
is(verify_websocket_key(undef, $sid), 0, 'undef key rejected');
|
|
is(verify_websocket_key($b64, undef), 0, 'undef session rejected');
|
|
is(verify_websocket_key('', $sid), 0, 'empty key rejected');
|
|
is(verify_websocket_key($b64, ''), 0, 'empty session rejected');
|
|
is(verify_websocket_key(" \t\n", $sid), 0,
|
|
'whitespace-only key rejected (would otherwise compare empty == empty)');
|
|
|
|
# Reflected attack: if a client could replay miniserv's rewritten key
|
|
# back as some OTHER session's id, we'd be in trouble. Pin: the function
|
|
# compares against the FULL base64 of the local session, not a substring.
|
|
my $other_sid = 'zzzz1111';
|
|
is(verify_websocket_key($b64, $other_sid), 0,
|
|
'key for one session never matches a different session');
|
|
};
|
|
|
|
# parse_resize_message — xterm.js resize signal
|
|
#
|
|
# The browser sends a custom out-of-band string on terminal resize:
|
|
# literal backslash + "033[8;(rows);(cols)t"
|
|
# Anything else is normal keyboard input and must be forwarded to the shell
|
|
# unchanged. Important security contract: a real ANSI CSI 8;... escape
|
|
# (chr(27)) must NOT be interpreted as resize — otherwise a remote that
|
|
# can write to the user's terminal output could be confused with input,
|
|
# though here input flows the other way it's still hygiene worth pinning.
|
|
subtest 'parse_resize_message' => sub {
|
|
is_deeply([parse_resize_message('\\033[8;(24);(80)t')],
|
|
[24, 80],
|
|
'valid resize message parses to (rows, cols)');
|
|
is_deeply([parse_resize_message('\\033[8;(1);(1)t')],
|
|
[1, 1], 'small terminal');
|
|
is_deeply([parse_resize_message('\\033[8;(9999);(9999)t')],
|
|
[9999, 9999], 'large terminal');
|
|
|
|
# Anything else is not a resize.
|
|
is_deeply([parse_resize_message('hello')], [], 'plain text is not resize');
|
|
is_deeply([parse_resize_message('')], [], 'empty string is not resize');
|
|
is_deeply([parse_resize_message(undef)], [], 'undef is not resize');
|
|
is_deeply([parse_resize_message("\033[8;24;80t")], [],
|
|
'real ANSI ESC sequence (chr 27) is not the custom format');
|
|
is_deeply([parse_resize_message('\\033[8;24;80t')], [],
|
|
'missing parens (real CSI shape) not accepted');
|
|
is_deeply([parse_resize_message('prefix\\033[8;(24);(80)t')], [],
|
|
'must match from start of message');
|
|
is_deeply([parse_resize_message('\\033[8;(24);(80)textra')], [],
|
|
'must match to end of message');
|
|
is_deeply([parse_resize_message('\\033[8;(-1);(80)t')], [],
|
|
'negative dimensions rejected');
|
|
is_deeply([parse_resize_message('\\033[8;(abc);(80)t')], [],
|
|
'non-numeric dimensions rejected');
|
|
|
|
# Return values are real numbers (not strings) so downstream ioctl()
|
|
# pack("s2", ...) sees a sane integer.
|
|
my ($r, $c) = parse_resize_message('\\033[8;(40);(120)t');
|
|
cmp_ok($r, '==', 40, 'rows is numeric');
|
|
cmp_ok($c, '==', 120, 'cols is numeric');
|
|
};
|
|
|
|
# resolve_shell_user — which Unix account does the terminal run as?
|
|
#
|
|
# The contract (see acl_security.pl for the UI / docs/access.html for the
|
|
# operator-facing model):
|
|
#
|
|
# - access user '*' → always the authenticated user. No override.
|
|
# - access user 'root', sudoenforce on, remote_user is a real local
|
|
# account → prefer the authenticated user over root.
|
|
# - access user 'root', remote_user same as access user (e.g. logged in as
|
|
# root) → keep root.
|
|
# - config user override → applies only when the resolved user is still
|
|
# 'root'.
|
|
# - in{user} → only honored when the resolved user is still 'root' AFTER
|
|
# the config override (i.e. the admin actually allowed root).
|
|
#
|
|
# This is the core privilege-boundary logic. Each scenario is a security
|
|
# contract.
|
|
subtest 'resolve_shell_user' => sub {
|
|
# access user '*': start from authenticated user. For non-root
|
|
# remote_user this is effectively "no override possible" — the
|
|
# trailing branches only act when $user eq 'root', so an alice-as-
|
|
# alice resolution can't be redirected.
|
|
is(resolve_shell_user({user => '*'}, 'alice', {}, {}),
|
|
'alice', "'*' with non-root authuser stays as authuser");
|
|
is(resolve_shell_user({user => '*'}, 'alice', {user => 'bob'}, {}),
|
|
'alice', "'*' with non-root authuser ignores in{user}");
|
|
is(resolve_shell_user({user => '*'}, 'alice', {}, {user => 'configured'}),
|
|
'alice', "'*' with non-root authuser ignores config{user}");
|
|
|
|
# Edge case carried forward from the inline version: when access='*'
|
|
# AND the authenticated user IS root, $user lands at "root" and the
|
|
# trailing config{user} / in{user} branches still apply. Functionally
|
|
# benign (root can do anything anyway) but pinned so a future refactor
|
|
# doesn't accidentally change it without intent.
|
|
is(resolve_shell_user({user => '*'}, 'root', {user => 'shell-svc'}, {}),
|
|
'shell-svc',
|
|
"'*' with root authuser: in{user} still routes (pre-existing behavior)");
|
|
|
|
# access user 'root', logged in as root → stay root.
|
|
is(resolve_shell_user({user => 'root', sudoenforce => 1},
|
|
'root', {}, {}),
|
|
'root',
|
|
"'root' + remote_user=root stays root");
|
|
|
|
# config{user} override applies when the resolved user is still root.
|
|
is(resolve_shell_user({user => 'root', sudoenforce => 1},
|
|
'root', {}, {user => 'shell-svc'}),
|
|
'shell-svc',
|
|
'config{user} overrides remaining-root');
|
|
|
|
# in{user} override is honored only when the resolved user is still
|
|
# 'root' — i.e. the admin explicitly allowed root.
|
|
is(resolve_shell_user({user => 'root', sudoenforce => '0'},
|
|
'root', {user => 'bob'}, {}),
|
|
'bob',
|
|
'in{user} override honored when user remained root');
|
|
|
|
# Empty in{user} doesn't trigger the override branch.
|
|
is(resolve_shell_user({user => 'root', sudoenforce => '0'},
|
|
'root', {user => ''}, {}),
|
|
'root', 'empty in{user} not treated as override');
|
|
|
|
# Empty / missing access{user} returns undef rather than producing a
|
|
# nonsense result the caller might try to exec.
|
|
is(resolve_shell_user({user => ''}, 'alice', {}, {}),
|
|
undef, 'empty access{user} returns undef');
|
|
is(resolve_shell_user({}, 'alice', {}, {}),
|
|
undef, 'missing access{user} returns undef');
|
|
|
|
# Specific named user in access{user} (neither '*' nor 'root') flows
|
|
# through unchanged.
|
|
is(resolve_shell_user({user => 'jenkins'},
|
|
'alice', {user => 'attacker'}, {}),
|
|
'jenkins',
|
|
'specific named access user is honored as-is and cannot be overridden');
|
|
|
|
# Sudoenforce path needs a real non-root local user with a home dir.
|
|
# Use the test process's own account when it's not running as root.
|
|
# This is the key privilege boundary: the admin allows 'root' in the
|
|
# ACL, sudoenforce is on, the Webmin-authenticated user exists locally
|
|
# → the shell drops to that user. Once de-rooted, the URL-borne
|
|
# in{user} parameter MUST NOT be honored as a re-escalation channel.
|
|
my @me = getpwuid($<);
|
|
SKIP: {
|
|
skip 'sudoenforce path needs a non-root local user', 4
|
|
if !@me || $me[0] eq 'root' || !$me[7];
|
|
my $local = $me[0];
|
|
|
|
# sudoenforce on + remote_user is a real local non-root account
|
|
# → drop to that user.
|
|
is(resolve_shell_user({user => 'root', sudoenforce => 1},
|
|
$local, {}, {}),
|
|
$local,
|
|
'sudoenforce on prefers authenticated non-root user when local');
|
|
|
|
# sudoenforce off keeps root even when remote_user is a local
|
|
# non-root account.
|
|
is(resolve_shell_user({user => 'root', sudoenforce => '0'},
|
|
$local, {}, {}),
|
|
'root',
|
|
'sudoenforce=0 keeps root regardless of authenticated user');
|
|
|
|
# config{user} does NOT override once the sudo path has already
|
|
# resolved away from root (the if-eq-root gate is closed).
|
|
is(resolve_shell_user({user => 'root', sudoenforce => 1},
|
|
$local, {}, {user => 'shell-svc'}),
|
|
$local,
|
|
'config{user} skipped when sudo path already de-rooted');
|
|
|
|
# Pinning current behavior: when access{user}='root' AND the
|
|
# user has supplied in{user}, the elsif's `!$in{'user'}` gate
|
|
# CLOSES the sudo-preference branch, leaving $user='root', and
|
|
# the trailing override then sets $user=in{user}. Effect:
|
|
# sudoenforce is a default ("prefer non-root if user didn't
|
|
# choose"), not a hard guard. An explicit ?user=X bypasses it
|
|
# even when the authenticated user has a local account.
|
|
#
|
|
# Worth flagging: the admin model treats access{user}='root' as
|
|
# "this Webmin user may run shells as any local user", and the
|
|
# trailing override honors that. But operators relying on
|
|
# sudoenforce as a hard "never run as root unless impossible"
|
|
# guard would be surprised. See bugs-found note.
|
|
is(resolve_shell_user({user => 'root', sudoenforce => 1},
|
|
$local, {user => 'daemon'}, {}),
|
|
'daemon',
|
|
'in{user} override bypasses sudoenforce (sudoenforce is a default, not a hard guard)');
|
|
}
|
|
};
|
|
|
|
# acl_security_save — parsing of the ACL editor form
|
|
#
|
|
# The mapping from form fields back into the stored ACL hash. user_def=1
|
|
# means "same as authenticated user" (stored as '*'); otherwise the typed
|
|
# username is stored verbatim. sudoenforce is 1/0 normalised.
|
|
subtest 'acl_security_save' => sub {
|
|
# user_def=1 → '*'
|
|
{
|
|
local %in = (user_def => 1, user => 'ignored', sudoenforce => 1);
|
|
my %o;
|
|
acl_security_save(\%o);
|
|
is($o{'user'}, '*', 'user_def=1 stores *');
|
|
is($o{'sudoenforce'}, 1, 'sudoenforce=1 stored as 1');
|
|
}
|
|
|
|
# user_def=0 → in{user} stored verbatim
|
|
{
|
|
local %in = (user_def => 0, user => 'root', sudoenforce => 0);
|
|
my %o;
|
|
acl_security_save(\%o);
|
|
is($o{'user'}, 'root', 'explicit user stored');
|
|
is($o{'sudoenforce'}, 0, 'sudoenforce=0 stored as 0');
|
|
}
|
|
|
|
# Falsy values normalize.
|
|
{
|
|
local %in = (user_def => 0, user => 'bob', sudoenforce => '');
|
|
my %o;
|
|
acl_security_save(\%o);
|
|
is($o{'sudoenforce'}, 0, 'empty sudoenforce normalizes to 0');
|
|
}
|
|
};
|
|
|
|
# Stock ACL files — `defaultacl` and `safeacl` are the templates Webmin
|
|
# applies when a new user (admin or non-admin) gets access to the module.
|
|
# Their values gate every later security check. Pin the defaults so a
|
|
# stray edit can't quietly loosen them.
|
|
subtest 'shipped ACL templates' => sub {
|
|
my %def;
|
|
open(my $df, '<', "$bindir/../defaultacl") or die "defaultacl: $!";
|
|
while (<$df>) { chomp; my ($k, $v) = split(/=/, $_, 2); $def{$k} = $v; }
|
|
close($df);
|
|
is($def{'user'}, 'root', 'defaultacl runs as root');
|
|
is($def{'sudoenforce'}, '1', 'defaultacl has sudoenforce ON');
|
|
|
|
my %safe;
|
|
open(my $sf, '<', "$bindir/../safeacl") or die "safeacl: $!";
|
|
while (<$sf>) { chomp; my ($k, $v) = split(/=/, $_, 2); $safe{$k} = $v; }
|
|
close($sf);
|
|
is($safe{'user'}, '*',
|
|
'safeacl runs as authenticated user (no root for non-admins)');
|
|
is($safe{'noconfig'}, '1',
|
|
'safeacl forbids the module config screen for non-admins');
|
|
};
|
|
|
|
# shellserver.pl require-and-stub —
|
|
# After the unless(caller) wrap, `require`'ing shellserver.pl should not
|
|
# bind any sockets, fork shells, or otherwise touch the system.
|
|
subtest 'shellserver require is side-effect free' => sub {
|
|
if ($shellserver_loaded) {
|
|
# It was required at the top of this file. If that had taken any
|
|
# of the main-body side effects, this test process would have
|
|
# died or hung. Reaching this point is itself the assertion.
|
|
ok(1, 'shellserver.pl required without firing main body');
|
|
}
|
|
else {
|
|
# Net::WebSocket::Server is an optional CPAN dep — record a skip
|
|
# rather than failing on hosts that don't have it. The guard we're
|
|
# testing for is still present in the file; compile.t covers parse.
|
|
SKIP: {
|
|
skip 'Net::WebSocket::Server not installed (optional dep)', 1;
|
|
}
|
|
}
|
|
|
|
# Verify the helpers shellserver relies on are reachable from this
|
|
# scope (i.e. xterm-lib.pl provided them, not an inside-caller block).
|
|
ok(defined(&verify_websocket_key), 'verify_websocket_key is callable');
|
|
ok(defined(&parse_resize_message), 'parse_resize_message is callable');
|
|
ok(defined(&resolve_shell_user), 'resolve_shell_user is callable');
|
|
};
|
|
|
|
done_testing();
|