The Exploit
An attacker whose document read permissions have been revoked or restricted can continue to receive email notifications containing the full document data and field-level changes by remaining in the Document Follow subscription list.
POST /api/resource/Document%20Follow HTTP/1.1
Host: target-frappe-instance.com
Content-Type: application/json
Authorization: Bearer <valid-but-restricted-token>
{
"ref_doctype": "Sales Order",
"ref_docname": "SO-2024-001",
"user": "[email protected]"
}
When the Document Follow notification is generated and sent via email, the attacker receives the full changed values, field names, and child table modifications—even though their role no longer grants read access to the Sales Order doctype. The email lands in their inbox containing sensitive fields like pricing, customer PII, and internal notes that should be inaccessible under the new permission model.
What the Patch Did
Before:
def get_message_for_user(user, frequency):
messages = {}
latest_document_follows = frappe.get_list(
"Document Follow",
filters={"user": user, "enabled": 1},
fields=["ref_doctype", "ref_docname", "creation"]
)
for document_follow in latest_document_follows:
content = get_message(
document_follow.ref_docname,
document_follow.ref_doctype,
frequency,
user
)
messages[f"{document_follow.ref_doctype}:{document_follow.ref_docname}"] = content
def get_field_changed(changed, time, doctype, doc_name, v):
items = []
for d in changed:
items.append(f"{d[0]}: {d[1]}")
return items
After:
def get_message_for_user(user, frequency):
messages = {}
latest_document_follows = frappe.get_list(
"Document Follow",
filters={"user": user, "enabled": 1},
fields=["ref_doctype", "ref_docname", "creation"]
)
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(...)
messages[...] = content
def get_field_changed(changed, time, doctype, doc_name, v, user):
items = []
permitted_fieldnames = frappe.get_meta(doctype).get_permitted_fieldnames(
permission_type="read", user=user
)
for d in changed:
if d[0] not in permitted_fieldnames:
continue
items.append(f"{d[0]}: {d[1]}")
return items
The patch introduced three overlapping security controls: (1) a document-level frappe.has_permission() check in the notification generation loop that deletes stale follows from revoked users; (2) a field-level permission filter using get_permitted_fieldnames() that restricts which field changes are included in the email body; and (3) the same control applied to child table change tracking. Each control operates at a different layer—document access, field visibility, and row visibility respectively.
Root Cause
CWE-862: Missing Authorization. The Document Follow notification system cached follow relationships in the database without re-evaluating the follower's current permissions at notification send time. When a user's role was updated, revoked, or scoped, the system never checked whether that user still retained read access to the followed document before including its data in the email payload. The dataflow is straightforward: a follower's database row (ref_doctype, ref_docname, user) flows directly into get_message() without passing through frappe.has_permission(), crossing the trust boundary from stored state into an output channel (email) without validation.
Why It Works
The load-bearing line is the frappe.has_permission(document_follow.ref_doctype, "read", ...) check in the notification loop. Without it, the follow record still exists in the database and the notification generator still calls get_message() and compiles the email. Removing that single line resurrects the vulnerability entirely. The subsequent field-level filter in get_field_changed() and get_row_changed() is defense-in-depth: it ensures that even if somehow a revoked user's follow survives to the email generation stage, sensitive fields are redacted. The engineer added the field-level checks because permission models are often hierarchical—a user might lose access to a document entirely, or might retain read access to the document but only certain fields (e.g., a sales rep can see customer name but not cost). Filtering at both layers makes the control resilient to future permission model changes.
Hardening Checklist
-
Add permission re-evaluation before notification dispatch: For any subscription, saved-search, or follow feature that generates emails or reports, call the authorization framework (e.g.,
frappe.has_permission()) on every included document at send time, not just at subscription creation time. Store no implicit assumption that past permissions remain current. -
Filter sensitive fields in output templates: Use the framework's field-level permission API (e.g.,
get_permitted_fieldnames()in Frappe, or equivalent field-level ACL in your framework) to whitelist which fields appear in emails. Never output all changed fields unconditionally. -
Audit follow/subscription deletion on role changes: Implement a hook that fires when a user's role or permissions are updated, and delete or flag Document Follow records for documents the user can no longer read. This prevents stale follows from existing in the first place.
-
Log notification generation with audit context: Record which user triggered the notification and what fields were included in the email body. This makes post-breach forensics viable and surfaces over-permissive notifications during code review.
-
Test permission revocation end-to-end: In the test suite, create a follow as User A, revoke User A's access, regenerate notifications, and assert the email is not sent or contains no data. This is not tested by default because it crosses the boundary between permission state and notification state.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-66000