SECURITY ADVISORY / 01

CVE-2026-66059 Exploit & Vulnerability Analysis

Complete CVE-2026-66059 security advisory with proof of concept (PoC), exploit details, and patch analysis for frappe.

frappe products NVD ↗
Exploit PoC Vulnerability Patch Analysis

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() and setup_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

Frequently asked questions about CVE-2026-66059

What is CVE-2026-66059?

CVE-2026-66059 is a security vulnerability identified in frappe. This security advisory provides detailed technical analysis of the vulnerability, exploit methodology, affected versions, and complete remediation guidance.

Is there a PoC (proof of concept) for CVE-2026-66059?

Yes. This writeup includes proof-of-concept details and a technical exploit breakdown for CVE-2026-66059. Review the analysis sections above for the PoC walkthrough and code examples.

How does CVE-2026-66059 get exploited?

The technical analysis section explains the vulnerability mechanics, attack vectors, and exploitation methodology affecting frappe. PatchLeaks publishes this information for defensive and educational purposes.

What products and versions are affected by CVE-2026-66059?

CVE-2026-66059 affects frappe. Check the affected-versions section of this advisory for specific version ranges, vulnerable configurations, and compatibility information.

How do I fix or patch CVE-2026-66059?

The patch analysis section provides guidance on updating to patched versions, applying workarounds, and implementing compensating controls for frappe.

What is the CVSS score for CVE-2026-66059?

The severity rating and CVSS scoring for CVE-2026-66059 affecting frappe is documented in the vulnerability details section. Refer to the NVD entry for the current authoritative score.