Hi Brad, thanks for building this - the feature set is ambitious and the live preview is genuinely nice.
I reviewed the current 1.2.0 and confirmed the issues below with working proof-of-concepts on a throwaway local install (benign markers only). I am posting publicly rather than only via PM because the addon is already installed on some boards and those admins should know so they can protect their members while you patch. This is not a pile-on - the two critical ones share a single small root cause and are quick to fix. I have deliberately written the payloads out in words rather than pasting literal exploit strings; happy to share exact repro steps privately.
For fairness up front: 1.2.0 already added URL-scheme validation for link fields and fixed the version_id, so you are clearly hardening this. Everything below I verified by actually exploiting it on a local install, not just by reading code - including confirming it affects logged-out guests and appears in the raw server response with no cookies (so it is genuinely server-side, not a browser-only trick).
Proven (working PoC on a local 1.2.0 install)
Root cause: the chosen font names are never validated against the real font list. The username-font and title-font values arrive from the save request as free text with no allowlist (Pub/Controller/Persona.php around lines 135 and 151) and are stored raw - neither field is in the colour/url/bool sanitising lists. The customizer offers them as a dropdown of your bundled fonts, but the server never checks the submitted value is one of them, so a member can send any string by editing the request. Each value then reaches two output sinks unescaped.
1. Stored XSS via the username font (critical)
On render the value is concatenated into the Google-Fonts stylesheet link in the page head (Listener.php ~80-85), and the whole head blob is emitted as pre-escaped / trusted markup (Listener.php ~264), so quotes and angle brackets are not escaped. A font value that contains a double-quote followed by an HTML tag closes the link's URL attribute and injects markup (including script) into the head.
- Verified: as an ordinary unprivileged member I saved such a value; my (harmless) marker script then executed on a normal forum page, and it fired for a logged-out guest too. I also fetched the page over a plain request with no cookies and the injected markup was present in the raw server HTML - so it is stored server-side and served to every visitor, not a client-side trick.
- Impact: the listener runs on every public page (hint public PAGE_CONTAINER) and emits font links for every styled user, so one member's stored value runs script in every visitor's browser site-wide = session/cookie theft, actions taken as staff/admins.
2. Stored XSS via the user-title font (critical)
Same issue, second field: the title-font value feeds the same head stylesheet link (Listener.php ~67-70 into ~80-85 and ~264). Verified the same way. A fix must cover both font fields - allowlisting only the username font leaves this one open.
3. Stored CSS injection via the font fields (high)
The same font value is also written into the generated stylesheet inside the font-family declaration (Repository/Persona.php ~972 and ~1553). A single quote closes that declaration and lets arbitrary style rules into a stylesheet that is served to everyone who sees the member's content. Verified: from a font value alone I injected a page-wide rule and watched it apply across the whole page, including for a guest. This is style-level (defacement, clickable overlays, loading remote resources) rather than script execution, hence high not critical.
4. Stored CSS injection via the gradient-direction field (medium)
The username gradient-direction value is written raw into the CSS gradient declaration (Repository/Persona.php ~921). Verified: a short value (this column is only 20 chars, but that is enough) closed the declaration and injected a global rule that restyled the whole page for a guest. The same unvalidated pattern applies to the nameplate border-style (~1368) and post border-style (~1191) fields. The colour fields, by contrast, are only 7 chars and cannot break out, so those are fine - it is specifically the style-token fields.
5. Premium-tier / paywall bypass via Import (high, if you gate cosmetics by tier)
The tier check runs only in the normal save action (Pub/Controller ~166-201). The import, copy-from-user and revert-to-history paths all call saveUserStyle directly with no tier check, and the render path checks only whether an asset is active, never its tier. Verified: as a plain Registered member I used the import feature with a style code equipping a Pro-tier avatar frame (min_tier = pro); it saved, and the generated stylesheet then rendered that Pro frame's border on my avatar site-wide - a paid cosmetic on a free account.
6. Moderation (blocked-user) bypass (medium)
The blocked-user flag is checked in the normal save action (~118) but not in the import / reset / copy / revert paths - the same paths that skip the tier gate in #5 - and blocking never regenerates or removes the already-served stylesheet. So a blocked user can re-apply their style (including an abusive one) and the block is effectively a no-op.
Additional (confirmed in code, not separately load-tested)
- Availability / DoS: every public page load runs unbounded full-table scans of the user-styles table (twice) plus a join to the users table and a full badge scan, into PHP memory (getAllUserConfigs, getAllUserBadges) with no caching - it runs for guests too and will degrade as the styled-user count grows.
Minor / cosmetic
- Custom-font upload is broken: it throws a fatal (an undefined-method error on the upload move call, Admin/Controller ~197) for any file, so nothing is ever written - a functional bug, not the security hole it looks like. When you wire it up, add validity + extension/size checks and use the framework file helpers.
- Admin dashboard layout: the ACP Overview stat values render as vertical one-character-per-line columns (the stat row is not laid out), and the Tools / User Configurations button rows are misaligned.
- Counters / history: the style-views and total-saves counters are non-atomic (lost updates under load); the style-history timeline snapshots the new style rather than the previous one, so revert restores the wrong state.
Fix
Validate both font fields (and the gradient / border style-token fields) against hard allowlists before use, and stop emitting user-derived strings as pre-escaped / trusted markup (let the templater escape them). Move the tier and blocked-user checks down into saveUserStyle so the import / copy / revert paths are covered, not just the normal save. That closes 1-6.
If you have Persona installed right now: I would disable it, or restrict who can set styles, until a fixed build is out - because of issues 1 and 2 any single member account can run script in every visitor's browser.