2 Commits a238ad3b7f ... 3888e7fcde

Author SHA1 Message Date
  Roman Hergenreder 3888e7fcde settings values json instead of strings 3 weeks ago
  Roman Hergenreder 3851b7f289 CORS, trusted domain 3 weeks ago

+ 3 - 0
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"]);

+ 1 - 1
Core/API/MailAPI.class.php

@@ -19,7 +19,7 @@ namespace Core\API {
       if ($this->success) {
         $settings = $req->getResult()["settings"];
 
-        if (!isset($settings["mail_enabled"]) || $settings["mail_enabled"] !== "1") {
+        if (!isset($settings["mail_enabled"]) || !$settings["mail_enabled"]) {
           $this->createError("Mailing is not configured on this server yet.");
           return null;
         }

+ 14 - 0
Core/API/Request.class.php

@@ -161,8 +161,22 @@ 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)) {
+        // TODO: origins require a protocol, e.g. https:// or http:// as prefix. 
+        // should we force https for all origins? or make exceptions for localhost?
+        header("Access-Control-Allow-Origin: " . implode(", ", $trustedDomains));
+      }
+    }
+
     $this->params = array_merge([], $this->defaultParams);
     $this->success = false;
     $this->result = array();

+ 24 - 5
Core/API/SettingsAPI.class.php

@@ -3,10 +3,27 @@
 namespace Core\API {
 
   use Core\Objects\Context;
+  use Core\API\Parameter\ArrayType;
+  use Core\API\Parameter\Parameter;
+  use Core\API\Parameter\StringType;
 
   abstract class SettingsAPI extends Request {
+
+    protected array $predefinedKeys;
+
     public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
       parent::__construct($context, $externalCall, $params);
+
+      // TODO: improve this, additional validation for allowed chars etc.
+      // API parameters should be more configurable, e.g. allow regexes, min/max values for numbers, etc.
+      $this->predefinedKeys = [
+        "allowed_extensions" => new ArrayType("allowed_extensions", Parameter::TYPE_STRING),
+        "trusted_domains" => new ArrayType("allowed_extensions", Parameter::TYPE_STRING),
+        "user_registration_enabled" => new Parameter("user_registration_enabled", Parameter::TYPE_BOOLEAN),
+        "recaptcha_enabled" => new Parameter("recaptcha_enabled", Parameter::TYPE_BOOLEAN),
+        "mail_enabled" => new Parameter("mail_enabled", Parameter::TYPE_BOOLEAN),
+        "mail_port" => new Parameter("mail_port", Parameter::TYPE_INT)
+      ];
     }
   }
 }
@@ -67,14 +84,16 @@ namespace Core\API\Settings {
       }
 
       $paramKey = new StringType('key', 32);
-      $paramValue = new StringType('value', 1024, true, NULL);
+      $paramValueDefault = new StringType('value', 1024, true, NULL);
 
       $sql = $this->context->getSQL();
