Files
webmin/t/web-lib-funcs-paths.t
Ilia Ross 5e90e1438a Fix module name validation (once again)
This patch fixes `get_module_info()` accepting module names with a trailing newline after the regex anchors were changed.

It preserves the strict validation introduced in my `2f4f017ac` to prevent loading files outside installed modules.

The check directly rejects undefined or empty names and any character outside ASCII letters, digits, underscores, and hyphens, including newlines anywhere in the name.

Regression tests pass and verify that invalid names are rejected before module lookup while valid names remain accepted.
2026-10-08 22:40:36 +02:00

257 lines
9.7 KiB
Perl

#!/usr/bin/perl
# Unit tests for path / URL / shell-quote helpers in web-lib-funcs.pl.
#
# Bare `require` loads the library; subs covered here either touch no
# globals or use ones we set up locally ($gconfig{os_type} for quote_path,
# %month_to_number_map / %number_to_month_map for the month helpers).
# In production those maps are populated by web-lib.pl; we set them by hand
# to keep tests independent of that initialiser.
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Spec;
my $script = File::Spec->rel2abs(
File::Spec->catfile(dirname(__FILE__), '..', 'web-lib-funcs.pl'));
require $script;
# get_module_info — validate names before looking up module files.
subtest 'get_module_info name validation' => sub {
no warnings qw(redefine once);
my @lookups;
# Record accepted names and stop before any filesystem access.
local *main::module_root_directory = sub {
push(@lookups, $_[0]);
return '/unused';
};
local *main::load_language_auto = sub { return 0; };
local *main::load_language_neutral = sub { return 0; };
local *main::read_file_cached = sub { return 0; };
# Invalid names must return empty without reaching the module lookup.
foreach my $case (
[ undef, 'undefined' ], [ '', 'empty' ],
[ "module\n", 'trailing newline' ],
[ "module\r\n", 'trailing CRLF' ],
[ "mod\nule", 'embedded newline' ],
[ 'module ', 'trailing space' ],
[ "module\t", 'trailing tab' ],
[ 'mod.ule', 'dot' ], [ 'mod/ule', 'slash' ],
[ "mod\x{e9}ule", 'non-ASCII letter' ]) {
@lookups = ();
is_deeply([main::get_module_info($case->[0])], [],
"$case->[1] returns no module info");
is_deeply(\@lookups, [], "$case->[1] skips module lookup");
}
# Valid names, including the string "0", must still reach the lookup.
foreach my $name ('module', 'Module_123-test', '0') {
@lookups = ();
my %info = main::get_module_info($name);
is_deeply(\@lookups, [$name], "$name reaches module lookup");
}
};
# simplify_path — strip ./ and resolve ../, refusing to escape root.
#
# Contract:
# - returns an absolute path (always leading "/")
# - returns undef when .. would pop above the root
# - "." and "" inputs both return "/"
# - a relative input ("foo") is promoted to "/foo"
#
# This is the web-lib-funcs version; miniserv.pl ships an independent
# implementation (`miniserv::simplify_path`) with a different signature
# (bogus-flag via aliased arg). Both are exercised by their own tests.
subtest 'simplify_path' => sub {
is(main::simplify_path('/foo/bar'), '/foo/bar', 'plain path');
is(main::simplify_path('/foo/./bar'), '/foo/bar', '. collapses');
is(main::simplify_path('/foo/../bar'), '/bar', '.. pops');
is(main::simplify_path('/a/b/c/../../d'), '/a/d',
'multiple .. pop the right number of segments');
is(main::simplify_path('//foo///bar//'), '/foo/bar', 'repeated slashes collapse');
is(main::simplify_path('/foo/'), '/foo', 'trailing slash dropped');
# Adversarial: escaping root must fail closed.
is(main::simplify_path('/../etc/passwd'), undef, '.. above root → undef');
is(main::simplify_path('/foo/../../bar'), undef, 'overshoot → undef');
# Surprising-but-current behaviour: empty / root / relative inputs.
is(main::simplify_path(''), '/', 'empty input → /');
is(main::simplify_path('/'), '/', 'root passes through');
is(main::simplify_path('foo'), '/foo', 'relative input is promoted to absolute');
};
# webmin_temp_dir_name / webmin_temp_dir_path — hidden final temp dir name.
subtest 'webmin temp dir path' => sub {
no warnings 'once';
local %main::gconfig = ('os_type' => 'linux');
is(main::webmin_temp_dir_name(), '.webmin',
'default final temp dir name');
is(main::webmin_temp_dir_path('/var/tmp'), '/var/tmp/.webmin',
'default name appended to base path');
is(main::webmin_temp_dir_path('/var/tmp/.webmin'), '/var/tmp/.webmin',
'default name is not appended twice');
local %main::gconfig = (
'os_type' => 'linux',
'tempdirname' => 'webmin-private',
);
is(main::webmin_temp_dir_name(), 'webmin-private',
'hidden tempdirname config overrides default name');
is(main::webmin_temp_dir_path('/tmp/path1/path2/path3'),
'/tmp/path1/path2/path3/webmin-private',
'custom name becomes the final path component');
local %main::gconfig = (
'os_type' => 'linux',
'tempdirname' => '../bad',
);
is(main::webmin_temp_dir_name(), '.webmin',
'invalid hidden name falls back to default');
};
# parse_http_url — absolute and base-relative URL parsing.
#
# Contract on success: returns (host, port, page, ssl, [user], [pass]).
# SSL mode 0=http, 1=https, 2=ftp.
subtest 'parse_http_url' => sub {
my @abs = main::parse_http_url('http://example.com/foo');
is_deeply([@abs[0..3]], ['example.com', 80, '/foo', 0],
'plain http URL');
my @https = main::parse_http_url('https://example.com:8443/bar');
is_deeply([@https[0..3]], ['example.com', 8443, '/bar', 1],
'https with explicit port and ssl=1');
my @ftp = main::parse_http_url('ftp://host/x');
is_deeply([@ftp[0..3]], ['host', 21, '/x', 2],
'ftp scheme → port 21 and ssl=2');
# Userinfo is captured as elements 4 and 5.
my @auth = main::parse_http_url('http://user:pass@example.com:81/foo');
is_deeply(\@auth, ['example.com', 81, '/foo', 0, 'user', 'pass'],
'user:pass extracted from authority');
# Bracketed IPv6 host.
my @v6 = main::parse_http_url('http://[2001:db8::1]:8080/foo');
is_deeply([@v6[0..3]], ['2001:db8::1', 8080, '/foo', 0],
'bracketed IPv6 host parsed, brackets stripped');
# Missing path defaults to "/".
my @noslash = main::parse_http_url('http://example.com');
is($noslash[2], '/', 'missing path defaults to /');
# no_default_port suppresses 80/443/21 substitution.
my @nd = main::parse_http_url('http://example.com/x', undef, undef,
undef, undef, undef, undef, 1);
is($nd[1], undef, 'no_default_port leaves port undef');
# Relative URL with a base.
my @rs = main::parse_http_url('/page', 'host', 80, '/old/', 0);
is_deeply([@rs[0..3]], ['host', 80, '/page', 0],
'server-absolute relative URL uses base host/port');
my @rd = main::parse_http_url('rel.html', 'host', 80, '/base/cur.html', 0);
is_deeply([@rd[0..3]], ['host', 80, '/base/rel.html', 0],
'directory-relative URL resolves against base page directory');
# Unparseable input with no base → undef.
is(main::parse_http_url('not a url'), undef,
'garbage with no base → undef');
};
# split_quoted_string — shell-ish tokenizer.
#
# Each iteration matches one of:
# "..." | '...' | \S+
# followed by trailing whitespace, then loops on the remainder.
subtest 'split_quoted_string' => sub {
is_deeply([main::split_quoted_string('one two three')],
['one', 'two', 'three'],
'bare words');
is_deeply([main::split_quoted_string('"hello world" foo "bar baz" qux')],
['hello world', 'foo', 'bar baz', 'qux'],
'double-quoted segments preserve internal spaces');
is_deeply([main::split_quoted_string(q{'a b' c 'd e'})],
['a b', 'c', 'd e'],
'single-quoted segments work the same way');
is_deeply([main::split_quoted_string('')], [],
'empty input → empty list');
# Unbalanced quote: the implementation falls through to the \S+
# branch and emits the leftover with the quote attached. Pin this.
is_deeply([main::split_quoted_string('unbalanced "quote')],
['unbalanced', '"quote'],
'unterminated quote is taken as a bare token');
# Leading and trailing whitespace tolerated around tokens.
is_deeply([main::split_quoted_string(' spaces between ')],
['spaces', 'between'],
'leading whitespace tolerated, interior whitespace splits tokens');
is_deeply([main::split_quoted_string("\tfoo\n")], ['foo'],
'tabs and newlines treated as whitespace too');
is_deeply([main::split_quoted_string(' ')], [],
'pure-whitespace input → empty list');
};
# quote_path — OS-dependent shell quoting.
#
# On Windows or for Windows-style absolute paths, wraps in double quotes.
# Everywhere else, uses quotemeta (which escapes every non-word char).
subtest 'quote_path' => sub {
no warnings 'once';
local %main::gconfig = ('os_type' => 'linux');
# quotemeta escapes /, space, etc.
is(main::quote_path('/a b/c'), '\\/a\\ b\\/c',
'unix path uses quotemeta');
is(main::quote_path('plain'), 'plain',
'all-word characters need no escaping');
# Windows-style path → "" wrapping, even when os_type isn't windows.
is(main::quote_path('c:/Users/x'), '"c:/Users/x"',
'drive-letter prefix wraps in double quotes');
# os_type=windows forces double-quote wrap.
local %main::gconfig = ('os_type' => 'windows');
is(main::quote_path('/etc/passwd'), '"/etc/passwd"',
'os_type=windows wraps in double quotes regardless of path shape');
};
# month_to_number / number_to_month — three-letter month name <-> 0-based index.
#
# In production these maps are populated by web-lib.pl; in tests we set
# them up locally so the suite doesn't depend on web-lib.pl loading.
subtest 'month_to_number / number_to_month' => sub {
no warnings 'once';
local %main::month_to_number_map = (
'jan' => 0, 'feb' => 1, 'mar' => 2, 'apr' => 3,
'may' => 4, 'jun' => 5, 'jul' => 6, 'aug' => 7,
'sep' => 8, 'oct' => 9, 'nov' => 10, 'dec' => 11,
);
local %main::number_to_month_map = reverse %main::month_to_number_map;
is(main::month_to_number('Jan'), 0, 'Jan → 0');
is(main::month_to_number('December'), 11, 'first three chars taken');
is(main::month_to_number('FEB'), 1, 'case-insensitive');
is(main::month_to_number('xyz'), undef, 'unknown returns undef');
is(main::number_to_month(0), 'Jan', '0 → Jan (ucfirst applied)');
is(main::number_to_month(11), 'Dec', '11 → Dec');
# Round-trip every month.
for my $m (qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec)) {
is(main::number_to_month(main::month_to_number($m)), $m,
"round-trip $m");
}
};
done_testing();