All vulnerabilities
GHSA-3jpp-mhvh-rv66

froxlor/froxlor: Stored XSS via customer theme value enables customer-to-admin account takeover in Froxlor 2.x admin listing

froxlor/froxlor
CVSS
7.7High
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in2.3.14Fixed versionGitHub
Published29 Sep 2026Updated 29 Sep 2026GitHub

What fixes it

  • 2.3.14froxlor/froxlor, composerGitHubcomposer require "froxlor/froxlor:^2.3.14"

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

Summary

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

Root Cause

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

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

Write and validation: lib/Froxlor/Api/Commands/Customers.php
  - theme validated with the default empty pattern and an emptiness/default check only
  - no allowlist against installed themes (contrast the def_language allowlist in the same method)
Web entry: customer_index.php (send=changetheme)
Listing column: lib/tablelisting/admin/tablelisting.customers.php (c.theme column, no callback)
Cell building: lib/Froxlor/UI/Listing.php (raw field value assigned to td.data)
Output sink: templates/Froxlor/table/table.html.twig:57 ({{ td.data|raw }})

Impact

Prerequisites: 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.

Proof of Concept

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.

  1. As an authenticated customer, submit a theme-change request that sets theme to an HTML payload.
  2. As an administrator with the Theme column enabled, open the customer overview.
POST /customer_index.php HTTP/1.1
Cookie: <customer session>
Content-Type: application/x-www-form-urlencoded

send=changetheme&csrf_token=<valid token>&theme=<img src=x onerror=alert(document.domain)>
Rendered admin customer-overview cell (Theme column):
<td><img src=x onerror=alert(document.domain)></td>
=> src=x fails to load, the inline onerror handler runs in the admin session.
Negative control: theme=Froxlor renders inert text.

Attack Chain

  1. Exposure: An authenticated customer reaches the shipped theme-change handler in customer_index.php (send=changetheme), which is enabled by default.
  2. 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.
  3. 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.
  4. 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.
  5. Primitive: Attacker-controlled HTML and script is injected into the administrator's rendered page; the 'unsafe-inline' content-security-policy permits the inline handler.
  6. 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.

Bypass Evidence

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

  • 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.
  • 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().
  • 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.
  • 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.

Affected Versions

  • Ecosystem: Packagist
  • Package: froxlor/froxlor
  • Confirmed affected range: <= 2.3.13
  • Latest release checked: 2.3.13 (Packagist / GitHub release tag 2.3.13), inspected directly
  • Fix status: not fixed

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

Suggested Fix

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

Reported by zx (GitHub: @manus-pi).

Text from GitHub Advisory Database

Affected versions 1

  • froxlor/froxlor, composer<= 2.3.13GitHub