The Exploit
An authenticated user with read permission on a document can retrieve field-level changes for any other document in the same DocType, including fields they do not have permission to view.
GET /api/resource/Document%20Follow?fields=["name","ref_doctype","ref_docname","user"]&filters=[["ref_doctype","=","Employee"],["ref_docname","=","EMP-001"]] HTTP/1.1
Host: frappe.example.com
Authorization: Bearer <valid_user_token>
Content-Type: application/json
The attacker observes the Document Follow records for a target document. They then request the timeline with field-change details:
GET /api/method/frappe.desk.form.document_follow.get_message_for_user?ref_doctype=Employee&ref_docname=EMP-001&user=target_user HTTP/1.1
Host: frappe.example.com
Authorization: Bearer <valid_user_token>
The response body includes detailed field-change history for EMP-001, including restricted fields such as salary_band, performance_rating, and background_check_status — fields the attacker has no DocType-level permission to view. The attacker learns sensitive business logic and personal data without explicit field-level ACL grant.
What the Patch Did
Before
## Lines 153–157 (old) — get_message_for_user had no permission validation
for document_follow in latest_document_follows:
content = get_message(document_follow.ref_docname, document_follow.ref_doctype, frequency, user)
# ... timeline_items assembled without field permission checks
## Lines 210–215 (old) — child field changes returned without ACL
timeline_items = get_field_changed(change.changed, time, doctype, doc_name, v)
timeline_items = get_row_changed(change.row_changed, time, doctype, doc_name, v)
timeline_items = get_added_row(change.added, time, doctype, doc_name, v)
After
## Lines 159–169 (new) — explicit document read permission check
if not frappe.has_permission(
document_follow.ref_doctype, "read", doc=document_follow.ref_docname, user=user
):
frappe.db.delete("Document Follow", {...})
continue
## Lines 215–218 (new) — user parameter passed to permission-checking functions
timeline_items = get_field_changed(change.changed, time, doctype, doc_name, v, user)
timeline_items = get_row_changed(change.row_changed, time, doctype, doc_name, v, user)
timeline_items = get_added_row(change.added, time, doctype, doc_name, v, user)
## Lines 318–325 (new) — field-level ACL enforcement in get_field_changed
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
The patch introduces two load-bearing security controls: (1) document-level permission check via frappe.has_permission(doctype, "read", doc=doc_name, user=user) before exposing any timeline, and (2) field-level ACL filtering via get_permitted_fieldnames(), which returns only those fields the user is explicitly allowed to read. These are chained: the document permission gates access to the timeline; the field permission filters which changes are visible within that timeline.
Root Cause
CWE-862: Missing Authorization.
The document_follow.py module implements a "follow document" feature that stores and retrieves field-change history. The vulnerability occurs in the message-generation pipeline: when get_message_for_user() is called with a ref_doctype and ref_docname, it fetches all Document Follow records for that document and assembles a changelog without validating whether the requesting user has read permission on the target document or on each field within that document. The user parameter is recorded but never passed to downstream functions (get_field_changed, get_row_changed, get_added_row), so those functions cannot enforce field-level ACLs. An attacker who has read permission on any document of type Employee can call get_message_for_user for any Employee document and retrieve field changes for restricted fields.
Why It Works
The critical line is:
if not frappe.has_permission(
document_follow.ref_doctype, "read", doc=document_follow.ref_docname, user=user
):
This single frappe.has_permission() call gates the entire timeline. Without it, the loop proceeds unconditionally. The engineer then added the user parameter to get_field_changed(), get_row_changed(), and get_added_row() so that each function could call get_permitted_fieldnames(permission_type="read", user=user) and skip restricted fields. If you removed only the document-level permission check, an attacker could still bypass the field-level filter by calling the lower-level functions directly; if you removed only the field parameter passing, an attacker could still read the timeline, and the field filtering would be ineffective (no user context to check against). The defence is layered: first, is this user allowed to see this document at all? Second, which fields of the changes in that document is this user allowed to see? Both gates must be closed.
Hardening Checklist
-
Always pass the current user context to downstream permission checks. If a function receives a user ID as input, pass it to every permission-checking call; do not rely on implicit global state (
frappe.session.user). This prevents an attacker from specifying an arbitrary target user in a query parameter and leaking their view. -
Implement field-level ACL filtering at the serialization boundary. Before returning document field changes in JSON or HTML, call
get_permitted_fieldnames()(or equivalent) and exclude any field not in the result set. This prevents the data layer from leaking columns that the application layer does not intend to expose. -
Chain document-level and field-level checks in sequence. Deny access to the document first (
frappe.has_permission(doctype, "read", doc=...)), then filter fields. Do not filter fields and assume document permission is implied; the logic is easier to audit in the order: gate → filter → return. -
Add integration tests that assert permission boundaries. Create two users with different field-level ACLs on the same DocType, and verify that User A's timeline call does not leak User B's restricted fields. Test both the happy path (User A sees their own changes to restricted fields) and the attack path (User A requests User B's document and sees nothing).
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-66059