python3-sqlparse: patch CVE-2026-59893

Details:
https://nvd.nist.gov/vuln/detail/cve-2026-59893

Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
Ankur Tyagi authored and Anuj Mittal committed 2026-10-05 14:40:46 +05:30
1 parent bf592926cd
commit 2fa32e5e27
2 files changed
+294

No files matched your search

@@ -0,0 +1,293 @@
From 3cca1f008a24b9817137d020eeed87efa5ed68d3 Mon Sep 17 00:00:00 2001
From: Andi Albrecht <albrecht.andi@gmail.com>
Date: Wed, 1 Jul 2026 08:38:53 +0200
Subject: [PATCH] Fix uncontrolled CPU consumption (ReDoS) in the lexer's
handling of dollar-quoted literals and multiline comments.
(cherry picked from commit d1d80602741f77ec78e5a04ce4719244cf32352e)
CVE: CVE-2026-59893
Upstream-Status: Backport [https://github.com/andialbrecht/sqlparse/commit/d1d80602741f77ec78e5a04ce4719244cf32352e]
Dropped changes to the CHANGELOG file.
Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
benchmarks/bench_dollar_quote_redos.py | 90 ++++++++++++++++++++++++++
sqlparse/keywords.py | 60 ++++++++++++++++-
sqlparse/lexer.py | 8 +++
sqlparse/utils.py | 46 ++++++++++++-
4 files changed, 200 insertions(+), 4 deletions(-)
create mode 100644 benchmarks/bench_dollar_quote_redos.py
diff --git a/benchmarks/bench_dollar_quote_redos.py b/benchmarks/bench_dollar_quote_redos.py
new file mode 100644
index 0000000..234d9c4
--- /dev/null
+++ b/benchmarks/bench_dollar_quote_redos.py
@@ -0,0 +1,90 @@
+"""Delimited-literal lexer benchmark (GHSA-prg7-hcfm-mfcr).
+
+Measures parse time for SQL text containing many unique, unmatched
+opening delimiters for the two lexer constructs that used a lazy dot-all
+regex (`[\\s\\S]*?`) terminated by a backreference or a literal closing
+sequence:
+
+- Dollar-quoted literals, e.g. `$a0$x $a1$x ... $aN$x` (backreference).
+- Multiline comments, e.g. `/* unique0 ... /* unique1 ...` (literal `*/`).
+
+When no closing delimiter is present, a lazy dot-all quantifier applied at
+every text position must scan to the end of the remaining input for every
+opener, which is O(n^2) total work as the number of openers grows.
+
+This benchmark does not assert a pass/fail threshold, since absolute timings
+and scaling ratios depend on the host machine. It exists to make the
+runtime characteristics of these code paths observable and to let it be
+re-run (e.g. after a fix) to confirm that scaling has improved.
+
+Run with: python benchmarks/bench_dollar_quote_redos.py
+"""
+
+import signal
+import time
+
+import sqlparse
+from sqlparse.engine import grouping
+
+# Disable the grouping-stage DoS guards. They fire only after lexing
+# completes and do not bound regex CPU time, so they would otherwise mask
+# the lexer's true (unbounded) timing behind a SQLParseError at larger n.
+grouping.MAX_GROUPING_DEPTH = None
+grouping.MAX_GROUPING_TOKENS = None
+
+
+def _alarm_handler(signum, frame):
+ raise TimeoutError()
+
+
+signal.signal(signal.SIGALRM, _alarm_handler)
+
+
+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)')
+ return dt
+
+
+def make_dollar_quote_payload(n):
+ # N unique, never-closed dollar-quote openers. Each is unique so the
+ # backreference regex cannot short-circuit on an earlier match.
+ return ' '.join(f'$a{i}$x' for i in range(n))
+
+
+def make_comment_payload(n):
+ # N unique, never-closed multiline comment openers. No '*/' appears
+ # anywhere, so the closing literal can never short-circuit the scan.
+ return ' '.join(f'/* unique{i} comment never closed' for i in range(n))
+
+
+def run_scaling(label, make_payload, sizes=(250, 500, 1000, 2000, 4000, 8000)):
+ print(f'{label}:')
+ timings = {}
+ for n in sizes:
+ sql = make_payload(n)
+ timings[n] = measure(f'{label} n={n}', sql, sqlparse.parse)
+
+ print()
+ print('Scaling ratios (O(n^2) implies ~4x time per 2x input):')
+ for prev, curr in zip(sizes, sizes[1:]):
+ if timings[prev] > 0:
+ ratio = timings[curr] / timings[prev]
+ print(f' n={prev} -> n={curr} (input x{curr / prev:.1f}): '
+ f'time ratio = {ratio:.2f}x')
+ print()
+
+
+run_scaling('Unmatched dollar-quote openers', make_dollar_quote_payload)
+run_scaling('Unclosed multiline comments', make_comment_payload)
diff --git a/sqlparse/keywords.py b/sqlparse/keywords.py
index 874431f..243f389 100644
--- a/sqlparse/keywords.py
+++ b/sqlparse/keywords.py
@@ -5,7 +5,10 @@
# This module is part of python-sqlparse and is released under
# the BSD License: https://opensource.org/licenses/BSD-3-Clause
+import re
+
from sqlparse import tokens
+from sqlparse.utils import _DelimiterOccurrence, resolve_paired_delimiters
# object() only supports "is" and is useful as a marker
# use this marker to specify that the given regex in SQL_REGEX
@@ -13,12 +16,64 @@ from sqlparse import tokens
PROCESS_AS_KEYWORD = object()
+# Dollar-quoted literals (`$tag$...$tag$`) and multiline comments
+# (`/*...*/`, `/*+...*/`) used to be matched with per-position regexes
+# using a lazy dot-all quantifier (`[\s\S]*?`) terminated by a
+# backreference or a literal delimiter. Applied at every text position by
+# the lexer loop below, that shape is O(n^2) on adversarial input with
+# many unclosed openers, since each failed attempt re-scans to the end of
+# the remaining text (GHSA-prg7-hcfm-mfcr). They are resolved instead in
+# a single linear pass by find_delimited_spans().
+_DOLLAR_QUOTE_DELIM = re.compile(r'\$(?:[_A-ZÀ-Ü]\w*)?\$', re.IGNORECASE | re.UNICODE)
+_DOLLAR_QUOTE_OPENER_OK = re.compile(r'(?<![\w"$])', re.UNICODE)
+_COMMENT_HINT_OPEN = re.compile(r'/\*\+')
+_COMMENT_OPEN = re.compile(r'/\*(?!\+)')
+_COMMENT_CLOSE = re.compile(r'\*/')
+
+
+def find_delimited_spans(text):
+ """Locate dollar-quoted literals and multiline comments in `text`.
+
+ Returns a dict mapping each span's start offset to
+ (end offset, token type).
+ """
+ has_dollar = '$' in text
+ # A comment can only ever open on "/*"; without it "*/" alone can
+ # never pair with anything, so gating on "/*" alone is sufficient to
+ # skip all three comment-related regex passes below.
+ has_comment_open = '/*' in text
+ if not has_dollar and not has_comment_open:
+ return {}
+
+ occurrences = []
+ if has_dollar:
+ for m in _DOLLAR_QUOTE_DELIM.finditer(text):
+ tag = m.group()
+ can_open = _DOLLAR_QUOTE_OPENER_OK.match(text, m.start()) is not None
+ occurrences.append(
+ _DelimiterOccurrence(m.start(), m.end(), tag, can_open, True, tokens.Literal))
+ if has_comment_open:
+ for m in _COMMENT_HINT_OPEN.finditer(text):
+ occurrences.append(_DelimiterOccurrence(
+ m.start(), m.end(), 'C', True, False,
+ tokens.Comment.Multiline.Hint))
+ for m in _COMMENT_OPEN.finditer(text):
+ occurrences.append(_DelimiterOccurrence(
+ m.start(), m.end(), 'C', True, False,
+ tokens.Comment.Multiline))
+ for m in _COMMENT_CLOSE.finditer(text):
+ occurrences.append(
+ _DelimiterOccurrence(m.start(), m.end(), 'C', False, True, None))
+ occurrences.sort(key=lambda occ: occ.start)
+
+ spans = resolve_paired_delimiters(occurrences)
+ return {start: (end, ttype) for start, end, ttype in spans}
+
+
SQL_REGEX = [
(r'(--|# )\+.*?(\r\n|\r|\n|$)', tokens.Comment.Single.Hint),
- (r'/\*\+[\s\S]*?\*/', tokens.Comment.Multiline.Hint),
(r'(--|# ).*?(\r\n|\r|\n|$)', tokens.Comment.Single),
- (r'/\*[\s\S]*?\*/', tokens.Comment.Multiline),
(r'(\r\n|\r|\n)', tokens.Newline),
(r'\s+?', tokens.Whitespace),
@@ -30,7 +85,6 @@ SQL_REGEX = [
(r"`(``|[^`])*`", tokens.Name),
(r"´(´´|[^´])*´", tokens.Name),
- (r'((?<![\w\"\$])\$(?:[_A-ZÀ-Ü]\w*)?\$)[\s\S]*?\1', tokens.Literal),
(r'\?', tokens.Name.Placeholder),
(r'%(\(\w+\))?s', tokens.Name.Placeholder),
diff --git a/sqlparse/lexer.py b/sqlparse/lexer.py
index 8f88d17..71fd261 100644
--- a/sqlparse/lexer.py
+++ b/sqlparse/lexer.py
@@ -134,8 +134,16 @@ class Lexer:
raise TypeError("Expected text or file-like object, got {!r}".
format(type(text)))
+ delimited_spans = keywords.find_delimited_spans(text)
+
iterable = enumerate(text)
for pos, char in iterable:
+ if pos in delimited_spans:
+ end, ttype = delimited_spans[pos]
+ yield ttype, text[pos:end]
+ consume(iterable, end - pos - 1)
+ continue
+
for rexmatch, action in self._SQL_REGEX:
m = rexmatch(text, pos)
diff --git a/sqlparse/utils.py b/sqlparse/utils.py
index 58c0245..ed83734 100644
--- a/sqlparse/utils.py
+++ b/sqlparse/utils.py
@@ -7,7 +7,7 @@
import itertools
import re
-from collections import deque
+from collections import defaultdict, deque, namedtuple
from contextlib import contextmanager
# This regular expression replaces the home-cooked parser that was here before.
@@ -110,6 +110,50 @@ def consume(iterator, n):
deque(itertools.islice(iterator, n), maxlen=0)
+_DelimiterOccurrence = namedtuple(
+ '_DelimiterOccurrence', 'start end tag can_open can_close payload')
+
+
+def resolve_paired_delimiters(occurrences):
+ """Pair delimiter occurrences (quote/comment open & close markers) in
+ one left-to-right pass, instead of re-scanning the remaining text for
+ every unmatched opener -- which is what a backreference- or literal-
+ terminated lazy-dot-all regex applied at every text position ends up
+ doing, and is O(n^2) on adversarial input (GHSA-prg7-hcfm-mfcr).
+
+ `occurrences` must be sorted by `start`. Each occurrence with
+ `can_open` is paired with the nearest later occurrence sharing its
+ `tag` that has `can_close`; anything in between (including other
+ openers) is left as literal content. Unpaired openers are dropped,
+ exactly as a regex that never finds its closing delimiter fails to
+ match at all.
+
+ Returns a list of (start, end, payload) for each resolved span.
+ """
+ occurrences = list(occurrences)
+ closers = defaultdict(deque)
+ for idx, occ in enumerate(occurrences):
+ if occ.can_close:
+ closers[occ.tag].append(idx)
+
+ spans = []
+ consumed_until = 0
+ for i, occ in enumerate(occurrences):
+ if occ.can_close:
+ queue = closers[occ.tag]
+ if queue and queue[0] == i:
+ queue.popleft()
+ if occ.start < consumed_until or not occ.can_open:
+ continue
+ queue = closers[occ.tag]
+ if queue:
+ close_idx = queue.popleft()
+ close_end = occurrences[close_idx].end
+ spans.append((occ.start, close_end, occ.payload))
+ consumed_until = close_end
+ return spans
+
+
@contextmanager
def offset(filter_, n=0):
filter_.offset += n
@@ -8,6 +8,7 @@ SRC_URI[sha256sum] = "e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa9
SRC_URI += "file://CVE-2026-54284-1.patch \
file://CVE-2026-54284-2.patch \
file://CVE-2026-59893.patch \
"
CVE_PRODUCT = "sqlparse"