The Exploit
An authenticated user whose document read permissions have been revoked can still receive email notifications containing the full document data by leveraging the Document Follow feature, which fails to re-evaluate access control at notification time.
POST /api/resource/Document%20Follow HTTP/1.1
Host: target.frappe.local
Content-Type: application/json
Cookie: sid=valid_user_session_token
{
"doctype": "Document Follow",
"docstatus": 0,
"ref_doctype": "Sales Order",
"ref_docname": "SO-00123",
"user": "[email protected]",
"document_follow_notify": 1,
"document_follow_frequency": "daily"
}
The attacker must be a valid Frappe user with an active session. When the document follow notification job runs (typically on a daily schedule), the revoked user receives an email containing the full Sales Order data—line items, amounts, customer details—despite having no current permission to view the document in the UI.
What the Patch Did
Before
for document_follow in latest_document_follows:
content = get_message(document_follow.ref_docname, document_follow.ref_doctype, frequency, user)
After
for document_follow in latest_document_follows:
if not frappe.has_permission(
document_follow.ref_doctype, "read", doc=document_follow.ref_docname, user=user
):
frappe.db.delete(
"Document Follow",
{
"ref_doctype": document_follow.ref_doctype,
"ref_docname": document_follow.ref_docname,
"user": user,
},
)
continue
content = get_message(document_follow.ref_docname, document_follow.ref_doctype, frequency, user)
The patch added an explicit frappe.has_permission() check for each followed document at notification-generation time. This re-evaluates the user's current access control state — roles, doctype-level permissions, and document-level sharing rules — rather than trusting the stale Document Follow record. If access has been revoked, the follow is deleted and no notification is sent. A second fix added a permission check at follow-creation time (lines 58–61 in the diff), preventing users from following documents they already cannot access.
Root Cause
CWE-862: Missing Authorization
The vulnerability stems from a trust-time–notification-time mismatch. When a user creates a Document Follow record on a document they can read, Frappe stores a reference to that document with the user. The notification daemon later queries all Document Follow records and generates emails without re-checking whether the user's permissions have changed in the interim. The attacker-controlled value is implicit: the passage of time and a permission revocation event that occurs after the follow is created but before the notification is sent. The dataflow is: (1) user creates Document Follow on accessible document, (2) admin revokes user's read permission via role change or document unsharing, (3) background job iterates latest_document_follows, (4) generates message via get_message() without consulting current User.has_role() or document-level sharing state, (5) email is sent. The trust boundary crossed is the transition from synchronous request (at follow-creation time) to asynchronous job (at notification time), where permission state is not re-evaluated.
Why It Works
The load-bearing line is frappe.has_permission(document_follow.ref_doctype, "read", doc=document_follow.ref_docname, user=user). Without this check, the code proceeds directly to get_message(), which serializes the document for email rendering without access control. The engineer added the frappe.db.delete() block to clean up stale follows — a hygiene measure that prevents the same revoked user from receiving notifications on every scheduled run. The engineer also added a near-identical check at follow-creation time (line 61 in the patch) as defense-in-depth: it prevents the follow from being created in the first place if permissions are already absent, reducing the window for race conditions. Either check alone is insufficient; a user could exploit the creation check by creating a follow while access is granted, then relying on the missing notification-time check after access is revoked. Both checks together close the gap.
Hardening Checklist
-
Re-evaluate access control at action time, not record creation time. Whenever code defers an action to a background job (sending email, generating reports, exporting data), explicitly call the permission evaluation function (e.g.,
frappe.has_permission(),check_perm()) immediately before the action, not at job-queue time. Do not assume roles or sharing rules are static. -
Delete stale permission-dependent records on access revocation. When a user's role or document-level sharing is modified, trigger a cascading cleanup of any records that depend on that access (Document Follow, saved report subscriptions, webhook triggers). Use the same cleanup logic shown in the patch: iterate the dependent records and delete those for which the user no longer has the required permission.
-
Add permission checks to both entry and exit points of a feature. If users can create a reference to a resource (follow, watch, subscribe), validate access at creation time and at use time. Do not rely on either alone.
-
Log and alert on stale permission-dependent records. Before deleting a Document Follow or subscription, emit a debug or warning log. In production, this helps detect either misconfigured permissions or attackers who deliberately create follows before access is revoked, betting on job scheduling delays.
-
Test permission revocation scenarios in integration tests. Write tests that create a follow, revoke access, trigger the notification job, and assert that no email is sent and the follow is deleted. Include tests for role changes, document unsharing, and doctype-level permission removal.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-66000