{"schema":"threatline.record/2","record":{"id":"GHSA-pp2f-frcg-w8rv","aliases":["GHSA-pp2f-frcg-w8rv"],"summary":"Missing authorization via unchecked `parentdomainid` in `SubDomains.delete`, enabling deletion of a reseller-assigned main domain","details":"### Summary\r\n\r\n**Missing authorization (CWE-862) in `SubDomains::delete`: the command never checks `parentdomainid`, so a customer deletes a main domain its reseller assigned to it.** The guard that is supposed to stop this exists only in the presentation layer, and `SubDomains::get` deliberately returns main domains, so the row the customer hands to `delete` can be one. The delete also skips every bookkeeping step its admin-only sibling performs, which leaks the reseller's domain slot permanently and drives the customer's own `subdomains_used` counter **negative**.\r\n\r\n### Details\r\n\r\n`SubDomains::delete` (`lib/Froxlor/Api/Commands/SubDomains.php:1161-1211`) checks `caneditdomain` at `:1180` and never inspects `parentdomainid`. `SubDomains::get` (`:493-497`) has no `parentdomainid` restriction, so a `parentdomainid = 0` main domain is returned to the customer and can be fed straight back.\r\n\r\nThe guard lives in the UI only — `customer_domains.php:108-126`, `domains_cantdeletemaindomain`.\r\n\r\n`caneditdomain` is not a barrier either: for any domain added by a reseller (an admin with `change_serversettings != 1`) it is hard-coded to `'1'` in the `else` branch of `Domains::add` (`Domains.php:535`).\r\n\r\nCompare the admin-only sibling `Domains::delete` (`Domains.php:2255-2291`), which deletes `WHERE id = :id OR parentdomainid = :id`, decrements the owning admin's `domains_used`, and resets `panel_customers.standardsubdomain`. `SubDomains::delete` does none of these: `:1205-1211` deletes only the one row, so child subdomains are orphaned with a dangling `parentdomainid`; the reseller's `domains_used` is never decremented; and `Customers::decreaseUsage(…, 'subdomains_used')` at `:1258` runs for a row that never counted toward that quota in the first place.\r\n\r\n### Proof of concept\r\n\r\nfroxlor **2.3.14** (`c147de18`), over HTTP. `reseller1` (`change_serversettings = 0`) creates customer `web2` and assigns it the main domain `reseller-owned.example.test` (id 4, `parentdomainid = 0`, `caneditdomain = 1`, `adminid = 2`). Customer `web2` then deletes it with its own API key.\r\n\r\n```\r\n=== PRE ===\r\ndomain  4  reseller-owned.example.test  parentdomainid=0  adminid=2\r\nadmin   2  domains=-1  domains_used=1\r\ncust    2  subdomains=5  subdomains_used=0\r\n\r\n$ curl -u CUST2KEY:CUST2SECRET \\\r\n    -d '{\"command\":\"SubDomains.delete\",\"params\":{\"id\":4}}' http://127.0.0.1/api.php\r\n{ \"data\": { \"id\": 4, \"domain\": \"reseller-owned.example.test\", \"adminid\": 2, … } }\r\n\r\n=== POST ===\r\ndomain-count 0                             <- the reseller's domain is gone\r\nadmin   2  domains=-1  domains_used=1      <- NOT decremented\r\ncust    2  subdomains=5  subdomains_used=-1  <- counter driven NEGATIVE\r\n```\r\n\r\nThe oracle is the row count and the two counters read back from the database after the call, not the HTTP status.\r\n\r\n### Impact\r\n\r\n`CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L` — **5.4 Medium**.\r\n\r\nCustomer → reseller. Destructive: the reseller loses a domain it provisioned, with the vhost, DNS and mail rows partly orphaned behind a dangling `parentdomainid`. And self-serve quota inflation: `subdomains_used` has no floor — `ApiCommand.php:236-246` does a raw `- 1` — so repeating the cycle grants the customer unbounded extra subdomain allowance.\r\n\r\n### Suggested fix\r\n\r\nReject `parentdomainid = 0` rows in `SubDomains::delete`, the way `customer_domains.php` already does in the UI; and route main-domain deletion through `Domains::delete`, which does the cascade and the counter bookkeeping correctly.\r\n\r\n### What this is not\r\n\r\nThe deletion is still scoped to `customerid = own` (`:1205`), so a customer cannot delete **another customer's** domain — only one the reseller assigned to it.\r\n\r\nThe quota-denial consequence for the reseller was not demonstrated end to end. My reseller had `domains = -1` (unlimited), so I proved the counter drift — `domains_used` not decremented, `subdomains_used` at `-1` — and inferred, rather than showed, the allocation failure that follows on a finite quota.\r\n\r\nVerified present at `2.3.14` and `origin/v2` `c147de18`. Not present on `origin/main` (v3 rewrite).\r\n","categories":["Unclassified"],"product":"froxlor/froxlor","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-pp2f-frcg-w8rv","source_modified_at":"2026-10-20T07:24:31Z","fetched_at":"2026-10-23T18:55:00.935475Z","package":{"ecosystem":"composer","name":"froxlor/froxlor"},"range":">= 2.0.0, <= 2.3.14"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-pp2f-frcg-w8rv","source_modified_at":"2026-10-20T07:24:31Z","fetched_at":"2026-10-23T18:55:00.935475Z","package":{"ecosystem":"composer","name":"froxlor/froxlor"},"version":"2.3.15"}],"cvss":5.4,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-20T07:24:31Z","modified_at":"2026-10-20T07:24:31Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-pp2f-frcg-w8rv","fetched_at":"2026-10-23T18:55:00.935475Z","source_modified_at":"2026-10-20T07:24:31Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-20T07:24:31Z","content_hash":"49c69ebfbeebdd46a5fa0b565840ca57af0c754ca2db0f4cf9e3a09211557cf5"}}
