Security-hardening report — stored XSS, mobile IDOR, headers (2026-06-18)
Findings from an authorized white-box + live black-box security test of the admin
app, run against a local full stack (Postgres + web on localhost:8080, built from
main at 201633c). Six issues fixed on branch dev (not yet merged — main is
the prod trigger, so nothing is deployed by this report). Patch-note summary:
../Patch/patch-notes-2026-06-18-security-hardening.md.
Verification: Docker smoke 157/0 on the dev tree; py_compile clean across the
changed handlers; the avatar XSS and the missing cookie/headers were re-confirmed gone
on a rebuilt local deploy.
BUG-1 · Stored XSS via profile avatar → admin account takeover (Critical)
Confirmed live. Any logged-in user (including a customer) can set their avatar via
POST /account. The only check was avatar.startswith("data:image/") and len < 300000.
The raw value is interpolated unescaped into a style="background-image:url('…')"
attribute, and that avatar is rendered cross-user — a comment author’s avatar shows on
the assigned admin/staff member’s ticket thread. So a malicious customer could store
data:image/png');background:url(x)"></span><img src=x onerror=alert(document.cookie)><span x="
and have it execute in the admin’s browser → full CRM/admin compromise. Reproduced
end-to-end locally: the payload stored, and onerror=alert rendered raw in the served HTML.
Cause: a startswith prefix check is not validation — data:image/ can be followed by
arbitrary characters including ' " < >. Plus the sinks interpolated the value without
esc().
Fix (two layers):
- Source: validate the upload as a strict base64 image data URL at the store —
re.fullmatch(r"data:image/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=]+", v). base64 cannot contain' " < >, so this both validates the image and neutralises the injection. - Sinks (defence in depth):
esc()the avatar at every render site — theavatar()helper (ethica/ui/html.py) and the two profile-card sinks (ethica/handlers/account.py).
Files: ethica/handlers/account.py, ethica/ui/html.py. Commit 429b5d3.
BUG-2 · Cross-customer IDOR in the mobile ticket view (Critical)
The mobile GET /mobile/ticket allowed a customer to read a ticket whenever its
company_id matched their own — dropping the company_admin gate the desktop
_customer_ticket() enforces. A regular (non-admin) customer could iterate ?id=N and
read colleagues’ tickets across their whole company; the desktop view returns 403 for
the same id.
Fix: route the customer check through the same boundary as desktop —
if role == "customer" and not self._customer_ticket(con, user, tid): 403. That requires
the caller to be the requester, or a company-admin for the ticket’s company, with support
enabled. Files: ethica/handlers/mobile.py. Commit 5ef94bc.
BUG-3 · Stored XSS via username on the admin Users page (High)
User-creation paths (admin Users page save_users, and the company-admin “add team
member” account_user_new) accepted arbitrary usernames with no charset validation. The
Users page interpolates form="u_<username>" into markup, so a crafted username
(a" onfocus=alert(1) autofocus x=") executes when an admin opens /admin/users. A
company_admin customer can reach account_user_new, making this customer-reachable.
Fix: constrain the username to [A-Za-z0-9._-]{1,64} at both creation points (the same
pattern already used elsewhere in settings). Files: ethica/handlers/account.py,
ethica/handlers/settings.py. Commit 429b5d3.
BUG-4 · Missing hardening response headers + non-Secure session cookie (Low, confirmed)
Live probe: no X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and the
session cookie was HttpOnly; SameSite=Lax but not Secure (the sid could ride a
plaintext request).
Fix: emit X-Content-Type-Options: nosniff on every response and
X-Frame-Options: SAMEORIGIN + Referrer-Policy: strict-origin-when-cross-origin on HTML.
SAMEORIGIN (not DENY) so the admin’s own newsletter/content preview iframes keep
working. Mark the session cookie Secure when the request arrived over HTTPS
(X-Forwarded-Proto from the Traefik edge) — left unset on plain HTTP so local dev and the
smoke still round-trip the cookie. Files: ethica/handlers/base.py, ethica/handlers/auth.py.
Commit da7730e.
BUG-5 · Non-constant-time collector-token comparison (Low)
/api/collector/push compared X-Collector-Token with != (a timing side-channel); every
other token check in the app already uses hmac.compare_digest. Fix: switch to
hmac.compare_digest. Files: ethica/handlers/public.py. Commit 4602f70.
Verified clean (no action)
SQL injection (the ?→%s shim is used consistently; dynamic table/order-by/pagination
interpolate only whitelisted constants or int()-cast values), CSRF coverage (every
cookie-auth mutation validates a per-session HMAC token with compare_digest — a forged
POST returned 403 bad csrf live), path traversal (file endpoints key on int() ids
against DB blobs, not filesystem paths; docs_reader normalizes before its containment
check), command injection (argv arrays everywhere; no shell=True/eval/exec/pickle),
and the auth core (PBKDF2-SHA256 @ 210k, 256-bit session ids, fresh sid per login).
Deferred follow-ups (tracked, not in this branch)
- SSRF (High): monitor / api-source / deployment target URLs have no
internal-IP guard, and those routes aren’t in
ADMIN_ONLY_PATHS, so a lower-tier staff admin could point an api-source at the cloud-metadata endpoint and have the collector fetch + surface it. Fix wants a sharedassert_public_url()(resolve host → reject loopback/link-local/private; http(s) only; pin against DNS rebind) wired into the write path and the fetchers (collector.py,services/monitoring.py), plus moving those route prefixes intoADMIN_ONLY_PATHS. Larger change, not smoke-exercised — deferred. - Invoice company-scope (Medium): invoices load by raw
idgated only bycan_bill; any billing-capable staffer can read/modify any company’s invoice. This matches the documented v1 “staff = all companies” model — revisit if least-privilege is wanted. - No access control was weakened to make any check pass.