From b54342dfaaa01cf5ef838abcbfd54911f0bc6147 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 28 Oct 2026 00:52:19 -0500 Subject: [PATCH] gh-98820: Fix quadratic time in csv.Sniffer for quoted fields (CVE-2026-18503) Backport of python/cpython gh-98820 (3.12 063d4555c9). Sniffer._guess_quote_and_delimiter() matched a quoted field body with a lazy ".*?", which scans to the end of the sample from every start position: quadratic in the sample size. An attacker-controlled sample passed to Sniffer.sniff() could burn significant CPU. The body of a quoted field now ends at the first quote that is not doubled, as it does for a csv reader. 2.7.18.x: Python 2.7's re module has no possessive quantifiers (*+ is 3.11+), so the body is matched atomically with the equivalent lookahead + backreference idiom (?=(?P...*))(?P=body). A differential fuzz of 20000 random samples under Python 3.12 found the 2.7 pattern and upstream's possessive pattern produce identical matches. assertRaisesRegexp instead of assertRaisesRegex in the test. Co-Authored-By: Claude Opus 5.5 --- Lib/csv.py | 16 +++++++++++----- Lib/test/test_csv.py | 7 +++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Lib/csv.py b/Lib/csv.py index 70c53ae7791e34..4ad565f5bff38d 100644 --- a/Lib/csv.py +++ b/Lib/csv.py @@ -214,12 +214,18 @@ def _guess_quote_and_delimiter(self, data, delimiters): this way. """ + # The body of a quoted field ends at the first quote which is + # not doubled, as it does for a reader. A lazy ".*?" scans to + # the end of the sample instead, from every start: quadratically. + # 2.7's re has no possessive "*+", so the body is matched + # atomically with a lookahead and a backreference. + body = r'(?=(?P(?:(?P=quote){2}|(?!(?P=quote)).)*))(?P=body)' matches = [] - for restr in ('(?P[^\w\n"\'])(?P ?)(?P["\']).*?(?P=quote)(?P=delim)', # ,".*?", - '(?:^|\n)(?P["\']).*?(?P=quote)(?P[^\w\n"\'])(?P ?)', # ".*?", - '(?P[^\w\n"\'])(?P ?)(?P["\']).*?(?P=quote)(?:$|\n)', # ,".*?" - '(?:^|\n)(?P["\']).*?(?P=quote)(?:$|\n)'): # ".*?" (no delim, no space) - regexp = re.compile(restr, re.DOTALL | re.MULTILINE) + for restr in (r'(?P[^\w\n"\'])(?P ?)(?P["\'])%s(?P=quote)(?P=delim)', # ,"...", + r'(?:^|\n)(?P["\'])%s(?P=quote)(?P[^\w\n"\'])(?P ?)', # "...", + r'(?P[^\w\n"\'])(?P ?)(?P["\'])%s(?P=quote)(?:$|\n)', # ,"..." + r'(?:^|\n)(?P["\'])%s(?P=quote)(?:$|\n)'): # "..." (no delim, no space) + regexp = re.compile(restr % body, re.DOTALL | re.MULTILINE) matches = regexp.findall(data) if matches: break diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 88d5af7c130d5d..e4c18ffe0a3a11 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -1096,6 +1096,13 @@ def test_doublequote(self): dialect = sniffer.sniff(self.sample9) self.assertTrue(dialect.doublequote) + def test_sniff_quoted_single_column(self): + # gh-98820: this sample used to take minutes. + sniffer = csv.Sniffer() + sample = '"abcdefghijklmnopqrstuvwxyz"\n' * 10000 + with self.assertRaisesRegexp(csv.Error, "Could not determine delimiter"): + sniffer.sniff(sample, delimiters=',:|\t') + class NUL: def write(s, *args): pass