← Back

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 — the avatar() 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.

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 shared assert_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 into ADMIN_ONLY_PATHS. Larger change, not smoke-exercised — deferred.
  • Invoice company-scope (Medium): invoices load by raw id gated only by can_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.