{"id":"GHSA-q4rm-m6xh-5pv7","summary":"Froxlor customer can create MySQL databases on disallowed servers via Mysqls.add API","details":"## Summary\n\nThe `Mysqls.add` API command (`lib/Froxlor/Api/Commands/Mysqls.php`) accepts a customer-controlled `mysql_server` parameter and only validates that the value is numeric and that the server index exists in `userdata.inc.php`. It never checks the value against the calling customer's `allowed_mysqlserver` allowlist. A customer can therefore create a database, plus a MySQL user with a password they choose, on any MySQL server the operator has configured — including servers that were explicitly excluded from that customer (e.g. a separate cluster, premium-tier host, or another tenant pool). The same `allowed_mysqlserver` check is correctly enforced in `MysqlServer::get()` / `MysqlServer::listing()` and in the customer-facing UI (`customer_mysql.php`), confirming the omission is a bug, not by-design.\n\n## Details\n\n**Vulnerable code path** — `lib/Froxlor/Api/Commands/Mysqls.php:69-99` (`add()`):\n\n```php\npublic function add()\n{\n    if (($this-\u003egetUserDetail('mysqls_used') \u003c $this-\u003egetUserDetail('mysqls') || ...) {\n        ...\n        $customer = $this-\u003egetCustomerData('mysqls');                       // line 80\n        $dbserver = $this-\u003egetParam('mysql_server', true,                    // line 81 — user-controlled\n            $this-\u003egetDefaultMySqlServer($customer));\n        ...\n        $dbserver = Validate::validate($dbserver, ..., '/^[0-9]+$/', ...);   // line 92 — numeric only\n        Database::needRoot(true, $dbserver, false);                          // line 93 — root ctx for ANY index\n        Database::needSqlData();\n        $sql_root = Database::getSqlData();\n        Database::needRoot(false);\n        if (!is_array($sql_root)) {                                          // line 97 — only existence check\n            throw new Exception(\"Database server with index #\" . $dbserver . \" is unknown\", 404);\n        }\n        ...\n        $username = $dbm-\u003ecreateDatabase($newdb_params['loginname'], $password,\n            $dbserver, ...);                                                 // line 116/118 — DB+user created\n        ...\n        Database::pexecute($stmt, [\"customerid\"=\u003e$customer['customerid'], ..., \"dbserver\"=\u003e$dbserver], ...);\n    }\n}\n```\n\nThe `$customer['allowed_mysqlserver']` field IS read on line 80 but is only consumed by `getDefaultMySqlServer()` (lines 566-573) to compute a default when the request omits `mysql_server`. As soon as the client supplies the parameter, the default path is skipped and no further authorization gate runs.\n\n**Cross-file evidence the check is intended elsewhere:**\n\n- `lib/Froxlor/Api/Commands/MysqlServer.php:319-323` — `get()` rejects with HTTP 405 when `$dbserver` is not in `allowed_mysqlserver`:\n  ```php\n  if ($this-\u003eisAdmin() == false) {\n      $allowed_mysqls = json_decode($this-\u003egetUserDetail('allowed_mysqlserver'), true);\n      if ($allowed_mysqls === false || empty($allowed_mysqls) || !in_array($dbserver, $allowed_mysqls)) {\n          throw new Exception(\"You cannot access this resource\", 405);\n      }\n      ...\n  }\n  ```\n- `lib/Froxlor/Api/Commands/MysqlServer.php:252-257` — same allowlist filter on `listing()`.\n- `customer_mysql.php:222` — UI rejects with `Response::dynamicError('No permission')` when `empty($allowed_mysqlservers)`.\n\n**Chain of execution (attacker → impact):**\n\n1. Customer authenticates to `api.php` with apikey/secret. The only API gate is `cust_api_allowed`; `allowed_mysqlserver` is not consulted at auth time.\n2. Customer sends JSON `{\"command\":\"Mysqls.add\",\"params\":{\"mysql_password\":\"\u003cvalid\u003e\",\"mysql_server\":\u003cdisallowed_idx\u003e}}`.\n3. `Mysqls.php:71` quota check passes (`mysqls_used \u003c mysqls`).\n4. `Mysqls.php:80` `getCustomerData('mysqls')` returns the caller's own row.\n5. `Mysqls.php:81` `$dbserver` is set from the request (default-fallback path skipped).\n6. `Mysqls.php:92` numeric regex passes.\n7. `Mysqls.php:93-99` `Database::needRoot(true, $dbserver, false)` switches to the root context of the attacker-chosen server; existence check passes.\n8. `Mysqls.php:116/118` `DbManager::createDatabase(...)` runs against the disallowed server using stored root credentials, creating the DB and granting the supplied password to `\u003cloginname\u003e_\u003csqlN\u003e` (DbManager.php:177-218).\n9. `Mysqls.php:127-141` inserts a row into `TABLE_PANEL_DATABASES` with the attacker's `customerid` and the disallowed `dbserver`, allowing later management via `Mysqls.get/update/delete` (which only filter by `customerid` for non-admins, e.g. `Mysqls.php:282`).\n\n## PoC\n\nPreconditions on the target instance:\n- ≥2 MySQL servers configured in `lib/userdata.inc.php` (e.g. index 0 default, index 1 internal/premium).\n- Customer X with `allowed_mysqlserver=[0]`, `cust_api_allowed=1`, `mysqls \u003e 0`, and an issued API key (`apikey:secret`).\n\nRequest — customer creates a database on server `1`, which is *not* in their allowlist:\n\n```bash\ncurl -k -u 'CUST_APIKEY:CUST_SECRET' \\\n  -H 'Content-Type: application/json' \\\n  -X POST \\\n  -d '{\"command\":\"Mysqls.add\",\"params\":{\"mysql_password\":\"ValidP@ssw0rd!\",\"mysql_server\":1}}' \\\n  https://froxlor.example.com/api.php\n```\n\nExpected (mirroring `MysqlServer.get()` behaviour): `HTTP 405 — \"You cannot access this resource\"`.\nActual: `HTTP 200` with the full database record, e.g.:\n\n```json\n{\"data\":{\"id\":42,\"customerid\":\u003ccust_id\u003e,\"databasename\":\"\u003cloginname\u003e_sql1\",\"dbserver\":1,...}}\n```\n\nVerify the credentials work on the forbidden server:\n\n```bash\nmysql -h server1.host -u \u003cloginname\u003e_sql1 -p   # password: ValidP@ssw0rd!\nmysql\u003e SHOW DATABASES;        # the new DB is present\nmysql\u003e USE \u003cloginname\u003e_sql1;  # full access to the newly-created DB\n```\n\nThe customer can subsequently manage the DB via `Mysqls.get`, `Mysqls.update`, and `Mysqls.delete` — those non-admin code paths filter only by `customerid` (`Mysqls.php:282-289`, `Mysqls.php:380-391`), which matches.\n\n## Impact\n\n- Bypass of the per-customer MySQL-server allowlist (`allowed_mysqlserver`) enforced by the admin/reseller. The authorization model is fully defeated for the `add` operation.\n- The customer obtains valid MySQL credentials on a server the operator explicitly excluded for them — possibly an internal/separate cluster, billing tier, premium-only host, or a server provisioned for a different tenant pool.\n- The customer can persist a DB on the forbidden server (resource and policy bypass), then read/write data there, and continue to manage it through `Mysqls.update` / `Mysqls.delete`.\n- Impact is bounded: privileges granted by `DbManager::grantPrivilegesTo` apply only to the new `\u003cloginname\u003e_sqlN` database, so no cross-tenant data exposure on the forbidden server. The damage is policy bypass, resource consumption on the forbidden server, and credential persistence there.\n\n## Recommended Fix\n\nMirror the allowlist check already present in `MysqlServer::get()`. After the numeric validation on `Mysqls.php:92`, before `Database::needRoot(...)`, add for non-admin callers:\n\n```php\n// validate whether the dbserver exists\n$dbserver = Validate::validate($dbserver, html_entity_decode(lng('mysql.mysql_server')), '/^[0-9]+$/', '', 0, true);\n\n// enforce per-customer allowed_mysqlserver allowlist (parity with MysqlServer::get())\nif (!$this-\u003eisAdmin()) {\n    $allowed = json_decode($customer['allowed_mysqlserver'] ?? '[]', true);\n    if (!is_array($allowed) || empty($allowed)\n        || !in_array((int)$dbserver, array_map('intval', $allowed), true)) {\n        throw new Exception('You cannot access this resource', 405);\n    }\n}\n\nDatabase::needRoot(true, $dbserver, false);\n```\n\nAudit `Mysqls::update()`, `Mysqls::delete()`, and `Mysqls::get()` for the same gap: those endpoints accept `mysql_server` and ultimately call `Database::needRoot(true, $result['dbserver'], false)` on the row's stored value. Once the row exists with a forbidden `dbserver`, those paths execute against the forbidden server unchallenged. Consider rejecting any non-admin operation whose target row's `dbserver` is outside `allowed_mysqlserver`, even if the row already exists, to defend in depth.","aliases":["CVE-2026-90935"],"modified":"2026-09-15T03:56:00.268778886Z","published":"2026-07-02T19:23:49Z","database_specific":{"cwe_ids":["CWE-285"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-07-02T19:23:49Z","nvd_published_at":null},"references":[{"type":"WEB","url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-q4rm-m6xh-5pv7"},{"type":"PACKAGE","url":"https://github.com/froxlor/froxlor"}],"affected":[{"package":{"name":"froxlor/froxlor","ecosystem":"Packagist","purl":"pkg:composer/froxlor/froxlor"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.3.7"}]}],"versions":["0.10.0","0.10.0-rc1","0.10.0-rc2","0.10.1","0.10.10","0.10.11","0.10.12","0.10.13","0.10.14","0.10.15","0.10.16","0.10.17","0.10.18","0.10.19","0.10.2","0.10.20","0.10.21","0.10.22","0.10.23","0.10.23.1","0.10.24","0.10.25","0.10.26","0.10.27","0.10.28","0.10.29","0.10.29.1","0.10.3","0.10.30","0.10.31","0.10.32","0.10.33","0.10.34","0.10.34.1","0.10.35","0.10.35.1","0.10.36","0.10.37","0.10.38","0.10.38.1","0.10.38.2","0.10.38.3","0.10.4","0.10.5","0.10.6","0.10.7","0.10.8","0.10.9","2.0.0","2.0.1","2.0.10","2.0.11","2.0.12","2.0.13","2.0.14","2.0.15","2.0.16","2.0.17","2.0.18","2.0.19","2.0.2","2.0.20","2.0.21","2.0.22","2.0.23","2.0.24","2.0.3","2.0.4","2.0.5","2.0.6","2.0.7","2.0.8","2.0.9","2.1.0","2.1.0-beta1","2.1.0-beta2","2.1.0-rc1","2.1.0-rc2","2.1.0-rc3","2.1.1","2.1.2","2.1.3","2.1.4","2.1.5","2.1.6","2.1.7","2.1.8","2.1.9","2.2.0","2.2.0-rc1","2.2.0-rc2","2.2.0-rc3","2.2.1","2.2.2","2.2.3","2.2.4","2.2.5","2.2.6","2.2.7","2.2.8","2.3.0","2.3.0-rc1","2.3.1","2.3.2","2.3.3","2.3.4","2.3.5","2.3.6"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-q4rm-m6xh-5pv7/GHSA-q4rm-m6xh-5pv7.json","last_known_affected_version_range":"\u003c= 2.3.6"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N"}]}