-      $query = $sql->insert("Settings", array("name", "value"));
+      $query = $sql->insert("Settings", ["name", "value"]);
       $keys = array();
       $deleteKeys = array();
 
       foreach ($values as $key => $value) {
+        $paramValue = $this->predefinedKeys[$key] ?? $paramValueDefault;
+
         if (!$paramKey->parseParam($key)) {
           $key = print_r($key, true);
           return $this->createError("Invalid Type for key in parameter settings: '$key' (Required: " . $paramKey->getTypeName() . ")");
@@ -85,7 +104,7 @@ namespace Core\API\Settings {
           return $this->createError("The property key should only contain alphanumeric characters, underscores and dashes");
         } else {
           if (!is_null($paramValue->value)) {
-            $query->addRow($paramKey->value, $paramValue->value);
+            $query->addRow($paramKey->value, json_encode($paramValue->value));
           } else {
             $deleteKeys[] = $paramKey->value;
           }
@@ -110,8 +129,8 @@ namespace Core\API\Settings {
 
       if (count($deleteKeys) !== count($keys)) {
         $query->onDuplicateKeyStrategy(new UpdateStrategy(
-          array("name"),
-          array("value" => new Column("value")))
+          ["name"],
+          ["value" => new Column("value")])
         );
 
 

+ 2 - 2
Core/API/Stats.class.php

@@ -29,8 +29,8 @@ class Stats extends Request {
 
     if ($this->success) {
       $settings = $req->getResult()["settings"];
-      $this->mailConfigured = ($settings["mail_enabled"] ?? "0") === "1";
-      $this->recaptchaConfigured = ($settings["recaptcha_enabled"] ?? "0") === "1";
+      $this->mailConfigured = $settings["mail_enabled"];
+      $this->recaptchaConfigured = $settings["recaptcha_enabled"];
     }
 
     return $this->success;

+ 4 - 1
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());
   }
 

+ 46 - 20
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));
@@ -59,7 +60,7 @@ class Settings {
     if ($res !== false && $res !== null) {
       $settings = array();
       foreach($res as $row) {
-        $settings[$row["name"]] = $row["value"];
+        $settings[$row["name"]] = json_decode($row["value"], true);
       }
       return $settings;
     } else {
@@ -75,7 +76,7 @@ class Settings {
     if ($res === false || $res === null) {
       return null;
     } else {
-      return (empty($res)) ? $defaultValue : $res[0]["value"];
+      return (empty($res)) ? $defaultValue : json_decode($res[0]["value"], true);
     }
   }
 
@@ -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;
@@ -129,7 +131,8 @@ class Settings {
       $this->mailSender = $result["mail_from"] ?? $this->mailSender;
       $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->allowedExtensions = $result["allowed_extensions"] ?? $this->allowedExtensions;
+      $this->trustedDomains = $result["trusted_domains"] ?? $this->trustedDomains;
       date_default_timezone_set($this->timeZone);
     }
 
@@ -137,22 +140,23 @@ class Settings {
   }
 
   public function addRows(Insert $query): void {
-    $query->addRow("site_name", $this->siteName, false, false)
-      ->addRow("base_url", $this->baseUrl, 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("mail_host", "", false, false)
-      ->addRow("mail_port", "", false, false)
-      ->addRow("mail_username", "", false, false)
-      ->addRow("mail_password", "", true, false)
-      ->addRow("mail_from", "", false, false)
-      ->addRow("mail_last_sync", "", false, false)
-      ->addRow("mail_footer", "", false, false)
+    $query->addRow("site_name", json_encode($this->siteName), false, false)
+      ->addRow("base_url", json_encode($this->baseUrl), false, false)
+      ->addRow("trusted_domains", json_encode($this->trustedDomains), false, false)
+      ->addRow("user_registration_enabled", json_encode($this->registrationAllowed), false, false)
+      ->addRow("installation_completed", json_encode($this->installationComplete), true, true)
+      ->addRow("time_zone", json_encode($this->timeZone), false, false)
+      ->addRow("recaptcha_enabled", json_encode($this->recaptchaEnabled), false, false)
+      ->addRow("recaptcha_public_key", json_encode($this->recaptchaPublicKey), false, false)
+      ->addRow("recaptcha_private_key", json_encode($this->recaptchaPrivateKey), true, false)
+      ->addRow("allowed_extensions", json_encode($this->allowedExtensions), false, false)
+      ->addRow("mail_host", '""', false, false)
+      ->addRow("mail_port", '587', false, false)
+      ->addRow("mail_username", '""', false, false)
+      ->addRow("mail_password", '""', true, false)
+      ->addRow("mail_from", '""', false, false)
+      ->addRow("mail_last_sync", '""', false, false)
+      ->addRow("mail_footer", '""', false, false)
       ->addRow("mail_async", false, 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;
+  }
 }

+ 8 - 8
Core/Documents/Install.class.php

@@ -472,7 +472,7 @@ namespace Documents\Install {
 
       if ($this->getParameter("skip") === "true") {
         $req = new \Core\API\Settings\Set($context);
-        $success = $req->execute(array("settings" => array("mail_enabled" => "0")));
+        $success = $req->execute(["settings" => ["mail_enabled" => false]]);
         $msg = $req->getLastError();
       } else {
 
@@ -538,13 +538,13 @@ namespace Documents\Install {
 
           if ($success) {
             $req = new \Core\API\Settings\Set($context);
-            $success = $req->execute(array("settings" => array(
-              "mail_enabled" => "1",
-              "mail_host" => "$address",
-              "mail_port" => "$port",
-              "mail_username" => "$username",
-              "mail_password" => "$password",
-            )));
+            $success = $req->execute(["settings" => [
+              "mail_enabled" => true,
+              "mail_host" => $address,
+              "mail_port" => $port,
+              "mail_username" => $username,
+              "mail_password" => $password,
+            ]]);
             $msg = $req->getLastError();
           }
         }

+ 1 - 3
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;
     }

+ 1 - 0
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 (* als Subdomain-Wildcard)",
   "time_zone" => "Zeitzone",
 
   # mail settings

+ 1 - 0
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 (* as subdomain-wildcard)",
   "time_zone" => "Time zone",
 
   # mail settings

+ 6 - 5
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 {

+ 1 - 1
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"],
 ];
 

+ 20 - 2
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());

+ 52 - 6
react/admin-panel/src/views/settings.js

@@ -12,7 +12,9 @@ import {
     TableHead,
     TableContainer,
     TableRow,
-    Tabs, TextField
+    Tabs, TextField,
+    Autocomplete,
+    Chip
 } from "@mui/material";
 import {Link} from "react-router-dom";
 import {
@@ -46,6 +48,7 @@ export default function SettingsView(props) {
           "user_registration_enabled",
           "time_zone",
           "allowed_extensions",
+          "trusted_domains",
       ],
       "mail": [
           "mail_enabled",
@@ -68,6 +71,7 @@ export default function SettingsView(props) {
     // data
     const [fetchSettings, setFetchSettings] = useState(true);
     const [settings, setSettings] = useState(null);
+    const [extra, setExtra] = useState({});
     const [uncategorizedKeys, setUncategorizedKeys] = useState([]);
 
     // ui
@@ -193,12 +197,9 @@ export default function SettingsView(props) {
         setFetchSettings(true);
         setNewKey("");
         setChanged(false);
+        setExtra({});
     }, []);
 
-    if (settings === null) {
-        return <CircularProgress />
-    }
-
     const parseBool = (v) => v !== undefined && (v === true || v === 1 || ["true", "1", "yes"].includes(v.toString().toLowerCase()));
 
     const renderTextInput = (key_name, disabled=false, props={}) => {
@@ -270,13 +271,54 @@ export default function SettingsView(props) {
         </SettingsFormGroup>
     }
 
+    const renderTextValuesInput = (key_name, disabled=false, props={}) => {
+
+        const finishTyping = () => {
+            console.log("finishTyping", key_name);
+            setExtra({...extra, [key_name]: ""});
+            if (extra[key_name]) {
+                setSettings({...settings, [key_name]: [...settings[key_name], extra[key_name]]});
+            }
+        }
+
+        return <SettingsFormGroup key={"form-" + key_name} {...props}>
+            <FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
+            <Autocomplete
+                clearIcon={false}
+                options={[]}
+                freeSolo
+                multiple
+                value={settings[key_name]}
+                onChange={(e, v) => setSettings({...settings, [key_name]: v})}
+                renderTags={(values, props) =>
+                    values.map((option, index) => (
+                        <Chip label={option} {...props({ index })} />
+                    ))
+                }
+                renderInput={(params) => <TextField
+                    {...params}
+                    value={extra[key_name] ?? ""}
+                    onChange={e => setExtra({...extra, [key_name]: e.target.value.trim()})}
+                    onKeyDown={e => {
+                        if (["Enter", "Tab", " "].includes(e.key)) {
+                            e.preventDefault();
+                            e.stopPropagation();
+                            finishTyping();
+                        }
+                    }}
+                    onBlur={finishTyping} />}
+            />
+        </SettingsFormGroup>
+    }
+
     const renderTab = () => {
         if (selectedTab === "general") {
             return [
                 renderTextInput("site_name"),
                 renderTextInput("base_url"),
+                renderTextValuesInput("trusted_domains"),
                 renderCheckBox("user_registration_enabled"),
-                renderTextInput("allowed_extensions"),
+                renderTextValuesInput("allowed_extensions"),
                 renderSelection("time_zone", TIME_ZONES),
             ];
         } else if (selectedTab === "mail") {
@@ -371,6 +413,10 @@ export default function SettingsView(props) {
         }
     }
 
+    if (settings === null) {
+        return <CircularProgress />
+    }
+
     return <>
         <div className={"content-header"}>
             <div className={"container-fluid"}>