fix: address security review findings

This commit is contained in:
Marek Miklewicz
2026-06-30 11:41:40 +02:00
parent 3b096b67bb
commit 54b2e620ff
12 changed files with 188 additions and 31 deletions
+13
View File
@@ -8,6 +8,7 @@ test('cleanup deletes only expired login urls matching local mail-login state',
'timestamp' => 1782810000,
'expires' => 1782810030,
'correlation' => 'autologin-h4-mxtest-1782810000',
'id' => 'HASHURLMATCH',
'redirect_path' => '/',
'client_ip' => '198.51.100.10',
'ip_bound' => 1,
@@ -31,5 +32,17 @@ test('cleanup deletes only expired login urls matching local mail-login state',
assert_true(mailLoginCleanupShouldDelete($matching, [$state], strtotime('2026-06-30T10:01:00Z')));
assert_false(mailLoginCleanupShouldDelete($wrongRedirect, [$state], strtotime('2026-06-30T10:01:00Z')));
assert_false(mailLoginCleanupShouldDelete($notExpired, [$state], strtotime('2026-06-30T10:01:00Z')));
$stateWithoutId = $state;
unset($stateWithoutId['id']);
assert_false(mailLoginCleanupShouldDelete($matching, [$stateWithoutId], strtotime('2026-06-30T10:01:00Z')));
});
test('cleanup rejects non-https directadmin api base url', function (): void {
try {
mailLoginCleanupValidateBaseUrl('http://127.0.0.1:2222');
throw new RuntimeException('Expected http base URL to fail');
} catch (InvalidArgumentException $e) {
assert_contains('https', strtolower($e->getMessage()));
}
});
+42 -1
View File
@@ -12,6 +12,7 @@ test('directadmin context derives server name from first host label', function (
$ctx = DirectAdminContext::fromServer([
'USERNAME' => 'mxtest',
'HTTP_HOST' => 'https://h4.domena.pl:2222',
'SERVER_NAME' => 'h4.domena.pl',
'REMOTE_ADDR' => '198.51.100.10',
'REQUEST_METHOD' => 'GET',
], $settings);
@@ -21,6 +22,34 @@ test('directadmin context derives server name from first host label', function (
assert_same('h4', $ctx->serverName());
});
test('directadmin context prefers trusted configured source server name over host header', function (): void {
$settings = Settings::fromArray(['MAIL_LOGIN_SOURCE_SERVER_NAME' => 'h4']);
$ctx = DirectAdminContext::fromServer([
'USERNAME' => 'mxtest',
'HTTP_HOST' => 'evil.example.net:2222',
'SERVER_NAME' => 'h4.domena.pl',
'REMOTE_ADDR' => '198.51.100.10',
'REQUEST_METHOD' => 'GET',
], $settings);
assert_same('h4.domena.pl', $ctx->sourceHost());
assert_same('h4', $ctx->serverName());
});
test('directadmin context fails closed without directadmin username', function (): void {
try {
DirectAdminContext::fromServer([
'USER' => 'root',
'HTTP_HOST' => 'h4.domena.pl:2222',
'REMOTE_ADDR' => '198.51.100.10',
'REQUEST_METHOD' => 'GET',
], Settings::fromArray([]));
throw new RuntimeException('Expected missing USERNAME to fail');
} catch (InvalidArgumentException $e) {
assert_contains('username', strtolower($e->getMessage()));
}
});
test('directadmin context rejects invalid usernames', function (): void {
try {
DirectAdminContext::fromServer([
@@ -58,6 +87,19 @@ test('client ip honors forwarded header only from trusted proxy', function (): v
assert_same('203.0.113.20', $ip);
});
test('client ip supports trusted proxy cidr ranges', function (): void {
$settings = Settings::fromArray([
'MAIL_LOGIN_CLIENT_IP_MODE' => 'trusted_proxy',
'MAIL_LOGIN_TRUSTED_PROXIES' => '10.0.0.0/24',
]);
$ip = ClientIp::resolve([
'REMOTE_ADDR' => '10.0.0.25',
'HTTP_X_FORWARDED_FOR' => '203.0.113.20',
], $settings);
assert_same('203.0.113.20', $ip);
});
test('source authorizer rejects hosts outside allowlist', function (): void {
$settings = Settings::fromArray(['MAIL_LOGIN_ALLOWED_SOURCE_HOSTS' => 'h4.domena.pl']);
@@ -79,4 +121,3 @@ test('key name uses requested format and truncates long values deterministically
assert_true(str_starts_with($long, 'autologin-'));
assert_true((bool)preg_match('/-[a-f0-9]{10}-1782810000$/', $long), 'Long key should contain hash suffix');
});
+6 -5
View File
@@ -18,7 +18,7 @@ test('mx helper conditionally passes user ip binding to da login-url', function
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg($script),
'mail-login-create-url mxtest h4.domena.pl h4 30 1 198.51.100.10 /'
'mail-login-create-url mxtest h4.domena.pl h4 30 1 198.51.100.10 / 1782810000'
);
exec($cmd, $out, $code);
assert_same(0, $code, implode("\n", $out));
@@ -33,7 +33,7 @@ test('mx helper conditionally passes user ip binding to da login-url', function
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg($script),
'mail-login-create-url mxtest h4.domena.pl h4 30 0 198.51.100.10 /'
'mail-login-create-url mxtest h4.domena.pl h4 30 0 198.51.100.10 / 1782810001'
);
exec($cmdNoIp, $outNoIp, $codeNoIp);
assert_same(0, $codeNoIp, implode("\n", $outNoIp));
@@ -57,7 +57,7 @@ test('mx helper accepts forced command arguments from ssh original command', fun
escapeshellarg($audit),
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg('mail-login-create-url mxtest h4.domena.pl evil 30 1 198.51.100.10 /'),
escapeshellarg('mail-login-create-url mxtest h4.domena.pl evil 30 1 198.51.100.10 / 1782810002'),
escapeshellarg($script)
);
exec($cmd, $out, $code);
@@ -83,7 +83,7 @@ test('mx helper rejects disallowed source hosts and invalid client ips', functio
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg($script),
'mail-login-create-url mxtest evil.domena.pl evil 30 1 198.51.100.10 /'
'mail-login-create-url mxtest evil.domena.pl evil 30 1 198.51.100.10 / 1782810003'
);
exec($badHost, $outHost, $codeHost);
assert_same(1, $codeHost);
@@ -95,8 +95,9 @@ test('mx helper rejects disallowed source hosts and invalid client ips', functio
escapeshellarg($state),
escapeshellarg('h4.domena.pl'),
escapeshellarg($script),
'mail-login-create-url mxtest h4.domena.pl h4 30 1 999.999.999.999 /'
'mail-login-create-url mxtest h4.domena.pl h4 30 1 999.999.999.999 / 1782810004'
);
exec($badIp, $outIp, $codeIp);
assert_same(1, $codeIp);
assert_not_contains("\nBROKEN", file_get_contents($audit) ?: '');
});
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/RateLimiter.php';
test('rate limiter enforces limit across sequential requests', function (): void {
$limiter = new RateLimiter(TEST_TMP . '/rate-limit');
$limiter->assertAllowed('user-mxtest', 2, 60);
$limiter->assertAllowed('user-mxtest', 2, 60);
try {
$limiter->assertAllowed('user-mxtest', 2, 60);
throw new RuntimeException('Expected third request to be rate limited');
} catch (RuntimeException $e) {
assert_contains('rate limit', strtolower($e->getMessage()));
}
});
+7 -5
View File
@@ -15,20 +15,22 @@ test('remote login url client builds fixed ssh command with positional arguments
'LOGIN_KEY_TTL' => '30',
'USER_IP_BOUND' => '1',
]);
$request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/');
$request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/', 1782810000);
$command = RemoteLoginUrlClient::buildCommand($settings, $request);
assert_same('ssh', $command[0]);
assert_contains('UserKnownHostsFile=/secure/known_hosts', implode(' ', $command));
assert_contains('root@mx1.domena.pl', implode(' ', $command));
assert_same('mail-login-create-url', $command[count($command) - 8]);
assert_same('mxtest', $command[count($command) - 7]);
assert_same('1', $command[count($command) - 3]);
$pos = array_search('mail-login-create-url', $command, true);
assert_true(is_int($pos), 'Command marker missing');
assert_same('mxtest', $command[$pos + 1]);
assert_same('1', $command[$pos + 5]);
assert_same('1782810000', $command[$pos + 8]);
});
test('remote login url client validates helper output before returning it', function (): void {
$settings = Settings::fromArray(['MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.domena.pl:2222']);
$request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/');
$request = new LoginUrlRequest('mxtest', 'h4.domena.pl', 'h4', 30, true, '198.51.100.10', '/', 1782810000);
$client = new RemoteLoginUrlClient(function (array $command, int $timeout): ProcessResult {
return new ProcessResult(0, "URL: https://mx1.domena.pl:2222/api/login/url?key=SECRET\n", '');