{"schema":"threatline.record/2","record":{"id":"GHSA-3jpp-mhvh-rv66","aliases":["GHSA-3jpp-mhvh-rv66"],"summary":"Stored XSS via customer theme value enables customer-to-admin account takeover in Froxlor 2.x admin listing","details":"## Summary\r\n\r\nAn authenticated hosting customer can store an arbitrary HTML and JavaScript payload in their account `theme` value. The panel validates `theme` only against a permissive pattern that allows `<`, `>`, and `\"`, and, unlike the sibling default-language field, it never checks the value against the set of installed themes. When an administrator views the customer overview with the optional Theme column enabled, the stored value is emitted into the page through a Twig `{{ ... |raw }}` table cell with HTML auto-escaping disabled, executing attacker-controlled script in the administrator's authenticated session on the panel origin.\r\n\r\n## Root Cause\r\n\r\nThe invariant that customer-controlled profile fields must be neutralized before they are rendered into an administrator page is violated for the `theme` field, on both the input and the output side.\r\n\r\nOn write, `theme` is validated with the default empty pattern, which rejects only control characters and passes `<`, `>`, and `\"` unchanged; the value is replaced only when it is empty, and it is never constrained to the real set of installed themes. This is asymmetric with the `def_language` field, which is validated against `Language::getLanguages()` in the same method. On read, the customer-listing renderer places the stored value into a plain-field table cell with no escaping callback, and the shared table template renders it via `{{ td.data|raw }}`, which explicitly disables Twig auto-escaping. Unlike the customer edit form (which pre-encodes its result array with `htmlentities` before rendering), the listing path returns raw database rows, so nothing neutralizes the payload before the raw sink.\r\n\r\n```text\r\nWrite and validation: lib/Froxlor/Api/Commands/Customers.php\r\n  - theme validated with the default empty pattern and an emptiness/default check only\r\n  - no allowlist against installed themes (contrast the def_language allowlist in the same method)\r\nWeb entry: customer_index.php (send=changetheme)\r\nListing column: lib/tablelisting/admin/tablelisting.customers.php (c.theme column, no callback)\r\nCell building: lib/Froxlor/UI/Listing.php (raw field value assigned to td.data)\r\nOutput sink: templates/Froxlor/table/table.html.twig:57 ({{ td.data|raw }})\r\n```\r\n\r\n## Impact\r\n\r\nPrerequisites: any authenticated customer account with the theme-change option enabled, which is the default. For the payload to reach an administrator, that administrator must have added the optional Theme column to their customer overview, a per-admin, non-default, persisted preference that is outside the attacker's control and that reduces the likelihood of exploitation. Demonstrated capability: execution of attacker-chosen JavaScript in the administrator's session on the panel origin. The panel's content-security-policy includes `'unsafe-inline'`, so inline event-handler payloads execute. Plausible consequence, following directly from script execution in an administrator session and stated explicitly as plausible rather than observed: abuse of the administrator CSRF token to create administrator accounts or API keys and to change server settings; because these panel settings are applied by a root cron, administrator compromise can plausibly lead to host compromise. The administrator-session script execution is the demonstrated primitive; the account-takeover and host-compromise outcomes are plausible extrapolations, not directly executed here.\r\n\r\n## Proof of Concept\r\n\r\n`No special tooling is required: a customer panel session and a browser. The payload is stored through the normal theme-change request and fires when an administrator who has enabled the Theme column opens the customer overview.`\r\n\r\n1. As an authenticated customer, submit a theme-change request that sets `theme` to an HTML payload.\r\n2. As an administrator with the Theme column enabled, open the customer overview.\r\n\r\n```text\r\nPOST /customer_index.php HTTP/1.1\r\nCookie: <customer session>\r\nContent-Type: application/x-www-form-urlencoded\r\n\r\nsend=changetheme&csrf_token=<valid token>&theme=<img src=x onerror=alert(document.domain)>\r\n```\r\n\r\n```text\r\nRendered admin customer-overview cell (Theme column):\r\n<td><img src=x onerror=alert(document.domain)></td>\r\n=> src=x fails to load, the inline onerror handler runs in the admin session.\r\nNegative control: theme=Froxlor renders inert text.\r\n```\r\n\r\n## Attack Chain\r\n\r\n1. **Exposure:** An authenticated customer reaches the shipped theme-change handler in `customer_index.php` (`send=changetheme`), which is enabled by default.\r\n2. **Control:** The attacker fully controls the `theme` string. Validation uses the default empty pattern `/^[^\\r\\n\\t\\f\\0]*$/D`, which passes `<`, `>`, and `\"` verbatim, so the `<img ... onerror=...>` payload survives.\r\n3. **Path:** `Customers::update` stores the value: the non-admin branch re-validates with the same empty pattern, applies only an emptiness/default check, and performs no theme allowlist (contrast the `def_language` allowlist), so the payload is written verbatim to the customer record.\r\n4. **Guard:** On render, the Theme listing column has no escaping callback; the cell value is assigned raw in `lib/Froxlor/UI/Listing.php` and emitted through `{{ td.data|raw }}` in `templates/Froxlor/table/table.html.twig`, which disables auto-escaping. The listing path, unlike the edit form, does not pre-encode the row data.\r\n5. **Primitive:** Attacker-controlled HTML and script is injected into the administrator's rendered page; the `'unsafe-inline'` content-security-policy permits the inline handler.\r\n6. **Result:** Script executes in the administrator's authenticated session on the panel origin, enabling CSRF-token abuse and, plausibly, creation of privileged accounts and server-setting changes.\r\n\r\n## Bypass Evidence\r\n\r\nThe relevant guards are input validation, an installed-theme allowlist, and output encoding; all three fail to block this vector, and the closest prior fixes do not cover this field.\r\n\r\n- Input validation: the default empty pattern `/^[^\\r\\n\\t\\f\\0]*$/D` does not include `<`, `>`, or `\"` in its negated character class, so the payload matches and is returned unchanged; the \"clean bad string\" replacement is skipped.\r\n- Allowlist: there is no check of `theme` against installed themes on either the web handler or the API write path, unlike `def_language`, which is validated against `Language::getLanguages()`.\r\n- Output encoding: the Theme column has no callback, the value is assigned raw, and `{{ td.data|raw }}` disables auto-escaping; the content-security-policy carries `'unsafe-inline'`, so inline handlers run.\r\n- Strongest disproof attempts and their outcomes: (a) checked whether the listing path pre-encodes row data the way the customer edit form does via `htmlentities` on its result array; it does not, the listing returns raw database rows. (b) Verified the prior stored-XSS fix for DNS content added `htmlspecialchars` only inside the DNS-content callback and does not touch `theme`, the write path, the listing renderer, or the shared template. (c) Verified the prior published advisory GHSA-89vj-gqqr-73p8 (stored XSS via SSL certificate issuer, first patched in 2.3.12) shipped an input allowlist on the issuer field only; that fix does not sanitize `theme` and the shared raw table sink remains intact in the latest 2.x release. This is therefore an unfixed variant on a distinct field and remediation boundary, not a duplicate.\r\n\r\n## Affected Versions\r\n\r\n- `Ecosystem: Packagist`\r\n- `Package: froxlor/froxlor`\r\n- `Confirmed affected range: <= 2.3.13`\r\n- `Latest release checked: 2.3.13 (Packagist / GitHub release tag 2.3.13), inspected directly`\r\n- `Fix status: not fixed`\r\n\r\nThe vulnerable input handling and the shared raw table sink are present throughout the 2.x line; the exact introducing 2.x release was not pinned within the tags inspected. No fix exists for the `theme` field in the latest 2.x release. The unreleased development branch was not treated as a fix.\r\n\r\n## Suggested Fix\r\n\r\nEnforce that the stored `theme` value is one of the installed themes. On write, validate `theme` against the real theme set (as `def_language` is validated against the installed languages) and reject or default any other value, in both the customer web handler and the API update path. Independently, treat the shared listing renderer as a defense-in-depth boundary: plain-field cells that carry user-controlled data should be HTML-encoded rather than emitted through `{{ ... |raw }}`, or the affected columns should be given an escaping callback. As an interim mitigation, administrators can avoid adding the Theme column to the customer overview, which removes the only rendering path for this field.\r\n\r\nReported by zx (GitHub: @manus-pi).","categories":["Unclassified"],"product":"froxlor/froxlor","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-3jpp-mhvh-rv66","source_modified_at":"2026-09-29T07:21:19Z","fetched_at":"2026-10-24T18:03:20.872036Z","package":{"ecosystem":"composer","name":"froxlor/froxlor"},"range":"<= 2.3.13"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-3jpp-mhvh-rv66","source_modified_at":"2026-09-29T07:21:19Z","fetched_at":"2026-10-24T18:03:20.872036Z","package":{"ecosystem":"composer","name":"froxlor/froxlor"},"version":"2.3.14"}],"cvss":7.7,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-29T07:21:19Z","modified_at":"2026-09-29T07:21:19Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-3jpp-mhvh-rv66","fetched_at":"2026-10-24T18:03:20.872036Z","source_modified_at":"2026-09-29T07:21:19Z"}],"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-09-29T07:21:19Z","content_hash":"cea01d48e4c238b91f0bd35973d65f7980236c9cbda792d1e4157cd2574ae31d"}}
