SmarterMail build-9777 wave (3 GHSAs): the JWT role claim is a snapshot¶
Wave in one line: The JWT role claim is a snapshot, the script-directory containment is a four-call chain away, and the sanitizer treats MathML as CDATA
Signal: GitHub Security Advisories published a coordinated three-advisory wave against SmarterMail (all fixed before build 9777) at 2026-10-09T18:31Z. SmarterMail is an internet-exposed Windows mail server with a long exploitation history (CVE-2026-24423 ConnectToHub RCE is already KEV-listed), and this wave reads like a field guide to its admin API: one leg defeats the token lifecycle, one leg walks a low-value authenticated primitive into SYSTEM code execution, one leg breaks the mail-content sanitizer with browser-reparse mechanics.
Advisory table¶
| GHSA | CVE | Sev. | Boundary |
|---|---|---|---|
| GHSA-3wv9-x54m-xfch | CVE-2026-104084 | high | JWT access and refresh tokens embed a role claim minted at issuance; POST /api/v1/auth/refresh-token redeems the refresh token without revalidating the role against the account's current role. A refresh token captured before an admin demotion — or held by a demoted user whose session wasn't actively polling at demotion time — mints fresh access tokens that retain DomainAdmin/SysAdmin until natural token expiry. |
| GHSA-4rp5-r82m-g6p3 | CVE-2026-104082 | high | RCE from a SysAdmin-scoped token via containment bypass: provision a mail domain with an arbitrary FileStore root path inside the trusted Scripts directory via the domain-put endpoint. Chain: AddOrUpdateMount discloses the Scripts path, global-mail endpoint clears the upload extension blacklist, ordinary mail file-storage upload lands the script, and saving a CommandMount triggers RunScript before validation → reverse shell as the SmarterMail service account (SYSTEM). |
| GHSA-vvh8-jc22-6q2f | CVE-2026-104083 | medium | Stored mutation XSS delivered as a crafted iCal message: payload inside <style> nested in MathML foreign content (<math><mtext><mglyph>) — the custom HTML sanitizer treats it as inert CDATA text, the browser reparses it as live markup, and <img src=x onerror=…> executes when the recipient opens the message at /interface/message-iframe, unconstrained by the interface's permissive CSP. |
Why this is durable operator value¶
- A role claim in a token is a snapshot, not a binding — and the refresh endpoint is where stale privileges resurface. Every RBAC audit asks "can I forge a role?"; almost none ask "can a legitimately issued token outlive a demotion?" The test is a lifecycle probe, not a forgery probe: issue a token as high-role, demote the account, then only refresh (never re-login). If the refreshed access token still carries the old role, de-privileging is decorative until the token TTL expires. Generalizes to every product with refresh-token flows and admin-demotion/revoke features (the revocation-free auth-cache side channel on the June 8 pyLoad page is the same genealogy — the credential's validity and the account's state are checked at different times, or not at all).
- Containment controls that gate a destination path lose to any feature that lets you name the destination. The Volume Mount script-directory control existed and enforced; the bypass was provisioning a different feature (domain FileStore root) whose path parameter accepted the same trusted directory. This is the validation-target-vs-write-target mismatch by way of a second endpoint. The chain also demonstrates three independent API-audit primitives composing: (a) an endpoint that discloses the containment-relevant path (
AddOrUpdateMountrevealing the Scripts dir), (b) a global setting reset an authenticated role can call (extension blacklist cleared viaglobal-mail), (c) an execute-before-validate trigger (saving a CommandMount runs the script before any check). On any appliance admin API with a "run script/command from path" feature, enumerate which endpoints write the path field, which write the allow-list/blacklist, and which execute on save rather than on validated read. - Sanitizer-vs-reparser grammar drift, foreign-content edition. The sanitizer made a correct-looking decision for its own parse model ("style content is CDATA text, harmless") that the browser's foreign-content reinsertion rules contradict — MathML/SVG contexts reset the tokenizer, so "inert text" becomes live markup on reparse. This is the DOMPurify mXSS family (May 7 page) with a different engine: not a sanitizer bug against its own grammar, but a custom sanitizer that never modeled foreign content at all. Operator rule: every HTML-folding mail/webmail sanitizer deserves the mXSS battery —
<math><mtext><style>,<svg><foreignObject>,<noscript>,<xmp>,noembed— delivered through whatever content-type the product ingests (here iCal, which most sanitizer test suites never parse). Delivery-vector angle: the payload rides an inbound calendar invite, i.e., zero user interaction beyond opening a message, and the sink is the webmail message iframe with a permissive CSP. - Product posture: same mail server, three boundary layers broken in one release — token lifecycle, admin-API containment, and inbound-content sanitization. On an authorized SmarterMail engagement, treat build < 9777 as "test every save-triggers-action endpoint and every path-typed admin field," not just CVE-by-CVE replay.
Replayable validation boundaries (lab/authorized scope only)¶
- Stale-role refresh probe (own lab tenant, own accounts): create a lab DomainAdmin, capture its refresh token, demote it from a second admin session, then call
POST /api/v1/auth/refresh-tokenwith the captured value and decode the returned access token's role claim. Evidence = decision table (role at issue / role at redeem / claim in refreshed token). Never capture or replay a real user's token; the probe uses tokens you were issued. - Containment-chain inventory (read-only posture + lab proof): with a lab SysAdmin token, walk the four steps against a scratch path you own: does the mount endpoint echo the Scripts path? does the global-mail endpoint accept a blacklist-clear? does an ordinary upload accept an extension that was just cleared? Use an inert script that writes a marker file; stop before touching the live Scripts directory on any shared system. The decision table per leg (allowed/blocked, path disclosed yes/no) is the deliverable even where full execution isn't attempted.
- mXSS battery via iCal (owned lab mailbox pair): send yourself a crafted invite containing the MathML foreign-content wrapper with a benign
<img onerror>marker; open in your own webmail session; evidence = DOM mutation in the message iframe. Test wrapper variants across<style>/<textarea>/<title>inside<mtext>/<foreignObject>. Never seed payloads into third-party mailboxes.
October 10 follow-up: Strapi admin API tokens keep removed content fields — stale-ceiling drift lives on the token side too (CVE-2026-108114 / GHSA-wmch-qg2f-grh3, medium, 5.47.0–5.57.0)¶
Strapi's reconcileTokenPermissionsToUserCeiling — the routine meant to shrink admin API token permissions when the owner's role is field-restricted — skips any token permission with omitted or null fields, so those tokens keep reading content fields an administrator just removed from the role. Same invariant as this page's refresh-token leg (3wv9): the credential keeps the old, wider authority after the identity's authority shrinks — but the lifecycle probe is different. The SmarterMail probe is issue-high → demote → refresh; the Strapi probe is issue-all-fields token → restrict role fields → call the Content API with the unchanged token.
Sweep rule for any platform with both user roles and long-lived API tokens: after restricting a field in a lab role, re-run reads through (a) a fresh session, (b) a refreshed token, (c) a pre-existing API token — all three paths must deny. A positive on (b) or (c) while (a) denies is the revocation-gap finding. The null/omitted-means-inherit-vs-inherit-means-unbounded confusion is the code fingerprint: grep the reconcile/sync functions for if (!fields) return / if (fields == null) continue shapes — skipping the sync is not shrinking.
Cross-references¶
- mXSS and sanitizer-context family: May 7 DOMPurify mXSS page (DOMPurify-side context switching; this leg is custom-sanitizer-vs-foreign-content); Obsidian MathJax URL filter leg on the Sept 23 desktop page.
- Revocation-free credential validity: pyLoad auth-cache leg on the June 8 pyLoad page; Keycloak DCR silent revocation-disable on the Sept 16 IdP page.
- Validation-target-vs-write-target and save-time execution: Nginx UI extractor-before-decide on the Oct 9 Nginx UI wave; Splunk sidecar file-primitive page (Oct 7).
- Prior SmarterMail KEV leg (missing-auth
ConnectToHubcommand execution): Feb 5 page — treat that legacy page as historical KEV context; this page is the operator workflow. - Windows mail-host trust density: Oct 8 hMailServer wave.