TL;DR

  • What: In MailWatch, the web front end for MailScanner, the quarantine action endpoint looked a message up by its id alone. The message viewer and the quarantine listings add the caller's domain scope to the same lookup. In my local test on v1.2.26, a regular user of one domain got "not found" when viewing another domain's quarantined message, then deleted it through quarantine_action.php with their own session and token.
  • Impact: per the advisory, any authenticated user could release to the inbox or permanently delete quarantined mail belonging to other users or domains. Rated High 8.1 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H). CWE-639 and CWE-566.
  • Fixed in 1.2.27, advisory GHSA-w7v5-7g6w-87r4, credited to me as reporter. CVE-2026-85749.

Why you should care

MailWatch's README describes it as a web-based front end to MailScanner. MailScanner holds back mail it flags as spam, a virus or dangerous content, and MailWatch is where people look at that quarantine and decide what to release or delete. One install can serve many users and domains, each with a role: user, domain admin, host, or administrator.

Quarantine is a security control. Releasing a message delivers something the filter stopped. Deleting one removes it for good.

The setup

At login MailWatch builds a SQL condition for the caller's role and stores it in the session as $_SESSION['global_filter']. For a regular user it limits rows to that user's own addresses. Only an administrator gets everything.

The read paths use it. This is the message viewer, mailscanner/viewmail.php at v1.2.26, line 57:

$sql = "SELECT * FROM maillog WHERE id='" . $message_id . "' AND " . $_SESSION['global_filter']; 

A message outside your scope returns no row, and the viewer says it was not found.

The bug

The release and delete actions go through mailscanner/quarantine_action.php. At v1.2.26, lines 82-87:

$id = deepSanitizeInput($_GET['id'], 'url'); if (false === $id || !validateInput($id, 'msgid')) { exit; } $list = quarantine_list_items($id); 

quarantine_list_items() in mailscanner/functions.php, lines 3528-3543, selects by id and nothing else:

function quarantine_list_items($msgid, $rpc_only = false) { $sql = " SELECT hostname, DATE_FORMAT(date,'%Y%m%d') AS date, id, to_address, ... FROM maillog WHERE id = '$msgid'"; 

The endpoint checks that you are logged in, and each action checks your CSRF token. Neither tells it whose message the id belongs to. So the action ran on whatever message the id named.

The "aha"

The viewer and the delete button asked the database for the same message. Only the viewer asked "and is it yours?"

Proof of concept (benign)

I ran MailWatch v1.2.26 locally in Docker with synthetic data only: php:8.2-apache, and mariadb:10.6 in place of the project's mysql:5.7 with the same schema. MailScanner itself was not installed, so a minimal config file stood in for it. Two domains, one regular user each: userb@b.example (the caller) and victimuser@a.example (the owner). One quarantined message addressed to the owner, id 9XyZaBcDeFgHij, with its file on disk. Both users logged in normally and used their own session and CSRF token.

These are the recorded requests and results, as the caller:

# control: the viewer refuses the other domain's message GET /viewmail.php?id=9XyZaBcDeFgHij&token=<caller's token> -> 200, "Message '9XyZaBcDeFgHij' not found"  # before maillog: 9XyZaBcDeFgHij | a.example | quarantined=1 quarantine/20260706/9XyZaBcDeFgHij/message.eml present # the action, same id, same token GET /quarantine_action.php?token=<caller's token>&id=9XyZaBcDeFgHij&action=delete&html=true&confirm=true -> 200, "Delete: deleted file /var/spool/MailScanner/quarantine/20260706/9XyZaBcDeFgHij/message.eml"  # after maillog: 9XyZaBcDeFgHij | a.example | quarantined=NULL quarantine/20260706/9XyZaBcDeFgHij/ (empty) 

As a positive control, the owner opened the same message in the viewer and got the full page with its own delete link.

Honest boundary: delete is what the run demonstrated over HTTP. Release goes through the same lookup, so by the source it had the same gap, but I did not run it. One message and two regular users, not domain admins or the host role. The test used MariaDB rather than MySQL and a stub MailScanner config. I did not re-run the test on a fixed version.

The fix

Shipped in 1.2.27 (release, 2026-07-30), merge commit bc04f25. The release notes put it as "Apply session filter consistently in quarantine operations". quarantine_list_items() now takes the filter and adds it to the query, at v1.2.27, lines 3529-3549:

function quarantine_list_items($msgid, $rpc_only = false, $global_filter = null) { ... WHERE id = '$msgid'"; if (null !== $global_filter) { $sql .= " AND ($global_filter)"; } 

and quarantine_action.php passes the session's filter to the lookup and to both actions:

$list = quarantine_list_items($id, false, $_SESSION['global_filter']); ... $result = quarantine_release($list, [0], $to, false, $_SESSION['global_filter']); ... $status[] = quarantine_delete($list, [$i], false, $_SESSION['global_filter']); 

quarantine_delete() and quarantine_release() also re-run the lookup with the filter before they act, so a message outside your scope is not found at all. The same commit touched detail.php and do_message_ops.php. The advisory credits endelwar as remediation developer.

Takeaways

  1. If a lookup is shared, put the scope inside it. The viewer added the session filter to its own query. The action called a helper that had no way to receive one. The fix gave the helper a filter parameter, so every caller that passes it gets the same answer.
  2. "Not found" on read should mean "not found" on write. If a user cannot see a record, every action that names the record should fail the same way.
  3. A CSRF token proves the request came from your session. It says nothing about which records your session may touch. Here both checks were present, and neither was an ownership check.

Disclosure timeline (UTC)

  • 2026-07-05: found during a source review of the quarantine code.
  • 2026-07-06: reproduced locally on v1.2.26 (synthetic data).
  • 2026-07-18: reported privately through GitHub's private vulnerability reporting.
  • 2026-07-30: v1.2.27 released with the fix, and advisory GHSA-w7v5-7g6w-87r4 published by the maintainers.
  • 2026-10-28: CVE-2026-85749 published to the CVE List.

Credit / CTA

If you run MailWatch, upgrade to the latest release. The fix for this issue shipped in 1.2.27, and 1.2.28 and 1.2.29 (November 2026) fix further security issues, including GHSA-fx6j-399x-49w7.

If you maintain a multi-tenant app, try this: take a record a user can't see, and send its id to every action endpoint, not just the view. Each one should fail the way the view does.

Reported by me. The advisory credits endelwar as remediation developer. Comments and corrections welcome.

References

  1. MailWatch security advisory GHSA-w7v5-7g6w-87r4, "IDOR lets any authenticated user release or delete any other tenant's quarantined mail", published 2026-07-30. https://github.com/mailwatch/MailWatch/security/advisories/GHSA-w7v5-7g6w-87r4
  2. CVE-2026-85749. https://www.cve.org/CVERecord?id=CVE-2026-85749
  3. Fix merge commit bc04f25. https://github.com/mailwatch/MailWatch/commit/bc04f25eb0cec028cbd4ded9c049cfc6f663a15f
  4. MailWatch release notes, v1.2.27. https://github.com/mailwatch/MailWatch/releases/tag/v1.2.27
  5. mailscanner/quarantine_action.php at v1.2.26 (before the fix). https://github.com/mailwatch/MailWatch/blob/v1.2.26/mailscanner/quarantine_action.php
  6. mailscanner/functions.php at v1.2.27 (after the fix). https://github.com/mailwatch/MailWatch/blob/v1.2.27/mailscanner/functions.php
  7. MITRE CWE-639, Authorization Bypass Through User-Controlled Key. https://cwe.mitre.org/data/definitions/639.html

Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). GitHub: @Santoshkumarpuppala