Skip to content

Commit 68a6b51

Browse files
committed
BUG#37418436: Arbitrary File Read in MySQL Python Client library
Local file access is strengthened on the client side (connector) when executing `LOCAL INFILE` statements. The connector is aware of the `filename` specified in a `LOCAL INFILE` statement (client request), and it verifies the `filename` got from the server response matches the requested. Change-Id: I7ff82dc5fdb1e62a97508f31f04ec7887e56b075
1 parent bbc36d5 commit 68a6b51

16 files changed

+1073
-763
lines changed

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ v9.3.0
1212
======
1313

1414
- BUG#37453587: Github links in PyPI project's pages do not work
15+
- BUG#37418436: Arbitrary File Read in MySQL Python Client library
1516

1617
v9.2.0
1718
======

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2009, 2024, Oracle and/or its affiliates.
1+
# Copyright (c) 2009, 2025, Oracle and/or its affiliates.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -43,8 +43,8 @@ def cmd_refresh_verify_options() -> Callable:
4343
"""Decorator verifying which options are relevant and which aren't based on
4444
the server version the client is connecting to."""
4545

46-
def decorator(func: Callable) -> Callable:
47-
@functools.wraps(func)
46+
def decorator(cmd_refresh: Callable) -> Callable:
47+
@functools.wraps(cmd_refresh)
4848
def wrapper(
4949
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
5050
) -> Callable:
@@ -63,7 +63,7 @@ def wrapper(
6363
stacklevel=1,
6464
)
6565

66-
return func(cnx, options, **kwargs)
66+
return cmd_refresh(cnx, options, **kwargs)
6767

6868
return wrapper
6969

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2009, 2024, Oracle and/or its affiliates.
1+
# Copyright (c) 2024, 2025, Oracle and/or its affiliates.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -32,7 +32,7 @@
3232
import unicodedata
3333

3434
from collections import deque
35-
from typing import Generator, Optional
35+
from typing import Deque, Generator, Optional
3636

3737
from .errors import InterfaceError
3838
from .types import MySQLScriptPartition
@@ -380,3 +380,27 @@ def split_multi_statement(
380380
single_stmts=deque(stmts[i : len(stmts)]),
381381
)
382382
)
383+
384+
385+
def get_local_infile_filenames(script: bytes) -> Deque[str]:
386+
"""Scans the MySQL script looking for `filenames` (one for each
387+
`LOCAL INFILE` statement found).
388+
389+
Arguments:
390+
script: a MySQL script that may include one or more `LOCAL INFILE` statements.
391+
392+
Returns:
393+
filenames: a list of filenames (one for each `LOCAL INFILE` statement found).
394+
An empty list is returned if no matches are found.
395+
"""
396+
matches = re.findall(
397+
pattern=rb"""LOCAL\s+INFILE\s+(["'])((?:\\\1|(?:(?!\1)).)*)(\1)""",
398+
string=MySQLScriptSplitter.remove_comments(script),
399+
flags=re.IGNORECASE,
400+
)
401+
if not matches or len(matches[0]) != 3:
402+
return deque([])
403+
404+
# If there is a match, we get ("'", "filename", "'") , that's to say,
405+
# the 1st and 3rd entries are the quote symbols, and the 2nd the actual filename.
406+
return deque([match[1].decode("utf-8") for match in matches])

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2014, 2024, Oracle and/or its affiliates.
1+
# Copyright (c) 2014, 2025, Oracle and/or its affiliates.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -46,6 +46,7 @@
4646
Any,
4747
BinaryIO,
4848
Callable,
49+
Deque,
4950
Dict,
5051
Generator,
5152
Iterator,
@@ -258,6 +259,13 @@ def __init__(self) -> None:
258259
self._init_command: Optional[str] = None
259260
self._character_set: CharacterSet = CharacterSet()
260261

262+
self._local_infile_filenames: Optional[Deque[str]] = None
263+
"""Stores the filenames from `LOCAL INFILE` requests
264+
found in the executed query."""
265+
266+
self._query: Optional[bytes] = None
267+
"""The query being processed."""
268+
261269
def __enter__(self) -> MySQLConnectionAbstract:
262270
return self
263271

mysql-connector-python/lib/mysql/connector/aio/_decorating.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2009, 2024, Oracle and/or its affiliates.
1+
# Copyright (c) 2009, 2025, Oracle and/or its affiliates.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -43,8 +43,8 @@ def cmd_refresh_verify_options() -> Callable:
4343
"""Decorator verifying which options are relevant and which aren't based on
4444
the server version the client is connecting to."""
4545

