The Exploit
An authenticated user with any role can follow a restricted document and receive email notifications containing field values and table row changes they have no permission to read.
POST /api/resource/Document%20Follow HTTP/1.1
Host: frappe.example.com
Content-Type: application/json
Cookie: sid=<valid_session_sid>
{
"ref_doctype": "Salary Structure Assignment",
"ref_docname": "SSA-2024-001",
"user": "[email protected]"
}
The server returns HTTP 200. The attacker's inbox receives digest emails containing field change history for Salary Structure Assignment — a DocType restricted at permlevel=1 — including sensitive fields like base_salary, allowances, and deductions that the attacker's role has no permission to view. The attacker never made the document, never granted themselves access, and never received explicit permission; the document follow mechanism bypassed field-level access controls entirely.
What the Patch Did
Before:
def get_field_changed(changed, time, doctype, doc_name, v):
from frappe.core.utils import html2text
items = []
for d in changed:
d[1] = d[1] if d[1] else " "
d[2] = d[2] if d[2] else " "
# ... field change rendered without permission check
After:
def get_field_changed(changed, time, doctype, doc_name, v, user):
from frappe.core.utils import html2text
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
d[1] = d[1] if d[1] else " "
d[2] = d[2] if d[2] else " "
# ... only permitted fields rendered
The patch adds a field-level permission filter via frappe.get_meta(doctype).get_permitted_fieldnames(permission_type="read", user=user). This checks the DocType's access control list (permlevel restrictions) and strips any field from the change history that the requesting user cannot read. The same control was added to get_added_row() and get_row_changed() to filter child table modifications. Additionally, get_message_for_user() now calls frappe.has_permission(document_follow.ref_doctype, "read", doc=document_follow.ref_docname, user=user) to prevent a user from receiving notifications for documents they cannot access at all.
Root Cause
CWE-862: Missing Authorization Check
The dataflow is straightforward: when a user creates a Document Follow record (via the follow_document RPC endpoint or API), the code stores a reference to the target DocType and docname without verifying the user's read permission on that document. Later, when digest emails are generated via get_message_for_user(), the code iterates over that user's Document Follow records and calls get_message() to render the change history. The get_field_changed(), get_added_row(), and get_row_changed() functions extract field names and old/new values from the document's version history without checking the user's field-level permissions (permlevel restrictions). The trust boundary crossed is between the document's ACL (doc-level and field-level permissions stored in the frappe.docperm table) and the change history renderer (which ran with the assumption that if a user could follow a document, they were entitled to see all its fields). Frappe's permission model enforces permlevel via frappe.get_meta(doctype).get_permitted_fieldnames(), but the document follow module never called it.
Why It Works
The load-bearing line is:
if d[0] not in permitted_fieldnames:
continue
Remove it, and the vulnerability persists: the function would still render all field changes, regardless of the user's permlevel. The preceding line — permitted_fieldnames = frappe.get_meta(doctype).get_permitted_fieldnames(...) — is useless without the filter check. The engineer added the user parameter to the function signature so that get_permitted_fieldnames() could evaluate permissions in the context of the recipient, not the system. The complementary check in get_message_for_user() — frappe.has_permission(document_follow.ref_doctype, "read", ...) — prevents the attack at an earlier gate (document-level), but it is not sufficient alone: a user might have read access to a document but restricted access to certain fields within it (e.g., an HR manager can read a Salary Structure Assignment but not the salary fields marked permlevel=1). Both checks are needed because Frappe's permission model is two-tier: document-level and field-level.
Hardening Checklist
- Audit all change-history and audit-log rendering surfaces: Any code that iterates over a document's version history, timeline events, or activity feeds must call
frappe.get_meta(doctype).get_permitted_fieldnames(permission_type="read", user=target_user)and filter field names against it before rendering values. - Require explicit document-level permission checks before subscription: In any subscription, follow, or watcher mechanism, insert
frappe.has_permission(doctype, "read", doc=docname, user=user)before storing the subscription. Do not assume that because a user can request to follow a document, they have the right to read it. - Test permission filters across permlevel boundaries: Add unit tests that create roles with restricted permlevel assignments, create documents, modify fields at different permission levels, and verify that digest emails and change notifications exclude restricted fields. The test helpers
setup_restricted_role()andsetup_restricted_doctype()in the patch are a good template. - Use
frappe.get_meta().get_permitted_fieldnames()as the source of truth for field visibility: Never hardcode field names or assume that the schema defines what a user should see. Always evaluate permissions against the user's role and the DocType's access control matrix. - Strip restricted fields from all serialized outputs: If the document follow feature exports change history to JSON, CSV, or email HTML, ensure the serialization step also filters by
get_permitted_fieldnames(). Do not defer the filter to the presentation layer.
References
- CVE-2026-66059 on NVD
- Frappe Framework GitHub Repository
- Frappe v16.23.0 and v15.112.0 release notes (vendor advisory)