Skip to content

Commit f80af6a

Browse files
committed
Add tests from PR, update CHANGELOG and AUTHORS.
1 parent 939b129 commit f80af6a

4 files changed

Lines changed: 107 additions & 0 deletions

File tree

AUTHORS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Alphabetical list of contributors:
1212
* Aki Ariga <chezou+github@gmail.com>
1313
* Alexander Beedie <ayembee@gmail.com>
1414
* Alexey Malyshev <nostrict@gmail.com>
15+
* alhudz <al.hudz.k@gmail.com>
1516
* ali-tny <aliteeney@googlemail.com>
1617
* andrew deryabin <github@djsf.com>
1718
* Andrew Tipton <andrew.tipton@compareglobalgroup.com>
@@ -77,6 +78,7 @@ Alphabetical list of contributors:
7778
* Tao Wang <twang2218@gmail.com>
7879
* Tenghuan <tenghuanhe@gmail.com>
7980
* Tim Graham <timograham@gmail.com>
81+
* tonghuaroot <tonghuaroot@users.noreply.github.com>
8082
* Victor Hahn <info@victor-hahn.de>
8183
* Victor Uriarte <vmuriart@gmail.com>
8284
* Ville Skyttä <ville.skytta@iki.fi>

CHANGELOG

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ Notable Changes
77
* Migrate project dependencies and environment management from `pixi` to `uv`.
88
* Replace `flake8` with `ruff` for code checking and linting.
99

10+
Security Fixes
11+
12+
* Fix uncontrolled CPU consumption in ``TokenList`` grouping (CWE-1333,
13+
patches by alhudz and tonghuaroot, pr848). Two independent
14+
quadratic behaviours were fixed:
15+
16+
- ``TokenList.__init__`` called ``str(self)`` which recursively flattened
17+
the entire token subtree on every group construction. Deeply nested SQL
18+
(parentheses, CASE WHEN, subqueries) therefore costs O(n·depth) CPU
19+
before the depth cap fires — a ~2 KB payload could pin a worker for 10+
20+
seconds. Fix: join children's already-cached ``value`` fields directly,
21+
reducing per-node cost to O(len(tokens)).
22+
23+
- ``group_tokens()`` recomputed ``grp.value = str(start)`` on every extend
24+
step, re-scanning the entire growing group each time. Wide flat queries
25+
(e.g. SELECT with thousands of columns) therefore cost O(N²) in the
26+
number of columns. Fix: append only the newly added tokens' values.
27+
1028
Enhancements
1129

1230
* Modernize type annotations in top-level API functions using PEP 585 and PEP 604 syntax.

benchmarks/bench_grouping.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Grouping performance benchmarks.
2+
3+
Measures parse time for SQL patterns that stress the grouping engine:
4+
- Deeply nested parentheses
5+
- Deeply nested CASE WHEN expressions
6+
- Wide column lists (tests O(N) identifier grouping, fixed in PR848)
7+
8+
Run with: python benchmarks/bench_grouping.py
9+
"""
10+
11+
import signal
12+
import time
13+
14+
import sqlparse
15+
16+
17+
def _alarm_handler(signum, frame):
18+
raise TimeoutError()
19+
20+
21+
signal.signal(signal.SIGALRM, _alarm_handler)
22+
23+
24+
def measure(label, sql, fn):
25+
signal.alarm(30)
26+
t0 = time.perf_counter()
27+
status = 'OK'
28+
try:
29+
fn(sql)
30+
except sqlparse.exceptions.SQLParseError:
31+
status = 'CAP'
32+
except TimeoutError:
33+
status = 'TIMEOUT'
34+
finally:
35+
signal.alarm(0)
36+
dt = (time.perf_counter() - t0) * 1000
37+
print(f' {status:8} {dt:8.1f} ms {label} ({len(sql)} B)')
38+
39+
40+
# Vector 1: deeply nested parentheses
41+
print('Nested parentheses:')
42+
for n in (200, 500, 1000, 2000):
43+
sql = 'SELECT ' + '(' * n + '1' + ')' * n
44+
measure(f'nested-paren n={n}', sql, sqlparse.parse)
45+
46+
# Vector 2: deeply nested CASE WHEN
47+
print('Nested CASE WHEN:')
48+
for n in (100, 200, 400):
49+
case = '1'
50+
for i in range(n):
51+
case = f'CASE WHEN x={i} THEN {case} ELSE NULL END'
52+
measure(f'CASE-nested n={n}', f'SELECT {case} FROM t', sqlparse.parse)
53+
54+
# Vector 3: wide column lists (O(N) grouping, regression fixed in PR848)
55+
print('Wide column lists:')
56+
for n in (500, 1000, 2000, 4000):
57+
cols = ', '.join(f'col_{i}' for i in range(n))
58+
sql = f'SELECT {cols} FROM t'
59+
measure(f'wide-select n={n}', sql, sqlparse.parse)

tests/test_dos_prevention.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,34 @@ def test_very_large_token_list_limited(self):
5050
with pytest.raises(SQLParseError, match="Maximum number of tokens exceeded"):
5151
sqlparse.format(sql, reindent=True)
5252

53+
def test_nested_paren_within_cap_under_1s(self):
54+
"""Reaching MAX_GROUPING_DEPTH must not require multi-second CPU.
55+
56+
Before the TokenList.__init__ fix, a 1 KB payload of 500 nested
57+
parens took ~1.3 s and a 2 KB payload of 1000 nested parens took
58+
~11 s before the depth cap raised SQLParseError, because each
59+
TokenList materialised its ``value`` via ``str(self)`` which
60+
recursed over the full subtree (O(n * depth)).
61+
"""
62+
sql = 'SELECT ' + '(' * 1000 + '1' + ')' * 1000
63+
t0 = time.perf_counter()
64+
with pytest.raises(SQLParseError, match='Maximum grouping depth'):
65+
sqlparse.parse(sql)
66+
dt = time.perf_counter() - t0
67+
assert dt < 1.0, f'parse took {dt:.2f}s, expected sub-second'
68+
69+
def test_nested_case_within_cap_under_1s(self):
70+
"""Same invariant as nested parentheses, exercised via CASE WHEN."""
71+
case = '1'
72+
for i in range(400):
73+
case = f'CASE WHEN x={i} THEN {case} ELSE NULL END'
74+
sql = f'SELECT {case} FROM t'
75+
t0 = time.perf_counter()
76+
with pytest.raises(SQLParseError, match='Maximum grouping depth'):
77+
sqlparse.parse(sql)
78+
dt = time.perf_counter() - t0
79+
assert dt < 1.0, f'parse took {dt:.2f}s, expected sub-second'
80+
5381
def test_normal_sql_still_works(self):
5482
"""Test that normal SQL still works correctly after DoS protections."""
5583
sql = """

0 commit comments

Comments
 (0)