{"uuid": "0769fb1b-7dde-4ff1-8344-5a11b240d163", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-37r3-q4cw-f7gq", "type": "seen", "source": "https://gist.github.com/CyberKareem/37b525da8fcc1cc9bd9ee5e50b75f2b2", "content": "# DjangoCRM (django-crm): Cross-department email IDOR (confidential email disclosure) (CWE-639)\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:** 7.1 High \u2014 `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N`\n- **CWE:** CWE-639 (Authorization Bypass Through User-Controlled Key)\n- **Reported:** GitHub private vulnerability reporting, GHSA-37r3-q4cw-f7gq; 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\ndjango-crm is a multi-department CRM built on the Django admin. Records are scoped per department: the base admin queryset filters every model to the requesting user's department, so a manager in one department cannot see another department's data. Three custom views that fetch and render original email content skip that scoping. They look up a `CrmEmail` (and, in one variant, an arbitrary `EmailAccount`) by a client-supplied integer id with the default manager and no owner or department binding, gated only by `staff_member_required`. Any staff user can read another department's email bodies, headers, and attachments, pulled live from the IMAP server.\n\n## Root cause\n\nThe three email views are wired in `crm/urls.py` behind nothing but `staff_member_required`, which is the stock Django decorator that checks `is_active and is_staff` (imported at `crm/urls.py:2`). It does no object-, owner-, or department-level check:\n\n```python\n# crm/urls.py:80-84\npath(\n    'view-original-email//',\n    staff_member_required(view_original_email),\n    name='view_original_email'\n),\n```\n\nThe same pattern covers `download-original-email//` (`crm/urls.py:91-95`), `view-original-email-uid///` (`crm/urls.py:85-89`), and the `DetailView`-backed `print-email/` (`crm/urls.py:46-54`) and `print-request/` (`crm/urls.py:55-63`).\n\nThe object is then resolved by the raw URL id. In `crm/views/view_original_email.py`, `get_ea_eml_uid` takes `object_id` straight off the URL and calls the default manager with no reference to `request.user`:\n\n```python\n# crm/views/view_original_email.py:105-110\nif object_id:   # crmemail.id\n    eml = CrmEmail.objects.get(id=object_id)\n    uid = eml.uid\n    ea = EmailAccount.objects.filter(email_host_user=eml.email_host_user).first()\nelif ea_id:\n    ea = EmailAccount.objects.get(id=ea_id)\n```\n\n`view_original_email` then IMAP-fetches `BODY[]` for that id, decodes the message, and renders it through `mark_safe(body)` into the email template (`crm/views/view_original_email.py:96`). The `download_original_email` view has the identical bare-pk lookup and returns the full raw `.eml`:\n\n```python\n# crm/views/download_original_email.py:18\ncrm_email = CrmEmail.objects.get(id=object_id)\n# ...\n# crm/views/download_original_email.py:43\nresponse = HttpResponse(data[0][1], content_type=\"message/rfc822\")  # full .eml\n```\n\nThe `view-original-email-uid` form is broader: it takes an arbitrary `EmailAccount` id (`EmailAccount.objects.get(id=ea_id)`, `crm/views/view_original_email.py:110`) plus an arbitrary IMAP `uid` off the URL, so a staff user can fetch any message by uid from any configured mailbox.\n\nThat department boundary is real and enforced everywhere else. The base CRM admin that `CrmEmailAdmin` inherits scopes every queryset to the caller's department:\n\n```python\n# crm/site/crmmodeladmin.py:238-248\ndef get_queryset(self, request):\n    qs = super().get_queryset(request)\n    if request.user.department_id:\n        return qs.filter(department_id=request.user.department_id)\n    elif request.user.is_superoperator:\n        return qs.filter(\n            department__in=request.user.groups.filter(\n                department__isnull=False\n            )\n        )\n    return qs\n```\n\n`CrmEmail` carries both of the keys that scoping relies on. It inherits `owner` from `Base` (`common/models.py:31`) and `department` from `Base1` (`common/models.py:71`) through the chain `CrmEmail(BaseEml)` \u2192 `BaseEml(Base1)` \u2192 `Base1(Base)`. `CrmEmailAdmin.has_change_permission` narrows edit rights even further to the related deal's `owner`/`co_owner` (`crm/site/crmemailadmin.py:265-277`). The admin layer treats these exact `CrmEmail` objects as per-department, per-owner confidential. The three custom views are the siblings that read the same objects with the department check removed.\n\n## Proof of concept\n\nA runtime PoC exists: a Django test (`test_email_idor.py`) built on the project's own fixtures, which creates an email owned by the Bookkeeping department, calls the email-view resolver as a Global-department staff user, and asserts the other department's confidential content comes back. It passes against the project's standard test data.\n\nManual reproduction:\n\n1. Create two staff users in two different departments (the shipped fixtures include `Valeria.Operator.Global` in Global and `Masha.Co-worker.Bookkeeping` in Bookkeeping).\n2. As the Bookkeeping user, receive or create a `CrmEmail`. Note its integer id (visible in the admin URL, or enumerable).\n3. Log in as the Global-department user and request `GET /view-original-email//` (or `/download-original-email//`) for that id.\n4. The view resolves the email with `CrmEmail.objects.get(id=object_id)`, fetches it from IMAP, and returns the full decoded body and attachments, even though the email belongs to a department the caller cannot see in the admin.\n5. The id is a small sequential integer, so an attacker walks `1..N` to sweep other departments' email. The `/view-original-email-uid///` route widens this to any message by uid from any configured `EmailAccount`.\n\n## Impact\n\nAny authenticated low-privilege staff user, in any department, can read another department's confidential email: full bodies, headers, and attachments, fetched live from the IMAP server. By incrementing `object_id` (or sweeping `ea_id`/`uid`), the attacker enumerates and reads correspondence belonging to departments they are not a member of, which breaks the application's department confidentiality boundary. This is the customer/deal/lead/contact email traffic the CRM stores.\n\nThe attacker controls only the URL integer id and needs an ordinary staff/department-manager account, which the product hands to regular sales managers. The disclosure drives the `C:H` rating. The `I:L` in the CVSS vector comes from the related mailing-campaign write sinks folded into the same merged advisory, not from these read paths, which have no integrity or availability effect on their own. The ceiling here is application-level data disclosure across departments. There is no OS- or server-level code execution, and the bug does not grant the Django `is_superuser` bit.\n\n## Remediation\n\nScope every one of these views to the caller before the IMAP fetch, mirroring `CrmModelAdmin.get_queryset`. Replace the bare `CrmEmail.objects.get(id=object_id)` with a department/owner-scoped lookup, for example:\n\n```python\nqs = CrmEmail.objects.filter(department_id=request.user.department_id)\ncrm_email = get_object_or_404(qs, id=object_id)\n```\n\nFor the `ea_id`/`uid` route, restrict `EmailAccount` to accounts owned by the caller's department before fetching by uid, and deny the request if the resolved account or email falls outside the caller's department. Apply the same scoping to the `DetailView`-backed `print-email` and `print-request` routes, since they share the bare-pk-without-department-scope shape.\n\n## References\n\n- https://github.com/DjangoCRM/django-crm/blob/main/crm/urls.py\n- https://github.com/DjangoCRM/django-crm/blob/main/crm/views/view_original_email.py\n- https://github.com/DjangoCRM/django-crm/blob/main/crm/views/download_original_email.py\n- https://github.com/DjangoCRM/django-crm/blob/main/crm/site/crmmodeladmin.py\n- https://github.com/DjangoCRM/django-crm/blob/main/crm/site/crmemailadmin.py\n- https://github.com/DjangoCRM/django-crm/blob/main/common/models.py\n", "creation_timestamp": "2026-06-29T16:59:42.316032Z"}