{"uuid": "eab1c71e-ad8d-4221-9846-fbc408e409a0", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-xc63-6253-wpxj", "type": "seen", "source": "https://gist.github.com/CyberKareem/b0be586b08e26973675ce3d8f1a4a8da", "content": "# DjangoCRM (django-crm): Authenticated stored XSS via chat-message content (cross-user) (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:** High (Critical if recipient is superuser) \u2014 CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H\n- **CWE:** CWE-79\n- **Reported:** GitHub private vulnerability reporting GHSA-xc63-6253-wpxj; 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 lets an authenticated operator send a chat message attached to a CRM object (deal, task, project, memo, user). The message body is stored verbatim and later rendered in the recipient's Django admin change list without HTML escaping. An operator who sets the message content to an HTML payload such as `` gets that markup executed as script in the recipient's browser when the recipient opens the chat. Because the sender picks the recipient, this is a cross-user attack: the script runs in a different, potentially higher-privileged, admin session.\n\n## Root cause\n\nThe message body is attacker-controlled and reaches the change-list cell unescaped.\n\nThe input is accepted as-is. `clean_content` only rejects an empty message and returns the raw string, and `ChatMessage.content` is a plain `TextField`:\n\n```python\n# chat/forms/chatmessageform.py:26-30\ndef clean_content(self):\n    data = self.cleaned_data['content']\n    if not data:\n        raise forms.ValidationError(_(\"Please write a message\"), code='invalid')\n    return data\n```\n\n```python\n# chat/models.py:22\ncontent = models.TextField(\n    blank=True, default='',\n    verbose_name=_(\"Message\")\n)\n```\n\nThe sink is the `message` change-list callable. It runs the content through `linebreaks` and wraps the result in `mark_safe`:\n\n```python\n# chat/site/chatmessageadmin.py:247-256\n@staticmethod\ndef message(obj):\n    if obj.content:\n        text = linebreaks(obj.content)                 # does NOT HTML-escape\n        if getattr(obj, 'is_unread', None):\n            text = f'{text}'\n        if not obj.answer_to:\n            return mark_safe(f'{text}', )\n        return mark_safe(\n            f'{text}'\n        )\n    return LEADERS\n```\n\n`linebreaks` (`django.template.defaultfilters.linebreaks`, imported at `chat/site/chatmessageadmin.py:18`) wraps text in `\n`/`` but does not escape HTML. On the pinned Django 6.0.6, `linebreaks('')` returns `'\n'`. The string is then passed to `mark_safe` (imported at line 22), so the admin template emits it as trusted HTML.\n\n`message` is both displayed and made a link in the change list:\n\n```python\n# chat/site/chatmessageadmin.py:60-64\nlist_display = (\n    'envelope', 'message', 'person', 'recipient_list',\n    'reply', 'files', 'created', 'id'\n)\nlist_display_links = ('message',)\n```\n\nThe change list is what the recipient sees. `get_changelist_instance` selects the rows addressed to the current user before rendering them:\n\n```python\n# chat/site/chatmessageadmin.py:94\nfor msg in cl.result_list.filter(recipients=request.user):\n```\n\nSo a recipient viewing their chat renders a sender's message as a `SafeString` cell, unescaped. No escaping exists anywhere on the content-to-render path.\n\n## Proof of concept\n\nA runtime PoC exists. It exercises the real `message(obj)` callable with the project's pinned `linebreaks` plus `mark_safe` and renders the result the way the admin change-list cell does; the attacker content comes back unescaped.\n\nEnd-to-end steps:\n\n1. Log in as operator A (any account allowed to use CRM chat).\n2. Open a CRM object (for example a deal) and add a chat message addressed to operator B. Set the message content to:\n   ``\n3. Operator B opens the chat for that object. The change list renders A's message, the `onerror` handler fires, and the script executes in B's authenticated admin session.\n\n## Impact\n\nThe injected script runs in the recipient's browser inside their authenticated Django admin session. It can do anything that session is authorized to do: read records the victim can see, read the admin CSRF token, and issue authenticated POST requests as the victim. The attacker chooses the recipient, so the payload can be aimed at a high-privilege account.\n\nThe realistic ceiling is full control of the CRM as an application administrator, bounded by the victim's own permissions. If the recipient is a chief, superoperator, or superuser, the script can create new privileged accounts or elevate the attacker's account by replaying the admin user-management forms with the victim's session and CSRF token. This is script execution in the victim's browser and control of the CRM application. It is not operating-system or server code execution, and it does not by itself give the attacker a shell on the host.\n\n## Remediation\n\nEscape the content before it is marked safe. Either escape the value and keep `linebreaks` in autoescape mode, or build the cell with `format_html` so the placeholder is escaped:\n\n```python\nfrom django.utils.html import format_html\n# ...\nreturn format_html(\n    '{}',\n    linebreaks(obj.content),\n)\n```\n\n`format_html` escapes the interpolated value, which neutralizes the markup while preserving the wrapper span. This is the same `format_html` pattern already used for the email subject in `crm/site/crmemailadmin.py` (`the_subject`, lines 442-448). Apply it to every `mark_safe` branch in `message` that interpolates `obj.content`.\n\n## References\n\n- https://github.com/DjangoCRM/django-crm/blob/main/chat/forms/chatmessageform.py#L26-L30\n- https://github.com/DjangoCRM/django-crm/blob/main/chat/models.py#L22\n- https://github.com/DjangoCRM/django-crm/blob/main/chat/site/chatmessageadmin.py#L247-L256\n- https://github.com/DjangoCRM/django-crm/blob/main/chat/site/chatmessageadmin.py#L60-L64\n- https://github.com/DjangoCRM/django-crm/blob/main/chat/site/chatmessageadmin.py#L94\n", "creation_timestamp": "2026-06-29T16:59:42.373617Z"}