The Exploit
An authenticated user with any role can follow documents they should not have read access to, and receive change notifications for restricted data.
POST /api/resource/Document%20Follow HTTP/1.1
Host: frappe.example.com
Content-Type: application/json
Authorization: Bearer <valid_session_token>
{
"ref_doctype": "Salary Structure",
"ref_docname": "EMP-2024-001",
"user": "[email protected]"
}
The server responds with HTTP 200 and a Document Follow record is created. The attacker's account is now following a salary structure document, and will receive email notifications whenever that document is updated—including sensitive fields like ctc and base—even though the attacker's role grants no read permission on the Salary Structure doctype. The follow persists across sessions and the attacker continues receiving notifications until the record is manually deleted by an administrator.
What the Patch Did
Before:
def add_document_follow(doctype, doc_name, user):
if user != frappe.session.user and not frappe.has_permission("Document Follow", "write"):
frappe.throw(_("You can only follow documents for yourself."), frappe.PermissionError)
if not frappe.db.get_value("User", user, "document_follow_notify", ignore=True, cache=True):
frappe.toast(_("Document follow is not enabled for this user."))
return False
After:
def add_document_follow(doctype, doc_name, user):
if user != frappe.session.user and not frappe.has_permission("Document Follow", "write"):
frappe.throw(_("You can only follow documents for yourself."), frappe.PermissionError)
if not frappe.has_permission(doctype, "read", doc=doc_name, user=user):
frappe.throw(_("You do not have permission to access this document."), frappe.PermissionError)
if not frappe.db.get_value("User", user, "document_follow_notify", ignore=True, cache=True):
frappe.toast(_("Document follow is not enabled for this user."))
return False
The patch added a document-level permission check using frappe.has_permission(doctype, "read", doc=doc_name, user=user). This function validates whether the calling user (or the target user if different) holds explicit read permission on the specific document before allowing the follow relationship to be created. The check happens before any notification setting is queried, preventing the creation of follow records for inaccessible documents.
The patch also modified get_message_for_user() to filter out stale follows:
Before:
for document_follow in latest_document_follows:
content = get_message(document_follow.ref_docname, document_follow.ref_doctype, frequency, user)
if content:
message = message + content
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)
if content:
message = message + content
This defense-in-depth control re-validates permissions at notification time and removes invalid follow records, preventing information leakage even if stale follows existed.
Root Cause
CWE-862: Missing Authorization. The update_follow endpoint (called by add_document_follow) accepted the doctype and doc_name parameters from the HTTP request without verifying that the authenticated user held read permission on the target document. The function checked whether the user could manage Document Follow records themselves, but did not gate access based on the document being followed. This trust boundary violation allowed any authenticated user to subscribe to change notifications for any document in the system, bypassing role-based access control (RBAC). The attacker's document reference travels from the JSON request body into the doctype and doc_name variables, flows directly into the follow record creation query, and crosses the authorization boundary without validation.
Why It Works
The load-bearing line is frappe.has_permission(doctype, "read", doc=doc_name, user=user). Without it, an authenticated user can craft a follow request for any doctype and docname pair, and the server accepts it because it only checks whether the user can manage Document Follow records (a metadata permission), not whether they can read the target document itself. If you remove that single line, the bug remains fully exploitable; any follow request succeeds. The engineers added the duplicate check in get_message_for_user() because permissions can change after a follow is created (a user's role can be revoked, or a document can be made private)—this reactive filter cleans up orphaned follows and prevents leakage during the notification window. Without the proactive check at creation time, however, the reactive cleanup is mere damage control.
Hardening Checklist
-
Always validate document-level permissions before creating relationships. If a user can "follow," "star," "subscribe to," or "tag" a document, call
frappe.has_permission(doctype, "read", doc=doc_name)before persisting the relationship, not just after. Do not assume that metadata permissions (like "can manage Document Follow") imply data access. -
Implement re-validation filters in notification and export workflows. When iterating over user-created subscriptions or preferences, re-check permissions before including data in responses or background jobs. Use the same permission check at both creation and consumption time to defend against role changes.
-
Audit all endpoints that accept document references as parameters. Grep for
frappe.call,frappe.db.get_value, or HTTP handlers that takedoctypeanddoc_nameas input. Ensure each one callsfrappe.has_permission(doctype, "read", doc=...)or equivalent before returning or processing the document. -
Test permission bypasses with restricted roles. Create a test user with no access to a sensitive doctype (e.g., Salary Structure, Bank Account). Attempt to follow, star, share, or export that document via the API. If the request succeeds, the permission check is missing.
-
Log all follow/subscription creation attempts. Include the user, target doctype, and target docname in audit logs. This enables rapid detection if an attacker mass-follows documents before permissions are re-validated.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-66058