Skip to content

Commit e6b927a

Browse files
committed
BUG#37013057: mysql-connector-python Parameterized query SQL injection
Malicious strings can be injected when utilizing dictionary-based query parameterization via the `cursor.execute()` API command and the C-based implementation of the connector. This patch fixes the injection issue. Change-Id: I077b5846d3e7a05b3425207026096370911daf2e
1 parent 8ca2932 commit e6b927a

File tree

4 files changed

+157
-10
lines changed

4 files changed

+157
-10
lines changed

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ v9.1.0
1818
- WL#16341: OpenID Connect (Oauth2 - JWT) Authentication Support
1919
- WL#16307: Remove Python 3.8 support
2020
- WL#16306: Add support for Python 3.13
21+
- BUG#37013057: mysql-connector-python Parameterized query SQL injection
2122

2223
v9.0.0
2324
======

mysql-connector-python/lib/mysql/connector/connection_cext.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -855,15 +855,15 @@ def more_results(self) -> bool:
855855

856856
def prepare_for_mysql(
857857
self, params: ParamsSequenceOrDictType
858-
) -> Union[Sequence[bytes], Dict[str, bytes]]:
858+
) -> Union[Sequence[bytes], Dict[bytes, bytes]]:
859859
"""Prepare parameters for statements
860860
861861
This method is use by cursors to prepared parameters found in the
862862
list (or tuple) params.
863863
864864
Returns dict.
865865
"""
866-
result: Union[List[Any], Dict[str, Any]] = []
866+
result: Union[List[bytes], Dict[bytes, bytes]] = []
867867
if isinstance(params, (list, tuple)):
868868
if self.converter:
869869
result = [
@@ -880,14 +880,14 @@ def prepare_for_mysql(
880880
result = {}
881881
if self.converter:
882882
for key, value in params.items():
883-
result[key] = self.converter.quote(
883+
result[key.encode()] = self.converter.quote(
884884
self.converter.escape(
885885
self.converter.to_mysql(value), self._sql_mode
886886
)
887887
)
888888
else:
889889
for key, value in params.items():
890-
result[key] = self._cmysql.convert_to_mysql(value)[0]
890+
result[key.encode()] = self._cmysql.convert_to_mysql(value)[0]
891891
else:
892892
raise ProgrammingError(
893893
f"Could not process parameters: {type(params).__name__}({params}),"

mysql-connector-python/lib/mysql/connector/cursor_cext.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
RE_SQL_ON_DUPLICATE,
7979
RE_SQL_PYTHON_CAPTURE_PARAM_NAME,
8080
RE_SQL_PYTHON_REPLACE_PARAM,
81+
_bytestr_format_dict,
8182
is_eol_comment,
8283
parse_multi_statement_query,
8384
)
@@ -343,8 +344,7 @@ def execute(
343344
if params:
344345
prepared = self._connection.prepare_for_mysql(params)
345346
if isinstance(prepared, dict):
346-
for key, value in prepared.items():
347-
stmt = stmt.replace(f"%({key})s".encode(), value)
347+
stmt = _bytestr_format_dict(stmt, prepared)
348348
elif isinstance(prepared, (list, tuple)):
349349
psub = _ParamSubstitutor(prepared)
350350
stmt = RE_PY_PARAM.sub(psub, stmt)
@@ -411,10 +411,7 @@ def remove_comments(match: re.Match) -> str:
411411
tmp = fmt
412412
prepared = self._connection.prepare_for_mysql(params)
413413
if isinstance(prepared, dict):
414-
for key, value in prepared.items():
415-
tmp = tmp.replace(
416-
f"%({key})s".encode(), value # type: ignore[arg-type]
417-
)
414+
tmp = _bytestr_format_dict(cast(bytes, tmp), prepared)
418415
elif isinstance(prepared, (list, tuple)):
419416
psub = _ParamSubstitutor(prepared)
420417
tmp = RE_PY_PARAM.sub(psub, tmp) # type: ignore[call-overload]

mysql-connector-python/tests/test_bugs.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8636,3 +8636,152 @@ async def test_buffered_raw_cursor(self) -> None:
86368636
],
86378637
await cur.fetchall(),
86388638
)
8639+
8640+
8641+
class BugOra37013057(tests.MySQLConnectorTests):
8642+
"""BUG#37013057: mysql-connector-python Parameterized query SQL injection
8643+
8644+
Malicious strings can be injected when utilizing
8645+
dictionary-based query parameterization via the
8646+
`cursor.execute()` API command and the C-based
8647+
implementation of the connector.
8648+
8649+
This patch fixes the injection issue.
8650+
"""
8651+
8652+
table_name = "BugOra37013057"
8653+
8654+
cur_flavors = [
8655+
{},
8656+
{"prepared": True},
8657+
{"raw": True},
8658+
{"buffered": True},
8659+
{"dictionary": True},
8660+
]
8661+
8662+
sql_dict = f"INSERT INTO {table_name}(username, password, city) VALUES (%(username)s, %(password)s, %(city)s)"
8663+
sql_tuple = (
8664+
f"INSERT INTO {table_name}(username, password, city) VALUES (%s, %s, %s)"
8665+
)
8666+
8667+
cases = [
8668+
{
8669+
"values_dict": {
8670+
"username": "%(password)s",
8671+
"password": ", sleep(10));--",
8672+
"city": "Montevideo",
8673+
},
8674+
"values_tuple": ("%(password)s", ", sleep(10));--", "Montevideo"),
8675+
},
8676+
{
8677+
"values_dict": {
8678+
"username": "%(password)s",
8679+
"password": ", curdate());",
8680+
"city": "Rio",
8681+
},
8682+
"values_tuple": ("%(password)s", ", curdate());", "Rio"),
8683+
},
8684+
{
8685+
"values_dict": {
8686+
"username": "%(password)s",
8687+
"password": "%(city)s",
8688+
"city": ", database());",
8689+
},
8690+
"values_tuple": ("%(password)s", "%(city)s", ", database());"),
8691+
},
8692+
]
8693+
8694+
def setUp(self):
8695+
with mysql.connector.connect(**tests.get_mysql_config()) as cnx:
8696+
with cnx.cursor() as cur:
8697+
cur.execute(
8698+
f"CREATE TABLE {self.table_name}"
8699+
"(username varchar(50), password varchar(50), city varchar(50))"
8700+
)
8701+
8702+
def tearDown(self):
8703+
with mysql.connector.connect(**tests.get_mysql_config()) as cnx:
8704+
with cnx.cursor() as cur:
8705+
cur.execute(f"DROP TABLE IF EXISTS {self.table_name}")
8706+
8707+
def _prepare_cur_dict_res(self, case):
8708+
if isinstance(case["values"], dict):
8709+
return case["values_dict"]
8710+
8711+
def _run_execute(self, dict_based=True, cur_config={}):
8712+
sql = self.sql_dict if dict_based else self.sql_tuple
8713+
for case in self.cases:
8714+
if dict_based:
8715+
values = case["values_dict"]
8716+
else:
8717+
values = case["values_tuple"]
8718+
8719+
if "dictionary" in cur_config:
8720+
exp_res = case["values_dict"].copy()
8721+
elif "raw" in cur_config:
8722+
exp_res = tuple([x.encode() for x in case["values_tuple"]])
8723+
else:
8724+
exp_res = case["values_tuple"]
8725+
8726+
with self.cnx.cursor(**cur_config) as cur:
8727+
cur.execute(sql, values)
8728+
cur.execute(f"select * from {self.table_name}")
8729+
res = cur.fetchone()
8730+
cur.execute(f"TRUNCATE TABLE {self.table_name}")
8731+
self.assertEqual(res, exp_res)
8732+
8733+
@foreach_cnx()
8734+
def test_execute_dict_based_injection(self):
8735+
for cur_config in self.cur_flavors:
8736+
self._run_execute(dict_based=True, cur_config=cur_config)
8737+
8738+
@foreach_cnx()
8739+
def test_execute_tuple_based_injection(self):
8740+
for cur_config in self.cur_flavors:
8741+
self._run_execute(dict_based=False, cur_config=cur_config)
8742+
8743+
8744+
class BugOra37013057_async(tests.MySQLConnectorAioTestCase):
8745+
"""BUG#37013057: mysql-connector-python Parameterized query SQL injection
8746+
8747+
For a description see `test_bugs.BugOra37013057`.
8748+
"""
8749+
8750+
def setUp(self) -> None:
8751+
self.bug_37013057 = BugOra37013057()
8752+
self.bug_37013057.setUp()
8753+
8754+
def tearDown(self) -> None:
8755+
self.bug_37013057.tearDown()
8756+
8757+
async def _run_execute(self, dict_based=True, cur_config={}):
8758+
sql = self.bug_37013057.sql_dict if dict_based else self.bug_37013057.sql_tuple
8759+
for case in self.bug_37013057.cases:
8760+
if dict_based:
8761+
values = case["values_dict"]
8762+
else:
8763+
values = case["values_tuple"]
8764+
8765+
if "dictionary" in cur_config:
8766+
exp_res = case["values_dict"].copy()
8767+
elif "raw" in cur_config:
8768+
exp_res = tuple([x.encode() for x in case["values_tuple"]])
8769+
else:
8770+
exp_res = case["values_tuple"]
8771+
8772+
async with await self.cnx.cursor(**cur_config) as cur:
8773+
await cur.execute(sql, values)
8774+
await cur.execute(f"select * from {self.bug_37013057.table_name}")
8775+
res = await cur.fetchone()
8776+
await cur.execute(f"TRUNCATE TABLE {self.bug_37013057.table_name}")
8777+
self.assertEqual(res, exp_res)
8778+
8779+
@foreach_cnx_aio()
8780+
async def test_execute_dict_based_injection(self):
8781+
for cur_config in self.bug_37013057.cur_flavors:
8782+
await self._run_execute(dict_based=True, cur_config=cur_config)
8783+
8784+
@foreach_cnx_aio()
8785+
async def test_execute_tuple_based_injection(self):
8786+
for cur_config in self.bug_37013057.cur_flavors:
8787+
await self._run_execute(dict_based=False, cur_config=cur_config)

0 commit comments

Comments
 (0)