46-
def decorator(func: Callable) -> Callable:
47-
@functools.wraps(func)
46+
def decorator(cmd_refresh: Callable) -> Callable:
47+
@functools.wraps(cmd_refresh)
4848
async def wrapper(
4949
cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any
5050
) -> Callable:
@@ -63,7 +63,7 @@ async def wrapper(
6363
stacklevel=1,
6464
)
6565

66-
return await func(cnx, options, **kwargs)
66+
return await cmd_refresh(cnx, options, **kwargs)
6767

6868
return wrapper
6969

mysql-connector-python/lib/mysql/connector/aio/abstracts.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2023, 2024, Oracle and/or its affiliates.
1+
# Copyright (c) 2023, 2025, Oracle and/or its affiliates.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -50,6 +50,7 @@
5050
AsyncGenerator,
5151
BinaryIO,
5252
Callable,
53+
Deque,
5354
Dict,
5455
Generator,
5556
Iterator,
@@ -287,6 +288,13 @@ def __init__(
287288

288289
self.converter: Optional[MySQLConverter] = None
289290

291+
self._local_infile_filenames: Optional[Deque[str]] = None
292+
"""Stores the filenames from `LOCAL INFILE` requests
293+
found in the executed query."""
294+
295+
self._query: Optional[bytes] = None
296+
"""The query being processed."""
297+
290298
self._validate_connection_options()
291299

292300
async def __aenter__(self) -> MySQLConnectionAbstract:

mysql-connector-python/lib/mysql/connector/aio/connection.py

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2023, 2024, Oracle and/or its affiliates.
1+
# Copyright (c) 2023, 2025, Oracle and/or its affiliates.
22
#
33
# This program is free software; you can redistribute it and/or modify
44
# it under the terms of the GNU General Public License, version 2.0, as
@@ -59,6 +59,7 @@
5959
)
6060

