feat: implement mail-login directadmin plugin

This commit is contained in:
Marek Miklewicz
2026-06-30 11:31:19 +02:00
parent f6506e3293
commit 77c5431db0
34 changed files with 1620 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
if (!defined('IN_DA_PLUGIN') || IN_DA_PLUGIN !== true) {
http_response_code(403);
echo 'Forbidden';
exit;
}
define('PLUGIN_ROOT', dirname(__DIR__));
define('PLUGIN_EXEC_DIR', __DIR__);
foreach ([
'Settings.php',
'Http.php',
'AuditLog.php',
'ClientIp.php',
'DirectAdminContext.php',
'KeyName.php',
'RateLimiter.php',
'RemoteLoginUrlClient.php',
'SourceAuthorizer.php',
'UrlValidator.php',
] as $file) {
require_once PLUGIN_EXEC_DIR . '/lib/' . $file;
}
try {
Http::bootstrapGlobals();
$action = basename((string)PLUGIN_ACTION);
$handlerPath = PLUGIN_EXEC_DIR . '/handlers/' . $action . '.php';
if (!is_file($handlerPath)) {
throw new RuntimeException('Missing handler');
}
$handler = require $handlerPath;
if (!is_callable($handler)) {
throw new RuntimeException('Invalid handler');
}
$handler();
} catch (Throwable $e) {
AuditLog::safeAppend(Settings::AUDIT_LOG, [
'event' => 'plugin_error',
'result' => 'failure',
'category' => get_class($e),
'message' => $e->getMessage(),
]);
Http::errorPage('Nie można teraz zalogować do serwera poczty.', 500);
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
return static function (): void {
if (Http::method() !== 'GET') {
Http::errorPage('Niedozwolona metoda żądania.', 405);
}
$settings = Settings::load(Settings::EXTERNAL_SETTINGS);
$ctx = DirectAdminContext::fromServer($_SERVER, $settings);
SourceAuthorizer::assertAllowed($ctx->sourceHost(), $settings);
$auditBase = [
'request_id' => bin2hex(random_bytes(8)),
'source_host' => $ctx->sourceHost(),
'server_name' => $ctx->serverName(),
'target_host' => $settings->targetBaseUrl(),
'username' => $ctx->username(),
'ttl' => $settings->loginKeyTtl(),
'user_ip_bound' => $settings->userIpBound() ? 1 : 0,
];
$clientIp = ClientIp::resolve($_SERVER, $settings);
$auditBase['client_ip'] = $clientIp;
$correlation = KeyName::build($ctx->serverName(), $ctx->username(), time());
$auditBase['correlation'] = $correlation;
$limiter = new RateLimiter(Settings::STATE_DIR . '/rate-limit');
$limiter->assertAllowed('user-' . $ctx->username(), $settings->rateLimitPerUserPerMinute(), 60);
$limiter->assertAllowed('ip-' . str_replace([':', '.'], '-', $clientIp), $settings->rateLimitPerIpPerMinute(), 60);
$redirectPath = UrlValidator::assertSafeRedirectPath($settings->redirectUrl());
$request = new LoginUrlRequest(
$ctx->username(),
$ctx->sourceHost(),
$ctx->serverName(),
$settings->loginKeyTtl(),
$settings->userIpBound(),
$clientIp,
$redirectPath
);
try {
$url = (new RemoteLoginUrlClient())->create($settings, $request);
AuditLog::append(Settings::AUDIT_LOG, $auditBase + [
'event' => 'create_login_url',
'result' => 'success',
]);
Http::redirectNoStore($url);
} catch (Throwable $e) {
AuditLog::append(Settings::AUDIT_LOG, $auditBase + [
'event' => 'create_login_url',
'result' => 'failure',
'category' => get_class($e),
'message' => $e->getMessage(),
]);
Http::errorPage('Nie można teraz zalogować do serwera poczty.', 502);
}
};
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
final class AuditLog
{
/**
* @param array<string,mixed> $fields
*/
public static function append(string $path, array $fields): void
{
self::safeAppend($path, $fields);
}
/**
* @param array<string,mixed> $fields
*/
public static function safeAppend(string $path, array $fields): void
{
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
$fields = self::redact($fields);
$fields['timestamp'] = gmdate('c');
$line = json_encode($fields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (is_string($line)) {
@file_put_contents($path, $line . "\n", FILE_APPEND | LOCK_EX);
@chmod($path, 0600);
}
}
/**
* @param array<string,mixed> $fields
* @return array<string,mixed>
*/
private static function redact(array $fields): array
{
foreach ($fields as $key => $value) {
$lower = strtolower((string)$key);
if (str_contains($lower, 'url') || str_contains($lower, 'key') || str_contains($lower, 'password') || str_contains($lower, 'secret')) {
$fields[$key] = '[redacted]';
} elseif (is_string($value)) {
$fields[$key] = preg_replace('/key=[A-Za-z0-9._-]+/', 'key=[redacted]', $value) ?? $value;
}
}
return $fields;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
final class ClientIp
{
/**
* @param array<string,mixed> $server
*/
public static function resolve(array $server, Settings $settings): string
{
$remote = self::validIp((string)($server['REMOTE_ADDR'] ?? ''));
if ($settings->clientIpMode() === 'direct') {
return $remote;
}
if (!self::ipInList($remote, $settings->trustedProxies())) {
throw new RuntimeException('REMOTE_ADDR is not a trusted proxy');
}
$xff = (string)($server['HTTP_X_FORWARDED_FOR'] ?? '');
foreach (explode(',', $xff) as $candidate) {
$candidate = trim($candidate);
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
return $candidate;
}
}
throw new RuntimeException('Missing valid forwarded client IP');
}
private static function validIp(string $ip): string
{
$ip = trim($ip);
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
throw new InvalidArgumentException('Invalid client IP');
}
return $ip;
}
/**
* @param list<string> $allowed
*/
private static function ipInList(string $ip, array $allowed): bool
{
foreach ($allowed as $entry) {
if ($entry === $ip) {
return true;
}
if (str_contains($entry, '/') && self::cidrContains($entry, $ip)) {
return true;
}
}
return false;
}
private static function cidrContains(string $cidr, string $ip): bool
{
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, '');
if (!filter_var($network, FILTER_VALIDATE_IP) || !preg_match('/^[0-9]+$/', $prefix)) {
return false;
}
$networkBin = inet_pton($network);
$ipBin = inet_pton($ip);
if ($networkBin === false || $ipBin === false || strlen($networkBin) !== strlen($ipBin)) {
return false;
}
$bits = (int)$prefix;
$bytes = intdiv($bits, 8);
$remainder = $bits % 8;
if ($bytes > 0 && substr($networkBin, 0, $bytes) !== substr($ipBin, 0, $bytes)) {
return false;
}
if ($remainder === 0) {
return true;
}
$mask = chr((0xff << (8 - $remainder)) & 0xff);
return (ord($networkBin[$bytes]) & ord($mask)) === (ord($ipBin[$bytes]) & ord($mask));
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
final class DirectAdminContext
{
private function __construct(
private string $username,
private string $sourceHost,
private string $serverName,
private string $method
) {
}
/**
* @param array<string,mixed> $server
*/
public static function fromServer(array $server, Settings $settings): self
{
$username = trim((string)($server['USERNAME'] ?? $server['USER'] ?? ''));
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'] ?? ''));
if ($host === '') {
throw new InvalidArgumentException('Missing source host');
}
$serverName = $settings->sourceServerName();
if ($serverName === '') {
$serverName = $settings->sanitizeServerName(explode('.', $host)[0] ?? '');
}
if ($serverName === '') {
throw new InvalidArgumentException('Invalid source server name');
}
return new self($username, $host, $serverName, strtoupper((string)($server['REQUEST_METHOD'] ?? 'GET')));
}
public function username(): string
{
return $this->username;
}
public function sourceHost(): string
{
return $this->sourceHost;
}
public function serverName(): string
{
return $this->serverName;
}
public function method(): string
{
return $this->method;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
final class Http
{
public static function bootstrapGlobals(): void
{
if (PHP_SAPI !== 'cli') {
return;
}
$_SERVER['REQUEST_METHOD'] = strtoupper((string)(getenv('REQUEST_METHOD') ?: 'GET'));
foreach ([
'USER',
'USERNAME',
'SESSION_ID',
'REQUEST_URI',
'HTTP_HOST',
'SERVER_NAME',
'SERVER_PORT',
'REMOTE_ADDR',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED_PROTO',
] as $key) {
if (!isset($_SERVER[$key])) {
$value = getenv($key);
if ($value !== false) {
$_SERVER[$key] = $value;
}
}
}
}
public static function method(): string
{
return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
}
public static function redirectNoStore(string $url): void
{
header('Cache-Control: no-store, private');
header('Pragma: no-cache');
header('Referrer-Policy: no-referrer');
header('X-Content-Type-Options: nosniff');
header('Location: ' . $url, true, 302);
exit;
}
public static function errorPage(string $message, int $status): void
{
http_response_code($status);
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store, private');
header('Referrer-Policy: no-referrer');
$safe = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<!doctype html><html lang="pl"><head><meta charset="utf-8">';
echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
echo '<title>Serwer poczty</title></head><body>';
echo '<h1>Serwer poczty</h1><p>' . $safe . '</p>';
echo '</body></html>';
exit;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
final class KeyName
{
public const MAX_LENGTH = 96;
public static function build(string $serverName, string $username, int $timestamp): string
{
$server = self::clean($serverName);
$user = self::clean($username);
$raw = 'autologin-' . $server . '-' . $user . '-' . $timestamp;
if (strlen($raw) <= self::MAX_LENGTH) {
return $raw;
}
$hash = substr(hash('sha256', $raw), 0, 10);
$suffix = '-' . $hash . '-' . $timestamp;
$bodyMax = self::MAX_LENGTH - strlen($suffix);
$body = rtrim(substr('autologin-' . $server . '-' . $user, 0, $bodyMax), '-');
return $body . $suffix;
}
private static function clean(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?? '';
$value = trim($value, '-');
return $value !== '' ? $value : 'unknown';
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
final class RateLimiter
{
public function __construct(private string $dir)
{
}
public function assertAllowed(string $bucket, int $limit, int $windowSeconds): void
{
if ($limit < 1) {
throw new RuntimeException('Rate limit is invalid');
}
if (!is_dir($this->dir)) {
mkdir($this->dir, 0700, true);
}
$file = $this->dir . '/' . preg_replace('/[^A-Za-z0-9._-]/', '_', $bucket) . '.json';
$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;
}
}
}
}
if (count($events) >= $limit) {
throw new RuntimeException('Rate limit exceeded');
}
$events[] = $now;
file_put_contents($file, json_encode($events), LOCK_EX);
@chmod($file, 0600);
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
final class LoginUrlRequest
{
public function __construct(
public readonly string $username,
public readonly string $sourceHost,
public readonly string $serverName,
public readonly int $ttl,
public readonly bool $userIpBound,
public readonly string $clientIp,
public readonly string $redirectPath
) {
}
}
final class ProcessResult
{
public function __construct(
public readonly int $exitCode,
public readonly string $stdout,
public readonly string $stderr
) {
}
}
final class RemoteLoginUrlClient
{
/** @var callable(array<int,string>,int):ProcessResult */
private $runner;
/**
* @param null|callable(array<int,string>,int):ProcessResult $runner
*/
public function __construct(?callable $runner = null)
{
$this->runner = $runner ?? [self::class, 'runProcess'];
}
/**
* @return list<string>
*/
public static function buildCommand(Settings $settings, LoginUrlRequest $request): array
{
return [
'ssh',
'-o',
'BatchMode=yes',
'-o',
'StrictHostKeyChecking=yes',
'-o',
'UserKnownHostsFile=' . $settings->sshKnownHosts(),
'-o',
'IdentitiesOnly=yes',
'-i',
$settings->sshIdentity(),
'-p',
(string)$settings->sshPort(),
$settings->sshUser() . '@' . $settings->sshHost(),
'mail-login-create-url',
$request->username,
$request->sourceHost,
$request->serverName,
(string)$request->ttl,
$request->userIpBound ? '1' : '0',
$request->clientIp,
$request->redirectPath,
];
}
public function create(Settings $settings, LoginUrlRequest $request): string
{
$command = self::buildCommand($settings, $request);
$result = ($this->runner)($command, $settings->connectTimeout());
if (!$result instanceof ProcessResult) {
throw new RuntimeException('Invalid process runner result');
}
if ($result->exitCode !== 0) {
throw new RuntimeException('MX helper failed');
}
if (!preg_match('/https:\/\/\S+/', $result->stdout, $match)) {
throw new RuntimeException('MX helper did not return a login URL');
}
try {
return UrlValidator::assertGeneratedLoginUrl(trim($match[0]), $settings);
} catch (InvalidArgumentException $e) {
throw new RuntimeException($e->getMessage(), 0, $e);
}
}
/**
* @param list<string> $command
*/
public static function runProcess(array $command, int $timeout): ProcessResult
{
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($command, $descriptors, $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Cannot start MX helper');
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$stdout = '';
$stderr = '';
$deadline = time() + $timeout;
do {
$stdout .= stream_get_contents($pipes[1]) ?: '';
$stderr .= stream_get_contents($pipes[2]) ?: '';
$status = proc_get_status($process);
if (!$status['running']) {
break;
}
if (time() >= $deadline) {
proc_terminate($process);
foreach ([$pipes[1], $pipes[2]] as $pipe) {
fclose($pipe);
}
proc_close($process);
throw new RuntimeException('MX helper timed out');
}
usleep(100000);
} while (true);
$stdout .= stream_get_contents($pipes[1]) ?: '';
$stderr .= stream_get_contents($pipes[2]) ?: '';
fclose($pipes[1]);
fclose($pipes[2]);
$exit = proc_close($process);
return new ProcessResult($exit, substr($stdout, 0, 8192), substr($stderr, 0, 8192));
}
}
+246
View File
@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
final class Settings
{
public const BASE_DIR = '/usr/local/hitme_plugins/mail-login';
public const CONFIG_DIR = self::BASE_DIR . '/config';
public const SECRET_DIR = self::BASE_DIR . '/secrets';
public const LOG_DIR = self::BASE_DIR . '/logs';
public const STATE_DIR = self::BASE_DIR . '/state';
public const AUDIT_LOG = self::LOG_DIR . '/audit.log';
public const EXTERNAL_SETTINGS = self::CONFIG_DIR . '/plugin-settings.conf';
/** @var array<string,string> */
private array $values;
/**
* @param array<string,string> $values
*/
private function __construct(array $values)
{
$this->values = $values;
$this->validate();
}
public static function load(string $path): self
{
$values = self::defaults();
if (is_file($path)) {
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$values[trim($key)] = trim($value);
}
}
}
return new self($values);
}
/**
* @param array<string,string> $values
*/
public static function fromArray(array $values): self
{
return new self(array_merge(self::defaults(), $values));
}
/**
* @return array<string,string>
*/
public static function defaults(): array
{
return [
'DEFAULT_ENABLE' => 'true',
'MAIL_LOGIN_METHOD' => 'login_url',
'MAIL_LOGIN_TARGET_NAME' => 'mx1',
'MAIL_LOGIN_TARGET_BASE_URL' => 'https://mx1.mojserwer.pl:2222',
'MAIL_LOGIN_REDIRECT_URL' => '/',
'LOGIN_KEY_TTL' => '30',
'USER_IP_BOUND' => '1',
'MAIL_LOGIN_ALLOWED_SOURCE_HOSTS' => 's1.abc.pl,serwer.mojserwer.pl,serwer2.xxb.ovh',
'MAIL_LOGIN_SOURCE_SERVER_NAME' => '',
'MAIL_LOGIN_CLIENT_IP_MODE' => 'direct',
'MAIL_LOGIN_TRUSTED_PROXIES' => '',
'MAIL_LOGIN_REMOTE_TRANSPORT' => 'ssh',
'MAIL_LOGIN_SSH_HOST' => 'mx1.mojserwer.pl',
'MAIL_LOGIN_SSH_PORT' => '22',
'MAIL_LOGIN_SSH_USER' => 'root',
'MAIL_LOGIN_SSH_IDENTITY' => self::SECRET_DIR . '/mx1_ed25519',
'MAIL_LOGIN_SSH_KNOWN_HOSTS' => self::SECRET_DIR . '/known_hosts',
'MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS' => '5',
'MAIL_LOGIN_RATE_LIMIT_PER_USER_PER_MINUTE' => '5',
'MAIL_LOGIN_RATE_LIMIT_PER_IP_PER_MINUTE' => '20',
'MAIL_LOGIN_ALLOW_PASSWORD_FALLBACK' => 'false',
];
}
public static function normalizeHost(string $host): string
{
$host = trim($host);
$host = preg_replace('/^https?:\/\//i', '', $host) ?? $host;
$host = preg_replace('/\/.*$/', '', $host) ?? $host;
if (str_starts_with($host, '[')) {
$end = strpos($host, ']');
if ($end !== false) {
return strtolower(substr($host, 1, $end - 1));
}
}
$host = preg_replace('/:[0-9]+$/', '', $host) ?? $host;
return strtolower(trim($host, ". \t\n\r\0\x0B"));
}
public function getString(string $key, string $default = ''): string
{
return $this->values[$key] ?? $default;
}
public function targetBaseUrl(): string
{
return rtrim($this->getString('MAIL_LOGIN_TARGET_BASE_URL'), '/');
}
public function redirectUrl(): string
{
return $this->getString('MAIL_LOGIN_REDIRECT_URL', '/');
}
public function loginKeyTtl(): int
{
return (int)$this->getString('LOGIN_KEY_TTL', '30');
}
public function userIpBound(): bool
{
return $this->getString('USER_IP_BOUND', '1') === '1';
}
/**
* @return list<string>
*/
public function allowedSourceHosts(): array
{
return $this->csvHosts('MAIL_LOGIN_ALLOWED_SOURCE_HOSTS');
}
public function sourceServerName(): string
{
return $this->sanitizeServerName($this->getString('MAIL_LOGIN_SOURCE_SERVER_NAME', ''));
}
public function clientIpMode(): string
{
return $this->getString('MAIL_LOGIN_CLIENT_IP_MODE', 'direct');
}
/**
* @return list<string>
*/
public function trustedProxies(): array
{
return $this->csvHosts('MAIL_LOGIN_TRUSTED_PROXIES');
}
public function sshHost(): string
{
return self::normalizeHost($this->getString('MAIL_LOGIN_SSH_HOST'));
}
public function sshPort(): int
{
return (int)$this->getString('MAIL_LOGIN_SSH_PORT', '22');
}
public function sshUser(): string
{
return $this->getString('MAIL_LOGIN_SSH_USER', 'root');
}
public function sshIdentity(): string
{
return $this->getString('MAIL_LOGIN_SSH_IDENTITY');
}
public function sshKnownHosts(): string
{
return $this->getString('MAIL_LOGIN_SSH_KNOWN_HOSTS');
}
public function connectTimeout(): int
{
return (int)$this->getString('MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS', '5');
}
public function rateLimitPerUserPerMinute(): int
{
return (int)$this->getString('MAIL_LOGIN_RATE_LIMIT_PER_USER_PER_MINUTE', '5');
}
public function rateLimitPerIpPerMinute(): int
{
return (int)$this->getString('MAIL_LOGIN_RATE_LIMIT_PER_IP_PER_MINUTE', '20');
}
public function sanitizeServerName(string $name): string
{
$name = strtolower(trim($name));
$name = preg_replace('/[^a-z0-9-]+/', '-', $name) ?? '';
$name = trim($name, '-');
if ($name === '') {
return '';
}
return substr($name, 0, 32);
}
/**
* @return list<string>
*/
private function csvHosts(string $key): array
{
$raw = $this->getString($key, '');
if ($raw === '') {
return [];
}
$out = [];
foreach (explode(',', $raw) as $item) {
$host = self::normalizeHost($item);
if ($host !== '') {
$out[] = $host;
}
}
return array_values(array_unique($out));
}
private function validate(): void
{
$ttlRaw = $this->getString('LOGIN_KEY_TTL', '');
if (!preg_match('/^[0-9]+$/', $ttlRaw)) {
throw new InvalidArgumentException('LOGIN_KEY_TTL must be an integer');
}
$ttl = (int)$ttlRaw;
if ($ttl < 5 || $ttl > 300) {
throw new InvalidArgumentException('LOGIN_KEY_TTL must be between 5 and 300 seconds');
}
if (!in_array($this->getString('USER_IP_BOUND', ''), ['0', '1'], true)) {
throw new InvalidArgumentException('USER_IP_BOUND must be 0 or 1');
}
if (!in_array($this->clientIpMode(), ['direct', 'trusted_proxy'], true)) {
throw new InvalidArgumentException('MAIL_LOGIN_CLIENT_IP_MODE must be direct or trusted_proxy');
}
$base = parse_url($this->targetBaseUrl());
if (!is_array($base) || ($base['scheme'] ?? '') !== 'https' || empty($base['host'])) {
throw new InvalidArgumentException('MAIL_LOGIN_TARGET_BASE_URL must be an https URL');
}
if ($this->sshPort() < 1 || $this->sshPort() > 65535) {
throw new InvalidArgumentException('MAIL_LOGIN_SSH_PORT is invalid');
}
if ($this->connectTimeout() < 1 || $this->connectTimeout() > 30) {
throw new InvalidArgumentException('MAIL_LOGIN_CONNECT_TIMEOUT_SECONDS is invalid');
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
final class SourceAuthorizer
{
public static function assertAllowed(string $sourceHost, Settings $settings): void
{
$allowed = $settings->allowedSourceHosts();
if ($allowed === []) {
throw new RuntimeException('No source host allowlist configured');
}
if (!in_array(Settings::normalizeHost($sourceHost), $allowed, true)) {
throw new RuntimeException('Source host is not allowed');
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
final class UrlValidator
{
public static function assertSafeRedirectPath(string $path): string
{
$path = trim($path);
if ($path === '' || !str_starts_with($path, '/') || str_starts_with($path, '//') || str_contains($path, '\\')) {
throw new InvalidArgumentException('Invalid redirect path');
}
if (!preg_match('/^\/[A-Za-z0-9._~%\/?=+-]*$/', $path)) {
throw new InvalidArgumentException('Invalid redirect path');
}
if (preg_match('/^[a-z][a-z0-9+.-]*:\/\//i', $path)) {
throw new InvalidArgumentException('Invalid redirect path');
}
return $path;
}
public static function assertGeneratedLoginUrl(string $url, Settings $settings): string
{
$parts = parse_url($url);
$base = parse_url($settings->targetBaseUrl());
if (!is_array($parts) || !is_array($base)) {
throw new InvalidArgumentException('Invalid login URL');
}
$scheme = strtolower((string)($parts['scheme'] ?? ''));
$host = strtolower((string)($parts['host'] ?? ''));
$baseHost = strtolower((string)($base['host'] ?? ''));
$port = (int)($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
$basePort = (int)($base['port'] ?? 443);
parse_str((string)($parts['query'] ?? ''), $query);
if ($scheme !== 'https' || $host !== $baseHost || $port !== $basePort || ($parts['path'] ?? '') !== '/api/login/url' || empty($query['key'])) {
throw new InvalidArgumentException('Invalid login URL');
}
return $url;
}
}