From 3851b7f2898f6e229fdf86f0cd81f52c06d50442 Mon Sep 17 00:00:00 2001 From: Roman Hergenreder Date: Thu, 11 Apr 2024 11:51:50 -0400 Subject: [PATCH] CORS, trusted domain --- Core/API/LogsAPI.class.php | 3 +++ Core/API/Request.class.php | 12 ++++++++++ Core/API/SettingsAPI.class.php | 1 + Core/API/Swagger.class.php | 5 ++++- Core/Configuration/Settings.class.php | 30 +++++++++++++++++++++++-- Core/Elements/Document.class.php | 4 +--- Core/Localization/de_DE/settings.php | 1 + Core/Localization/en_US/settings.php | 1 + Core/Objects/Router/Router.class.php | 11 ++++----- cli.php | 2 +- index.php | 22 ++++++++++++++++-- react/admin-panel/src/views/settings.js | 2 ++ 12 files changed, 80 insertions(+), 14 deletions(-) diff --git a/Core/API/LogsAPI.class.php b/Core/API/LogsAPI.class.php index e42cdaf..7bb50c2 100644 --- a/Core/API/LogsAPI.class.php +++ b/Core/API/LogsAPI.class.php @@ -105,6 +105,8 @@ namespace Core\API\Logs { "message" => $content, "timestamp" => $date->format(Parameter::DATE_TIME_FORMAT) ]; + + $this->result["pagination"]["total"] += 1; } } } @@ -139,6 +141,7 @@ namespace Core\API\Logs { "timestamp" => (new \DateTime())->format(Parameter::DATE_TIME_FORMAT) ] ]; + $this->result["pagination"]["total"] += 1; } $this->loadFromFileSystem($this->result["logs"]); diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php index 640a91b..d2cbce7 100644 --- a/Core/API/Request.class.php +++ b/Core/API/Request.class.php @@ -161,8 +161,20 @@ abstract class Request { return true; } + protected function getCORS(): array { + $settings = $this->context->getSettings(); + return $settings->getTrustedDomains(); + } + public final function execute($values = array()): bool { + if ($this->externalCall) { + $trustedDomains = $this->getCORS(); + if (!empty($trustedDomains)) { + header("Access-Control-Allow-Origin: " . implode(", ", $trustedDomains)); + } + } + $this->params = array_merge([], $this->defaultParams); $this->success = false; $this->result = array(); diff --git a/Core/API/SettingsAPI.class.php b/Core/API/SettingsAPI.class.php index 241842f..43afd22 100644 --- a/Core/API/SettingsAPI.class.php +++ b/Core/API/SettingsAPI.class.php @@ -53,6 +53,7 @@ namespace Core\API\Settings { } } + // TODO: we need additional validation for built-in settings here, e.g. csv-values, bool values, etc. class Set extends SettingsAPI { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, array( diff --git a/Core/API/Swagger.class.php b/Core/API/Swagger.class.php index 2a09455..2258c55 100644 --- a/Core/API/Swagger.class.php +++ b/Core/API/Swagger.class.php @@ -13,9 +13,12 @@ class Swagger extends Request { $this->csrfTokenRequired = false; } + protected function getCORS(): array { + return ["*"]; + } + public function _execute(): bool { header("Content-Type: application/x-yaml"); - header("Access-Control-Allow-Origin: *"); die($this->getDocumentation()); } diff --git a/Core/Configuration/Settings.class.php b/Core/Configuration/Settings.class.php index 974b794..c99c6c2 100644 --- a/Core/Configuration/Settings.class.php +++ b/Core/Configuration/Settings.class.php @@ -22,6 +22,7 @@ class Settings { // general settings private string $siteName; private string $baseUrl; + private array $trustedDomains; private bool $registrationAllowed; private array $allowedExtensions; private string $timeZone; @@ -45,7 +46,7 @@ class Settings { } public static function getAll(?SQL $sql, ?string $pattern = null, bool $external = false): ?array { - $query = $sql->select("name", "value") ->from("Settings"); + $query = $sql->select("name", "value")->from("Settings"); if ($pattern) { $query->where(new CondRegex(new Column("name"), $pattern)); @@ -91,6 +92,7 @@ class Settings { // General $settings->siteName = "WebBase"; $settings->baseUrl = "$protocol://$hostname"; + $settings->trustedDomains = [$hostname]; $settings->allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'htm', 'html']; $settings->installationComplete = false; $settings->registrationAllowed = false; @@ -130,6 +132,7 @@ class Settings { $this->mailFooter = $result["mail_footer"] ?? $this->mailFooter; $this->mailAsync = $result["mail_async"] ?? $this->mailAsync; $this->allowedExtensions = explode(",", $result["allowed_extensions"] ?? strtolower(implode(",", $this->allowedExtensions))); + $this->trustedDomains = explode(",", $result["trusted_domains"] ?? strtolower(implode(",", $this->trustedDomains))); date_default_timezone_set($this->timeZone); } @@ -139,13 +142,14 @@ class Settings { public function addRows(Insert $query): void { $query->addRow("site_name", $this->siteName, false, false) ->addRow("base_url", $this->baseUrl, false, false) + ->addRow("trusted_domains", implode(",", $this->trustedDomains), false, false) ->addRow("user_registration_enabled", $this->registrationAllowed ? "1" : "0", false, false) ->addRow("installation_completed", $this->installationComplete ? "1" : "0", true, true) ->addRow("time_zone", $this->timeZone, false, false) ->addRow("recaptcha_enabled", $this->recaptchaEnabled ? "1" : "0", false, false) ->addRow("recaptcha_public_key", $this->recaptchaPublicKey, false, false) ->addRow("recaptcha_private_key", $this->recaptchaPrivateKey, true, false) - ->addRow("allowed_extensions", implode(",", $this->allowedExtensions), true, false) + ->addRow("allowed_extensions", implode(",", $this->allowedExtensions), false, false) ->addRow("mail_host", "", false, false) ->addRow("mail_port", "", false, false) ->addRow("mail_username", "", false, false) @@ -211,4 +215,26 @@ class Settings { public function getLogger(): Logger { return $this->logger; } + + public function isTrustedDomain(string $domain): bool { + $domain = strtolower($domain); + foreach ($this->trustedDomains as $trustedDomain) { + $trustedDomain = trim(strtolower($trustedDomain)); + if ($trustedDomain === $domain) { + return true; + } + + + // *.def.com <-> abc.def.com + if (startsWith($trustedDomain, "*.") && endsWith($domain, substr($trustedDomain, 1))) { + return true; + } + } + + return false; + } + + public function getTrustedDomains(): array { + return $this->trustedDomains; + } } \ No newline at end of file diff --git a/Core/Elements/Document.class.php b/Core/Elements/Document.class.php index cadd9a3..4e2e38e 100644 --- a/Core/Elements/Document.class.php +++ b/Core/Elements/Document.class.php @@ -21,7 +21,6 @@ abstract class Document { private bool $cspEnabled; private ?string $cspNonce; private array $cspWhitelist; - private string $domain; protected bool $searchable; protected array $languageModules; @@ -31,7 +30,6 @@ abstract class Document { $this->cspNonce = null; $this->databaseRequired = true; $this->cspWhitelist = []; - $this->domain = $this->getSettings()->getBaseUrl(); $this->logger = new Logger("Document", $this->getSQL()); $this->searchable = false; $this->languageModules = ["general"]; @@ -83,7 +81,7 @@ abstract class Document { public function addCSPWhitelist(string $path) { $urlParts = parse_url($path); if (!$urlParts || !isset($urlParts["host"])) { - $this->cspWhitelist[] = $this->domain . $path; + $this->cspWhitelist[] = getProtocol() . "://" . getCurrentHostName() . $path; } else { $this->cspWhitelist[] = $path; } diff --git a/Core/Localization/de_DE/settings.php b/Core/Localization/de_DE/settings.php index e29a520..d05589b 100644 --- a/Core/Localization/de_DE/settings.php +++ b/Core/Localization/de_DE/settings.php @@ -25,6 +25,7 @@ return [ "base_url" => "Basis URL", "user_registration_enabled" => "Benutzerregistrierung erlauben", "allowed_extensions" => "Erlaubte Dateierweiterungen", + "trusted_domains" => "Vertraute Ursprungs-Domains (Komma getrennt, * als Subdomain-Wildcard)", "time_zone" => "Zeitzone", # mail settings diff --git a/Core/Localization/en_US/settings.php b/Core/Localization/en_US/settings.php index a7ef151..520df97 100644 --- a/Core/Localization/en_US/settings.php +++ b/Core/Localization/en_US/settings.php @@ -25,6 +25,7 @@ return [ "base_url" => "Base URL", "user_registration_enabled" => "Allow user registration", "allowed_extensions" => "Allowed file extensions", + "trusted_domains" => "Trusted origin domains (comma separated, * as subdomain-wildcard)", "time_zone" => "Time zone", # mail settings diff --git a/Core/Objects/Router/Router.class.php b/Core/Objects/Router/Router.class.php index e31f1fe..02817c6 100644 --- a/Core/Objects/Router/Router.class.php +++ b/Core/Objects/Router/Router.class.php @@ -40,21 +40,22 @@ class Router { return $this->requestedUri; } - public function run(string $url): string { + public function run(string $url, array &$pathParams): ?Route { // TODO: do we want a global try cache and return status page 500 on any error? $this->requestedUri = $url; $url = strtok($url, "?"); foreach ($this->routes as $route) { - $pathParams = $route->match($url); - if ($pathParams !== false) { + $match = $route->match($url); + if ($match !== false) { $this->activeRoute = $route; - return $route->call($this, $pathParams); + $pathParams = $match; + return $this->activeRoute; } } - return $this->returnStatusCode(404); + return null; } public function returnStatusCode(int $code, array $params = []): string { diff --git a/cli.php b/cli.php index 1342b45..dc97aed 100755 --- a/cli.php +++ b/cli.php @@ -959,7 +959,7 @@ $registeredCommands = [ "mail" => ["handler" => "onMail", "description" => "send mails and process the pipeline", "requiresDocker" => true], "settings" => ["handler" => "onSettings", "description" => "change and view settings"], "impersonate" => ["handler" => "onImpersonate", "description" => "create a session and print cookies and csrf tokens", "requiresDocker" => true], - "frontend" => ["handler" => "onFrontend", "description" => "build and manage frontend modules", "requiresDocker" => true], + "frontend" => ["handler" => "onFrontend", "description" => "build and manage frontend modules"], "api" => ["handler" => "onAPI", "description" => "view and create API endpoints"], ]; diff --git a/index.php b/index.php index 301c9df..77ef994 100644 --- a/index.php +++ b/index.php @@ -18,7 +18,8 @@ use Core\Objects\Router\Router; if (!is_readable(getClassPath(Configuration::class))) { header("Content-Type: application/json"); - die(json_encode([ "success" => false, "msg" => "Configuration class is not readable, check permissions before proceeding." ])); + http_response_code(500); + die(json_encode(createError("Configuration class is not readable, check permissions before proceeding."))); } $context = Context::instance(); @@ -26,6 +27,8 @@ $sql = $context->initSQL(); $settings = $context->getSettings(); $context->parseCookies(); +$currentHostName = getCurrentHostName(); + $installation = !$sql || ($sql->isConnected() && !$settings->isInstalled()); $requestedUri = $_GET["site"] ?? $_GET["api"] ?? $_SERVER["REQUEST_URI"]; @@ -61,12 +64,27 @@ if ($installation) { } if ($router !== null) { + if ((!isset($_GET["site"]) || $_GET["site"] === "/") && isset($_GET["error"]) && is_string($_GET["error"]) && preg_match("/^\d+$/", $_GET["error"])) { $response = $router->returnStatusCode(intval($_GET["error"])); } else { try { - $response = $router->run($requestedUri); + $pathParams = []; + $route = $router->run($requestedUri, $pathParams); + if ($route === null) { + $response = $router->returnStatusCode(404); + } else if (!$settings->isTrustedDomain($currentHostName)) { + if ($route instanceof \Core\Objects\Router\ApiRoute) { + header("Content-Type: application/json"); + http_response_code(403); + $response = json_encode(createError("Untrusted Origin")); + } else { + $response = $router->returnStatusCode(403, ["message" => "Untrusted Origin"]); + } + } else { + $response = $route->call($router, $pathParams); + } } catch (\Throwable $e) { http_response_code(500); $router->getLogger()->error($e->getMessage()); diff --git a/react/admin-panel/src/views/settings.js b/react/admin-panel/src/views/settings.js index 101b1aa..faf50a2 100644 --- a/react/admin-panel/src/views/settings.js +++ b/react/admin-panel/src/views/settings.js @@ -46,6 +46,7 @@ export default function SettingsView(props) { "user_registration_enabled", "time_zone", "allowed_extensions", + "trusted_domains", ], "mail": [ "mail_enabled", @@ -275,6 +276,7 @@ export default function SettingsView(props) { return [ renderTextInput("site_name"), renderTextInput("base_url"), + renderTextInput("trusted_domains"), renderCheckBox("user_registration_enabled"), renderTextInput("allowed_extensions"), renderSelection("time_zone", TIME_ZONES),