Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐘
🐘 Packagist
Not in CISA KEV
MEDIUM severity

GHSA-q4rm-m6xh-5pv7 froxlor/froxlor

MEDIUM

GHSA-q4rm-m6xh-5pv7 is a medium-severity (CVSS 4.3) CWE-285 vulnerability in froxlor/froxlor. A fix is available for froxlor/froxlor — see the affected versions and patch details below.

Froxlor customer can create MySQL databases on disallowed servers via Mysqls.add API

Also known asCVE-2026-90935
Published
Jul 2, 2026
Updated
Sep 15, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Sep 18, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.

Exploitation and automatability from CISA’s SSVC triage for GHSA-q4rm-m6xh-5pv7.

EPSS Exploitation Probability

via FIRST.org ↗
0.2%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs12th percentile — riskier than 12% of all scored CVEsHighest risk

EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.

How urgent is this, really

GHSA-q4rm-m6xh-5pv7 plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.

Where this sits among everything scored

Of 376,715 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.

Real-World Exposure

1 pkg affected
🐘froxlor/froxlor

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.

Description

Summary

The 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.

Details

Vulnerable code pathlib/Froxlor/Api/Commands/Mysqls.php:69-99 (add()):

public function add()
{
    if (($this->getUserDetail('mysqls_used') < $this->getUserDetail('mysqls') || ...) {
        ...
        $customer = $this->getCustomerData('mysqls');                       // line 80
        $dbserver = $this->getParam('mysql_server', true,                    // line 81 — user-controlled
            $this->getDefaultMySqlServer($customer));
        ...
        $dbserver = Validate::validate($dbserver, ..., '/^[0-9]+$/', ...);   // line 92 — numeric only
        Database::needRoot(true, $dbserver, false);                          // line 93 — root ctx for ANY index
        Database::needSqlData();
        $sql_root = Database::getSqlData();
        Database::needRoot(false);
        if (!is_array($sql_root)) {                                          // line 97 — only existence check
            throw new Exception("Database server with index #" . $dbserver . " is unknown", 404);
        }
        ...
        $username = $dbm->createDatabase($newdb_params['loginname'], $password,
            $dbserver, ...);                                                 // line 116/118 — DB+user created
        ...
        Database::pexecute($stmt, ["customerid"=>$customer['customerid'], ..., "dbserver"=>$dbserver], ...);
    }
}

The $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.

Cross-file evidence the check is intended elsewhere:

  • lib/Froxlor/Api/Commands/MysqlServer.php:319-323get() rejects with HTTP 405 when $dbserver is not in allowed_mysqlserver:
    if ($this->isAdmin() == false) {
        $allowed_mysqls = json_decode($this->getUserDetail('allowed_mysqlserver'), true);
        if ($allowed_mysqls === false || empty($allowed_mysqls) || !in_array($dbserver, $allowed_mysqls)) {
            throw new Exception("You cannot access this resource", 405);
        }
        ...
    }
    
  • lib/Froxlor/Api/Commands/MysqlServer.php:252-257 — same allowlist filter on listing().
  • customer_mysql.php:222 — UI rejects with Response::dynamicError('No permission') when empty($allowed_mysqlservers).

Chain of execution (attacker → impact):

  1. Customer authenticates to api.php with apikey/secret. The only API gate is cust_api_allowed; allowed_mysqlserver is not consulted at auth time.
  2. Customer sends JSON {"command":"Mysqls.add","params":{"mysql_password":"<valid>","mysql_server":<disallowed_idx>}}.
  3. Mysqls.php:71 quota check passes (mysqls_used < mysqls).
  4. Mysqls.php:80 getCustomerData('mysqls') returns the caller's own row.
  5. Mysqls.php:81 $dbserver is set from the request (default-fallback path skipped).
  6. Mysqls.php:92 numeric regex passes.
  7. Mysqls.php:93-99 Database::needRoot(true, $dbserver, false) switches to the root context of the attacker-chosen server; existence check passes.
  8. Mysqls.php:116/118 DbManager::createDatabase(...) runs against the disallowed server using stored root credentials, creating the DB and granting the supplied password to <loginname>_<sqlN> (DbManager.php:177-218).
  9. 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).

PoC

Preconditions on the target instance:

  • ≥2 MySQL servers configured in lib/userdata.inc.php (e.g. index 0 default, index 1 internal/premium).
  • Customer X with allowed_mysqlserver=[0], cust_api_allowed=1, mysqls > 0, and an issued API key (apikey:secret).

Request — customer creates a database on server 1, which is not in their allowlist:

curl -k -u 'CUST_APIKEY:CUST_SECRET' \
  -H 'Content-Type: application/json' \
  -X POST \
  -d '{"command":"Mysqls.add","params":{"mysql_password":"ValidP@ssw0rd!","mysql_server":1}}' \
  https://froxlor.example.com/api.php

Expected (mirroring MysqlServer.get() behaviour): HTTP 405 — "You cannot access this resource". Actual: HTTP 200 with the full database record, e.g.:

{"data":{"id":42,"customerid":<cust_id>,"databasename":"<loginname>_sql1","dbserver":1,...}}

Verify the credentials work on the forbidden server:

mysql -h server1.host -u <loginname>_sql1 -p   # password: ValidP@ssw0rd!
mysql> SHOW DATABASES;        # the new DB is present
mysql> USE <loginname>_sql1;  # full access to the newly-created DB

The 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.

Impact

  • 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.
  • 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.
  • 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.
  • Impact is bounded: privileges granted by DbManager::grantPrivilegesTo apply only to the new <loginname>_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.

Recommended Fix

Mirror the allowlist check already present in MysqlServer::get(). After the numeric validation on Mysqls.php:92, before Database::needRoot(...), add for non-admin callers:

// validate whether the dbserver exists
$dbserver = Validate::validate($dbserver, html_entity_decode(lng('mysql.mysql_server')), '/^[0-9]+$/', '', 0, true);

// enforce per-customer allowed_mysqlserver allowlist (parity with MysqlServer::get())
if (!$this->isAdmin()) {
    $allowed = json_decode($customer['allowed_mysqlserver'] ?? '[]', true);
    if (!is_array($allowed) || empty($allowed)
        || !in_array((int)$dbserver, array_map('intval', $allowed), true)) {
        throw new Exception('You cannot access this resource', 405);
    }
}

Database::needRoot(true, $dbserver, false);

Audit 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.

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐘Packagistfroxlor/froxlorall versions2.3.7composer require froxlor/froxlor:^2.3.7

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for froxlor/froxlor, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update froxlor/froxlor to 2.3.7 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-q4rm-m6xh-5pv7 is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like GHSA-q4rm-m6xh-5pv7 can be triaged on real exposure rather than presence alone.

Tailored to GHSA-q4rm-m6xh-5pv7. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

## Summary The `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 po
O3 Security · Impact-Aware SCA

Is GHSA-q4rm-m6xh-5pv7 in your dependencies?

O3 Security finds GHSA-q4rm-m6xh-5pv7 across Packagist dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.

GHSA-q4rm-m6xh-5pv7: froxlor/froxlor | O3 Security