6161
from .. import version
62+
from .._scripting import get_local_infile_filenames
6263
from ..constants import (
6364
ClientFlag,
6465
FieldType,
@@ -81,6 +82,7 @@
8182
WriteTimeoutError,
8283
get_exception,
8384
)
85+
from ..protocol import EOF_STATUS, ERR_STATUS, LOCAL_INFILE_STATUS, OK_STATUS
8486
from ..types import (
8587
BinaryProtocolType,
8688
DescriptionType,
@@ -219,7 +221,7 @@ async def _do_handshake(self) -> None:
219221
"""Get the handshake from the MySQL server."""
220222
packet = await self._socket.read()
221223
logger.debug("Protocol::Handshake packet: %s", packet)
222-
if packet[4] == 255:
224+
if packet[4] == ERR_STATUS:
223225
raise get_exception(packet)
224226

225227
self._handshake = self._protocol.parse_handshake(packet)
@@ -368,11 +370,11 @@ def _handle_ok(self, packet: bytes) -> OkPacketType:
368370
packet, an error will be raised. If the packet is neither an OK or an Error
369371
packet, InterfaceError will be raised.
370372
"""
371-
if packet[4] == 0:
373+
if packet[4] == OK_STATUS:
372374
ok_pkt = self._protocol.parse_ok(packet)
373375
self._handle_server_status(ok_pkt["status_flag"])
374376
return ok_pkt
375-
if packet[4] == 255:
377+
if packet[4] == ERR_STATUS:
376378
raise get_exception(packet)
377379
raise InterfaceError("Expected OK packet")
378380

@@ -393,11 +395,11 @@ def _handle_eof(self, packet: bytes) -> EofPacketType:
393395
packet, an error will be raised. If the packet is neither and OK or an Error
394396
packet, InterfaceError will be raised.
395397
"""
396-
if packet[4] == 254:
398+
if packet[4] == EOF_STATUS:
397399
eof = self._protocol.parse_eof(packet)
398400
self._handle_server_status(eof["status_flag"])
399401
return eof
400-
if packet[4] == 255:
402+
if packet[4] == ERR_STATUS:
401403
raise get_exception(packet)
402404
raise InterfaceError("Expected EOF packet")
403405

@@ -409,7 +411,49 @@ async def _handle_load_data_infile(
409411
write_timeout: Optional[int] = None,
410412
) -> OkPacketType:
411413
"""Handle a LOAD DATA INFILE LOCAL request."""
414+
if self._local_infile_filenames is None:
415+
self._local_infile_filenames = get_local_infile_filenames(self._query)
416+
if not self._local_infile_filenames:
417+
raise InterfaceError(
418+
"No `LOCAL INFILE` statements found in the client's request. "
419+
"Check your request includes valid `LOCAL INFILE` statements."
420+
)
421+
elif not self._local_infile_filenames:
422+
raise InterfaceError(
423+
"Got more `LOCAL INFILE` responses than number of `LOCAL INFILE` "
424+
"statements specified in the client's request. Please, report this "
425+
"issue to the development team."
426+
)
427+
412428
file_name = os.path.abspath(filename)
429+
file_name_from_request = os.path.abspath(self._local_infile_filenames.popleft())
430+
431+
# Verify the file location specified by `filename` from client's request exists
432+
if not os.path.exists(file_name_from_request):
433+
raise InterfaceError(
434+
f"Location specified by filename {file_name_from_request} "
435+
"from client's request does not exist."
436+
)
437+
438+
# Verify the file location specified by `filename` from server's response exists
439+
if not os.path.exists(file_name):
440+
raise InterfaceError(
441+
f"Location specified by filename {file_name} from server's "
442+
"response does not exist."
443+
)
444+
445+
# Verify the `filename` specified by server's response matches the one from
446+
# the client's request.
447+
try:
448+
if not os.path.samefile(file_name, file_name_from_request):
449+
raise InterfaceError(
450+
f"Filename {file_name} from the server's response is not the same "
451+
f"as filename {file_name_from_request} from the "
452+
"client's request."
453+
)
454+
except OSError as err:
455+
raise InterfaceError from err
456+
413457
if os.path.islink(file_name):
414458
raise OperationalError("Use of symbolic link is not allowed")
415459
if not self._allow_local_infile and not self._allow_local_infile_in_path:
@@ -478,16 +522,16 @@ async def _handle_result(
478522
"""
479523
if not packet or len(packet) < 4:
480524
raise InterfaceError("Empty response")
481-
if packet[4] == 0:
525+
if packet[4] == OK_STATUS:
482526
return self._handle_ok(packet)
483-
if packet[4] == 251:
527+
if packet[4] == LOCAL_INFILE_STATUS:
484528
filename = packet[5:].decode()
485529
return await self._handle_load_data_infile(
486530
filename, read_timeout, write_timeout
487531
)
488-
if packet[4] == 254:
532+
if packet[4] == EOF_STATUS:
489533
return self._handle_eof(packet)
490-
if packet[4] == 255:
534+
if packet[4] == ERR_STATUS:
491535
raise get_exception(packet)
492536

493537
# We have a text result set
@@ -520,9 +564,9 @@ def _handle_binary_ok(self, packet: bytes) -> Dict[str, int]:
520564
521565
Returns a dict()
522566
"""
523-
if packet[4] == 0:
567+
if packet[4] == OK_STATUS:
524568
return self._protocol.parse_binary_prepare_ok(packet)
525-
if packet[4] == 255:
569+
if packet[4] == ERR_STATUS:
526570
raise get_exception(packet)
527571
raise InterfaceError("Expected Binary OK packet")
528572

@@ -545,11 +589,11 @@ async def _handle_binary_result(
545589
"""
546590
if not packet or len(packet) < 4:
547591
raise InterfaceError("Empty response")
548-
if packet[4] == 0:
592+
if packet[4] == OK_STATUS:
549593
return self._handle_ok(packet)
550-
if packet[4] == 254:
594+
if packet[4] == EOF_STATUS:
551595
return self._handle_eof(packet)
552-
if packet[4] == 255:
596+
if packet[4] == ERR_STATUS:
553597
raise get_exception(packet)
554598

555599
# We have a binary result set
@@ -965,6 +1009,11 @@ async def cmd_query(
9651009
if isinstance(query, str):
9661010
query = query.encode()
9671011
query = bytearray(query)
1012+
1013+
# Set/Reset internal state related to query execution
1014+
self._query = query
1015+
self._local_infile_filenames = None
1016+
9681017
# Prepare query attrs
9691018
charset = self._charset.name if self._charset.name != "utf8mb4" else "utf8"
9701019
packet = bytearray()

0 commit comments

Comments
 (0)