{"uuid": "8bda4ec4-09f1-4de4-986b-7e5e89aae5e0", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-fqgm-2x23-c4xm", "type": "seen", "source": "https://gist.github.com/CyberKareem/a5884cfd6ce864e0c126d4ad2a0c689c", "content": "# DjangoCRM (django-crm): Unauthenticated stored XSS via incoming-email Subject (admin takeover) (CWE-79)\n\n- **Affected:** DjangoCRM/django-crm, all versions up to and including the latest `main` branch (no fixed release as of 2026-06-29).\n- **Severity:** 9.6 Critical \u2014 `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H`\n- **CWE:** CWE-79 (Improper Neutralization of Input During Web Page Generation \u2014 Stored XSS)\n- **Reported:** GitHub private vulnerability reporting, GHSA-fqgm-2x23-c4xm; closed by the maintainer without remediation. CVE requested via MITRE CNA-LR, 2026-06-29.\n- **Credit:** Abdullah Kareem (cyberkareem) \u2014 https://github.com/CyberKareem\n\n## Summary\n\nDjangoCRM imports email over IMAP and shows the operator a notification for each new message. The email `Subject` is decoded but never HTML-escaped, then wrapped in `mark_safe` and rendered into the Django admin message list. Anyone who can send an email to a CRM-imported mailbox (a public `sales@`/`support@`/lead address) controls that subject, so an outside sender can plant JavaScript that runs in the staff or superuser admin session the next time the operator loads an admin page.\n\n## Root cause\n\nThe taint path runs from the raw IMAP `Subject` header to an unescaped admin template render. Each hop keeps the value as attacker-controlled text and never escapes it.\n\n1. `crm/utils/restore_imap_emails.py:73` decodes the raw subject:\n   `subj = ensure_decoding(email_message['Subject'])`. `ensure_decoding` in `crm/utils/helpers.py:55-62` is just `str(make_header(decode_header(string)))` \u2014 it decodes MIME-encoded words, it does not escape HTML.\n2. `crm/utils/restore_imap_emails.py:109` truncates it: `crm_eml.subject = truncatechars(subj, 220)`. `truncatechars` shortens the string; it does not escape.\n3. `crm/utils/restore_imap_emails.py:186-195` (`_notify_user`) interpolates the subject into an f-string and marks the whole thing safe.\n4. `common/utils/helpers.py:220-224` (`save_message`) persists that message into `owner.profile.messages` (a JSONField on the staff owner's profile).\n5. `common/utils/usermiddleware.py:34` (`activate_stored_messages_to_user`) pops the stored message and calls `mark_safe` on it a second time, then hands it to `messages.add_message`.\n6. `templates/admin/base.html:106` renders it: `{{ message|capfirst }}`. Django's `capfirst` is declared `@register.filter(is_safe=True)` + `@stringfilter`, so it preserves the `SafeString` and autoescape never fires.\n\nThe three decisive lines:\n\n```python\n# crm/utils/restore_imap_emails.py:194  (_notify_user)\nmessage = mark_safe(f'{msg}: {crm_eml.subject}')\n\n# common/utils/usermiddleware.py:34  (activate_stored_messages_to_user)\nmsg = mark_safe(profile.messages.pop(0))    # NOQA\n\n# templates/admin/base.html:106\n{{ message|capfirst }}\n```\n\nThere is no escaping anywhere on this header-to-template path. `crm_eml.subject` is fully attacker-controlled, and `mark_safe` tells the template engine to emit it verbatim.\n\n### Why an unauthenticated email reaches this code\n\nMail is imported on every authenticated request. The middleware at `common/utils/usermiddleware.py:25` calls `iem.import_emails(request.user)` (`crm/apps.py:37`), which fetches for any `EmailAccount(do_import=True)` \u2014 see `crm/utils/import_emails.py:44`. The IMAP search criterion is `(TEXT \"[ticket:\" UID start:*)` (`crm/utils/helpers.py:131`), and ticket extraction is `re.search(r\"\\[ticket:(.+?)]\", txt)` (`crm/utils/ticketproc.py:15-17`). So the attacker email only has to contain the literal string `[ticket:x]` somewhere in the subject or body; the ticket value need not exist, because `update_with_deal_and_request` (`crm/utils/restore_imap_emails.py:297`) tolerates an unknown ticket. An incoming message then routes through `_notify_user` and stores the `mark_safe`'d subject for the mailbox owner.\n\n## Proof of concept\n\nA runtime PoC exists. It drives the real source-to-sink render on the project's own Django 6.0.6, using the actual `truncatechars`, `capfirst`, and `mark_safe` (matching lines 109 / 194 / 34 / 106), and shows the payload rendered unescaped in the admin `\n`.\n\nSteps against a live instance:\n\n1. Identify a CRM-imported inbox \u2014 any address attached to an `EmailAccount` with `do_import=True` (a public `sales@`, `support@`, or lead intake address).\n2. Send an email to that inbox with a script payload in the Subject and the literal `[ticket:x]` token so the importer accepts it:\n   ```\n   To: sales@victim-crm.example\n   Subject:  [ticket:x]\n\n   anything\n   ```\n3. The staff owner of that mailbox opens any Django admin page. The middleware imports the mail, re-emits the stored notification, and the template renders the subject unescaped. The `onerror` handler runs in the operator's authenticated admin session.\n\nA realistic payload reads the admin CSRF token from `/admin/auth/user/add/` and POSTs a new account with `is_superuser=on`, which gives the attacker a standing admin login.\n\n## Impact\n\nThis is stored XSS that crosses a privilege boundary: an unauthenticated external email sender gets JavaScript execution inside an authenticated staff or superuser admin browser session. The script runs with the victim operator's session and CSRF token, so it can read and exfiltrate any data the admin can see (customer PII, mailboxes, deals) and perform any admin action, including creating a new Django superuser account for persistent access.\n\nThe ceiling here is application-level admin control of the CRM, not operating-system or server code execution. The injected script runs in the victim's browser, not on the server. Creating a superuser grants Django application administrator authority over the CRM; it does not give a shell on the host.\n\n## Remediation\n\nStop calling `mark_safe` on user-controlled content. In `_notify_user`, build the notification with `format_html`, which escapes its arguments:\n\n```python\nfrom django.utils.html import format_html\nmessage = format_html('{}: {}', msg, url, crm_eml.subject)\n```\n\nAlternatively, run `escape(crm_eml.subject)` before interpolation. Also remove the redundant `mark_safe` in `activate_stored_messages_to_user` (`common/utils/usermiddleware.py:34`) so a stored value cannot be re-marked safe on the way to the template. The `url` is internally generated, but `crm_eml.subject` is attacker-controlled and must be escaped before it reaches the template.\n\n## References\n\n- `crm/utils/restore_imap_emails.py` \u2014 https://github.com/DjangoCRM/django-crm/blob/main/crm/utils/restore_imap_emails.py\n- `crm/utils/helpers.py` \u2014 https://github.com/DjangoCRM/django-crm/blob/main/crm/utils/helpers.py\n- `common/utils/helpers.py` \u2014 https://github.com/DjangoCRM/django-crm/blob/main/common/utils/helpers.py\n- `common/utils/usermiddleware.py` \u2014 https://github.com/DjangoCRM/django-crm/blob/main/common/utils/usermiddleware.py\n- `templates/admin/base.html` \u2014 https://github.com/DjangoCRM/django-crm/blob/main/templates/admin/base.html\n- `crm/utils/ticketproc.py` \u2014 https://github.com/DjangoCRM/django-crm/blob/main/crm/utils/ticketproc.py\n- `crm/utils/import_emails.py` \u2014 https://github.com/DjangoCRM/django-crm/blob/main/crm/utils/import_emails.py\n- `crm/apps.py` \u2014 https://github.com/DjangoCRM/django-crm/blob/main/crm/apps.py\n", "creation_timestamp": "2026-06-29T16:59:42.401782Z"}