Skip to content

Commit 27901d1

Browse files
Merge pull request #745 from pyathena-dev/fix/formatter-escaper-sqli
Fix escaper selection for DELETE and CTAS to prevent SQL injection (CVE-2026-65321)
2 parents 53617fc + ce77c64 commit 27901d1

2 files changed

Lines changed: 138 additions & 5 deletions

File tree

‎pyathena/formatter.py‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4+
import re
45
import textwrap
56
import uuid
67
from abc import ABCMeta, abstractmethod
@@ -154,6 +155,57 @@ def _escape_presto(val: str) -> str:
154155
return f"'{escaped}'"
155156

156157

158+
_LEADING_COMMENT_PATTERN = re.compile(
159+
r"^(?:\s*(?:/\*.*?\*/|--[^\n]*(?:\n|$)))+",
160+
re.DOTALL,
161+
)
162+
163+
# Statements executed by the Hive DDL engine, where the backslash escaping
164+
# convention of _escape_hive() is the correct one. Everything else is assumed
165+
# to be executed by Trino, which requires single quotes to be doubled and
166+
# treats a backslash inside a string literal as an ordinary character.
167+
#
168+
# Defaulting to the Trino escaper is deliberate: it is the fail-safe
169+
# direction. Doubling a quote in a Hive statement is at worst a formatting
170+
# bug, because the Hive lexer concatenates adjacent string literals, while
171+
# backslash-escaping a quote in a Trino statement lets the parameter value
172+
# terminate the string literal and inject SQL.
173+
#
174+
# CREATE TABLE is only treated as Hive DDL when it is not a CTAS statement.
175+
# CREATE TABLE ... AS SELECT and CREATE VIEW are executed by Trino.
176+
_HIVE_STATEMENT_PATTERN = re.compile(
177+
r"""^(?:
178+
ALTER\s+(?:DATABASE|SCHEMA|TABLE)\b
179+
| CREATE\s+(?:DATABASE|SCHEMA)\b
180+
| CREATE\s+(?:EXTERNAL\s+)?TABLE\b(?!.*\bAS\b)
181+
| DROP\s+(?:DATABASE|SCHEMA|TABLE)\b
182+
| MSCK\s+REPAIR\b
183+
| SHOW\b
184+
| DESC(?:RIBE)?\b
185+
)""",
186+
re.IGNORECASE | re.VERBOSE | re.DOTALL,
187+
)
188+
189+
190+
def _strip_leading_comments(operation: str) -> str:
191+
"""Remove leading SQL comments and whitespace.
192+
193+
Statement type detection must not be defeated by a leading comment such
194+
as ``/* generated by ... */ DELETE FROM ...``.
195+
"""
196+
match = _LEADING_COMMENT_PATTERN.match(operation)
197+
if match:
198+
operation = operation[match.end() :]
199+
return operation.lstrip()
200+
201+
202+
def _get_escaper(operation: str) -> Callable[[str], str]:
203+
"""Select the escaper matching the engine that will parse the statement."""
204+
if _HIVE_STATEMENT_PATTERN.match(_strip_leading_comments(operation)):
205+
return _escape_hive
206+
return _escape_presto
207+
208+
157209
def _escape_hive(val: str) -> str:
158210
escaped = (
159211
val.replace("\\", "\\\\")
@@ -268,11 +320,7 @@ def format(self, operation: str, parameters: dict[str, Any] | None = None) -> st
268320
raise ProgrammingError("Query is none or empty.")
269321
operation = operation.strip()
270322

271-
operation_upper = operation.upper()
272-
if operation_upper.startswith(("SELECT", "WITH", "INSERT", "UPDATE", "MERGE")):
273-
escaper = _escape_presto
274-
else:
275-
escaper = _escape_hive
323+
escaper = _get_escaper(operation)
276324

277325
kwargs: dict[str, Any] | None = None
278326
if parameters is not None:

‎tests/pyathena/test_formatter.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,61 @@
66

77
from pyathena.error import ProgrammingError
88

9+
# A value whose single quote must not be allowed to terminate the string
10+
# literal. Trino and Athena do not treat a backslash as an escape character
11+
# inside a string literal, so a statement parsed by Trino must double the quote
12+
# ('') rather than backslash-escape it (\').
13+
HOSTILE = "a' OR 1=1 --"
14+
15+
# Statements parsed by the Trino/Athena engine, where single quotes must be
16+
# doubled. DELETE and CTAS (CREATE TABLE ... AS SELECT) are the paths that
17+
# CVE-2026-65321 left routed through the Hive escaper.
18+
TRINO_STATEMENTS = [
19+
"SELECT * FROM t WHERE c = %(v)s",
20+
"WITH x AS (SELECT 1) SELECT * FROM t WHERE c = %(v)s",
21+
"INSERT INTO t VALUES (%(v)s)",
22+
"UPDATE t SET c = %(v)s",
23+
"MERGE INTO t USING s ON t.c = %(v)s",
24+
"DELETE FROM t WHERE c = %(v)s",
25+
"delete from t where c = %(v)s",
26+
"CREATE TABLE t WITH (format = 'PARQUET') AS SELECT * FROM s WHERE c = %(v)s",
27+
"CREATE VIEW v AS SELECT * FROM s WHERE c = %(v)s",
28+
"EXPLAIN DELETE FROM t WHERE c = %(v)s",
29+
"UNLOAD (SELECT * FROM t WHERE c = %(v)s) TO 's3://b/p/' WITH (format = 'PARQUET')",
30+
"/* generated by etl */ DELETE FROM t WHERE c = %(v)s",
31+
"-- nightly compaction\nDELETE FROM t WHERE c = %(v)s",
32+
"/* a */\n-- b\nDELETE FROM t WHERE c = %(v)s",
33+
"/* multi\nline */\nDELETE FROM t WHERE c = %(v)s",
34+
"\n\t DELETE FROM t WHERE c = %(v)s",
35+
# Near-miss statements: their prefixes are adjacent to the Hive allowlist
36+
# (ALTER/DROP/CREATE ... VIEW, VALUES) but Trino executes them, so they must
37+
# NOT be misrouted to the Hive escaper.
38+
"ALTER VIEW v AS SELECT name FROM s WHERE c = %(v)s",
39+
"DROP VIEW v -- %(v)s",
40+
"CREATE OR REPLACE VIEW v AS SELECT %(v)s",
41+
"VALUES (%(v)s)",
42+
]
43+
44+
# Statements parsed by the Hive DDL engine, where backslash escaping is correct.
45+
HIVE_STATEMENTS = [
46+
"CREATE EXTERNAL TABLE t (c string) LOCATION %(v)s",
47+
"CREATE TABLE t (c string) LOCATION %(v)s",
48+
"CREATE DATABASE d LOCATION %(v)s",
49+
"ALTER TABLE t SET LOCATION %(v)s",
50+
"DROP TABLE t PURGE -- %(v)s",
51+
"MSCK REPAIR TABLE t -- %(v)s",
52+
"SHOW PARTITIONS t -- %(v)s",
53+
"DESCRIBE t -- %(v)s",
54+
"CREATE SCHEMA s LOCATION %(v)s",
55+
"DROP DATABASE d -- %(v)s",
56+
"ALTER DATABASE d SET DBPROPERTIES ('k' = %(v)s)",
57+
# Leading comments must be stripped before detection on the Hive side too,
58+
# otherwise a commented Hive DDL statement would silently lose its escaping.
59+
"-- cleanup\nDROP TABLE t LOCATION %(v)s",
60+
"/* migrate */ ALTER TABLE t SET LOCATION %(v)s",
61+
"-- a\n-- b\nCREATE EXTERNAL TABLE t (c string) LOCATION %(v)s",
62+
]
63+
964

1065
class TestDefaultParameterFormatter:
1166
def test_add_partition(self, formatter):
@@ -468,3 +523,33 @@ def test_format_bad_parameter(self, formatter):
468523
["a string"],
469524
),
470525
)
526+
527+
@pytest.mark.parametrize("operation", TRINO_STATEMENTS)
528+
def test_trino_statements_use_quote_doubling(self, formatter, operation):
529+
result = formatter.format(operation, {"v": HOSTILE})
530+
assert "\\'" not in result, f"backslash escaping leaked into: {result}"
531+
assert "''" in result
532+
533+
@pytest.mark.parametrize("operation", HIVE_STATEMENTS)
534+
def test_hive_ddl_keeps_backslash_escaping(self, formatter, operation):
535+
assert "\\'" in formatter.format(operation, {"v": HOSTILE})
536+
537+
def test_quote_cannot_terminate_literal_in_delete(self, formatter):
538+
result = formatter.format("DELETE FROM t WHERE c = %(v)s", {"v": HOSTILE})
539+
# Every quote in the rendered value is doubled, so the literal opened by
540+
# the formatter is still open until its own closing quote.
541+
assert result.count("'") % 2 == 0
542+
assert result == "DELETE FROM t WHERE c = 'a'' OR 1=1 --'"
543+
544+
def test_ctas_detection_is_case_and_whitespace_insensitive(self, formatter):
545+
for operation in (
546+
"create table t as select %(v)s",
547+
"CREATE\nTABLE\nt\nAS\nSELECT %(v)s",
548+
):
549+
assert "\\'" not in formatter.format(operation, {"v": HOSTILE})
550+
551+
def test_table_name_starting_with_as_is_still_hive_ddl(self, formatter):
552+
# `as_of` must not be mistaken for the CTAS `AS` keyword.
553+
assert "\\'" in formatter.format(
554+
"CREATE EXTERNAL TABLE as_of (c string) LOCATION %(v)s", {"v": HOSTILE}
555+
)

0 commit comments

Comments
 (0)