mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-10-15 06:17:16 +00:00
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:
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"
|
||||
|
||||
Reference in new issue
Block a user