fix: address security review findings
This commit is contained in:
@@ -28,7 +28,8 @@ return static function (): void {
|
||||
|
||||
$clientIp = ClientIp::resolve($_SERVER, $settings);
|
||||
$auditBase['client_ip'] = $clientIp;
|
||||
$correlation = KeyName::build($ctx->serverName(), $ctx->username(), time());
|
||||
$createdAt = time();
|
||||
$correlation = KeyName::build($ctx->serverName(), $ctx->username(), $createdAt);
|
||||
$auditBase['correlation'] = $correlation;
|
||||
|
||||
$limiter = new RateLimiter(Settings::STATE_DIR . '/rate-limit');
|
||||
@@ -43,7 +44,8 @@ return static function (): void {
|
||||
$settings->loginKeyTtl(),
|
||||
$settings->userIpBound(),
|
||||
$clientIp,
|
||||
$redirectPath
|
||||
$redirectPath,
|
||||
$createdAt
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,11 +16,11 @@ final class DirectAdminContext
|
||||
*/
|
||||
public static function fromServer(array $server, Settings $settings): self
|
||||
{
|
||||
$username = trim((string)($server['USERNAME'] ?? $server['USER'] ?? ''));
|
||||
$username = trim((string)($server['USERNAME'] ?? ''));
|
||||
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/', $username)) {
|
||||
throw new InvalidArgumentException('Invalid DirectAdmin username');
|
||||
}
|
||||
$host = Settings::normalizeHost((string)($server['HTTP_HOST'] ?? $server['SERVER_NAME'] ?? ''));
|
||||
$host = self::trustedSourceHost($server, $settings);
|
||||
if ($host === '') {
|
||||
throw new InvalidArgumentException('Missing source host');
|
||||
}
|
||||
@@ -34,6 +34,23 @@ final class DirectAdminContext
|
||||
return new self($username, $host, $serverName, strtoupper((string)($server['REQUEST_METHOD'] ?? 'GET')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $server
|
||||
*/
|
||||
private static function trustedSourceHost(array $server, Settings $settings): string
|
||||
{
|
||||
$configuredName = $settings->sourceServerName();
|
||||
if ($configuredName !== '') {
|
||||
foreach ($settings->allowedSourceHosts() as $allowedHost) {
|
||||
$label = $settings->sanitizeServerName(explode('.', $allowedHost)[0] ?? '');
|
||||
if ($label === $configuredName) {
|
||||
return $allowedHost;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Settings::normalizeHost((string)($server['SERVER_NAME'] ?? ''));
|
||||
}
|
||||
|
||||
public function username(): string
|
||||
{
|
||||
return $this->username;
|
||||
|
||||
@@ -16,23 +16,37 @@ final class RateLimiter
|
||||
mkdir($this->dir, 0700, true);
|
||||
}
|
||||
$file = $this->dir . '/' . preg_replace('/[^A-Za-z0-9._-]/', '_', $bucket) . '.json';
|
||||
$handle = fopen($file, 'c+');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Cannot open rate limit bucket');
|
||||
}
|
||||
if (!flock($handle, LOCK_EX)) {
|
||||
fclose($handle);
|
||||
throw new RuntimeException('Cannot lock rate limit bucket');
|
||||
}
|
||||
$now = time();
|
||||
$events = [];
|
||||
if (is_file($file)) {
|
||||
$decoded = json_decode((string)file_get_contents($file), true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $event) {
|
||||
if (is_int($event) && $event > $now - $windowSeconds) {
|
||||
$events[] = $event;
|
||||
}
|
||||
$contents = stream_get_contents($handle);
|
||||
$decoded = json_decode(is_string($contents) ? $contents : '', true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $event) {
|
||||
if (is_int($event) && $event > $now - $windowSeconds) {
|
||||
$events[] = $event;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count($events) >= $limit) {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
throw new RuntimeException('Rate limit exceeded');
|
||||
}
|
||||
$events[] = $now;
|
||||
file_put_contents($file, json_encode($events), LOCK_EX);
|
||||
ftruncate($handle, 0);
|
||||
rewind($handle);
|
||||
fwrite($handle, json_encode($events) ?: '[]');
|
||||
fflush($handle);
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
@chmod($file, 0600);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ final class LoginUrlRequest
|
||||
public readonly int $ttl,
|
||||
public readonly bool $userIpBound,
|
||||
public readonly string $clientIp,
|
||||
public readonly string $redirectPath
|
||||
public readonly string $redirectPath,
|
||||
public readonly int $createdAt
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -66,6 +67,7 @@ final class RemoteLoginUrlClient
|
||||
$request->userIpBound ? '1' : '0',
|
||||
$request->clientIp,
|
||||
$request->redirectPath,
|
||||
(string)$request->createdAt,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+31
-1
@@ -153,7 +153,7 @@ final class Settings
|
||||
*/
|
||||
public function trustedProxies(): array
|
||||
{
|
||||
return $this->csvHosts('MAIL_LOGIN_TRUSTED_PROXIES');
|
||||
return $this->csvNetworks('MAIL_LOGIN_TRUSTED_PROXIES');
|
||||
}
|
||||
|
||||
public function sshHost(): string
|
||||
@@ -226,6 +226,36 @@ final class Settings
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function csvNetworks(string $key): array
|
||||
{
|
||||
$raw = $this->getString($key, '');
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (explode(',', $raw) as $item) {
|
||||
$item = trim($item);
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($item, '/')) {
|
||||
[$ip, $prefix] = explode('/', $item, 2);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) && preg_match('/^[0-9]+$/', $prefix)) {
|
||||
$out[] = $ip . '/' . $prefix;
|
||||
}
|
||||
} else {
|
||||
$host = self::normalizeHost($item);
|
||||
if ($host !== '') {
|
||||
$out[] = $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
private function validate(): void
|
||||
{
|
||||
$ttlRaw = $this->getString('LOGIN_KEY_TTL', '');
|
||||
|
||||
Reference in New Issue
Block a user