Skip to content

Commit e10c30d

Browse files
committed
WL14098: Add option to specify LOAD DATA LOCAL IN PATH
The purpose of this Worklog is to improve security of the LOAD DATA LOCAL INFILE option that is used to specify a file from where to upload data, by allowing a user to specify a single folder that is safe to upload files from.
1 parent 8f5ae0f commit e10c30d

File tree

9 files changed

+436
-24
lines changed

9 files changed

+436
-24
lines changed

lib/mysql/connector/abstracts.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2014, 2020, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2014, 2020, 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
@@ -29,6 +29,7 @@
2929
"""Module gathering all abstract base classes"""
3030

3131
from abc import ABCMeta, abstractmethod, abstractproperty
32+
import os
3233
import re
3334
import time
3435
import weakref
@@ -99,6 +100,9 @@ def __init__(self, **kwargs):
99100
self._have_next_result = False
100101
self._raw = False
101102
self._in_transaction = False
103+
self._allow_local_infile = DEFAULT_CONFIGURATION["allow_local_infile"]
104+
self._allow_local_infile_in_path = (
105+
DEFAULT_CONFIGURATION["allow_local_infile_in_path"])
102106

103107
self._prepared_statements = None
104108

@@ -399,9 +403,20 @@ def config(self, **kwargs):
399403
except KeyError:
400404
pass # Missing compress argument is OK
401405

402-
allow_local_infile = config.get(
406+
self._allow_local_infile = config.get(
403407
'allow_local_infile', DEFAULT_CONFIGURATION['allow_local_infile'])
404-
if allow_local_infile:
408+
self._allow_local_infile_in_path = config.get(
409+
'allow_local_infile_in_path',
410+
DEFAULT_CONFIGURATION['allow_local_infile_in_path'])
411+
infile_in_path = None
412+
if self._allow_local_infile_in_path:
413+
infile_in_path = os.path.abspath(self._allow_local_infile_in_path)
414+
if infile_in_path and os.path.exists(infile_in_path) and \
415+
not os.path.isdir(infile_in_path) or \
416+
os.path.islink(infile_in_path):
417+
raise AttributeError("allow_local_infile_in_path must be a "
418+
"directory")
419+
if self._allow_local_infile or self._allow_local_infile_in_path:
405420
self.set_client_flags([ClientFlag.LOCAL_FILES])
406421
else:
407422
self.set_client_flags([-ClientFlag.LOCAL_FILES])

lib/mysql/connector/connection.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2009, 2020, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2009, 2020, 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
@@ -438,8 +438,39 @@ def _handle_eof(self, packet):
438438

439439
def _handle_load_data_infile(self, filename):
440440
"""Handle a LOAD DATA INFILE LOCAL request"""
441+
file_name = os.path.abspath(filename)
442+
if os.path.islink(file_name):
443+
raise errors.OperationalError("Use of symbolic link is not allowed")
444+
if not self._allow_local_infile and \
445+
not self._allow_local_infile_in_path:
446+
raise errors.DatabaseError(
447+
"LOAD DATA LOCAL INFILE file request rejected due to "
448+
"restrictions on access.")
449+
if not self._allow_local_infile and self._allow_local_infile_in_path:
450+
# validate filename is inside of allow_local_infile_in_path path.
451+
infile_path = os.path.abspath(self._allow_local_infile_in_path)
452+
c_path = None
453+
try:
454+
if PY2:
455+
c_path = os.path.commonprefix([infile_path, file_name])
456+
if not os.path.exists(c_path):
457+
raise ValueError("Can't locate path")
458+
else:
459+
c_path = os.path.commonpath([infile_path, file_name])
460+
except ValueError as err:
461+
err_msg = ("{} while loading file `{}` and path `{}` given"
462+
" in allow_local_infile_in_path")
463+
raise errors.InterfaceError(
464+
err_msg.format(str(err), file_name, infile_path))
465+
466+
if c_path != infile_path:
467+
err_msg = ("The file `{}` is not found in the given "
468+
"allow_local_infile_in_path {}")
469+
raise errors.DatabaseError(
470+
err_msg.format(file_name,infile_path))
471+
441472
try:
442-
data_file = open(filename, 'rb')
473+
data_file = open(file_name, 'rb')
443474
return self._handle_ok(self._send_data(data_file,
444475
send_empty_packet=True))
445476
except IOError:
@@ -450,7 +481,7 @@ def _handle_load_data_infile(self, filename):
450481
raise errors.OperationalError(
451482
"MySQL Connection not available.")
452483
raise errors.InterfaceError(
453-
"File '{0}' could not be read".format(filename))
484+
"File '{0}' could not be read".format(file_name))
454485
finally:
455486
try:
456487
data_file.close()
@@ -596,8 +627,15 @@ def cmd_query(self, query, raw=False, buffered=False, raw_as_string=False):
596627
"""
597628
if not isinstance(query, bytes):
598629
query = query.encode('utf-8')
599-
result = self._handle_result(self._send_cmd(ServerCmd.QUERY, query))
600-
630+
try:
631+
result = self._handle_result(self._send_cmd(ServerCmd.QUERY, query))
632+
except errors.ProgrammingError as err:
633+
if err.errno == 3948 and \
634+
"Loading local data is disabled" in err.msg:
635+
err_msg = ("LOAD DATA LOCAL INFILE file request rejected due "
636+
"to restrictions on access.")
637+
raise errors.DatabaseError(err_msg)
638+
raise
601639
if self._have_next_result:
602640
raise errors.InterfaceError(
603641
'Use cmd_query_iter for statements with multiple queries.')
@@ -797,6 +835,13 @@ def is_connected(self):
797835
return False # This method does not raise
798836
return True
799837

838+
def set_allow_local_infile_in_path(self, path):
839+
"""set local_infile_in_path
840+
841+
Set allow_local_infile_in_path.
842+
"""
843+
self._allow_local_infile_in_path = path
844+
800845
def reset_session(self, user_variables=None, session_variables=None):
801846
"""Clears the current active session
802847

lib/mysql/connector/connection_cext.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ def _server_status(self):
117117
"""Returns the server status attribute of MYSQL structure"""
118118
return self._cmysql.st_server_status()
119119

120+
def set_allow_local_infile_in_path(self, path):
121+
"""set local_infile_in_path
122+
123+
Set allow_local_infile_in_path.
124+
"""
125+
126+
if self._cmysql:
127+
self._cmysql.set_load_data_local_infile_option(path)
128+
120129
def set_unicode(self, value=True):
121130
"""Toggle unicode mode
122131
@@ -182,7 +191,9 @@ def _open_connection(self):
182191
'unix_socket': self._unix_socket,
183192
'compress': self.isset_client_flag(ClientFlag.COMPRESS),
184193
'ssl_disabled': True,
185-
"conn_attrs": self._conn_attrs
194+
"conn_attrs": self._conn_attrs,
195+
"local_infile": self._allow_local_infile,
196+
"load_data_local_dir": self._allow_local_infile_in_path
186197
}
187198

188199
tls_versions = self._ssl.get('tls_versions')

lib/mysql/connector/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
'force_ipv6': False,
7676
'auth_plugin': None,
7777
'allow_local_infile': False,
78+
'allow_local_infile_in_path': None,
7879
'consume_results': False,
7980
'conn_attrs': None,
8081
'dns_srv': False,

src/include/mysql_capi.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2014, 2020, Oracle and/or its affiliates. All rights reserved.
2+
* Copyright (c) 2014, 2020, Oracle and/or its affiliates.
33
*
44
* This program is free software; you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License, version 2.0, as
@@ -220,6 +220,9 @@ MySQL_select_db(MySQL *self, PyObject *db);
220220
PyObject*
221221
MySQL_set_character_set(MySQL *self, PyObject *args);
222222

223+
PyObject*
224+
MySQL_set_load_data_local_infile_option(MySQL *self, PyObject *args);
225+
223226
PyObject*
224227
MySQL_shutdown(MySQL *self, PyObject *args);
225228

src/mysql_capi.c

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2014, 2020, Oracle and/or its affiliates. All rights reserved.
2+
* Copyright (c) 2014, 2020, Oracle and/or its affiliates.
33
*
44
* This program is free software; you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License, version 2.0, as
@@ -1004,8 +1004,8 @@ MySQL_set_character_set(MySQL *self, PyObject *args)
10041004

10051005
if (res)
10061006
{
1007-
raise_with_session(&self->session, NULL);
1008-
return NULL;
1007+
raise_with_session(&self->session, NULL);
1008+
return NULL;
10091009
}
10101010

10111011
Py_DECREF(self->charset_name);
@@ -1015,6 +1015,49 @@ MySQL_set_character_set(MySQL *self, PyObject *args)
10151015
Py_RETURN_NONE;
10161016
}
10171017

1018+
/**
1019+
Set the local_infile_in_path for the current session.
1020+
1021+
Set the local_infile_in_path for the current session. The
1022+
directory from where a load data is allowed when allow_local_infile is
1023+
dissabled.
1024+
1025+
Raises TypeError when the argument is not a PyString_type.
1026+
1027+
@param self MySQL instance
1028+
@param args allow_local_infile_in_path
1029+
1030+
@return int
1031+
@retval 0 Zero for success.
1032+
*/
1033+
PyObject*
1034+
MySQL_set_load_data_local_infile_option(MySQL *self, PyObject *args)
1035+
{
1036+
PyObject* value;
1037+
int res;
1038+
1039+
if (!PyArg_ParseTuple(args, "O!", &PyStringType, &value))
1040+
{
1041+
return NULL;
1042+
}
1043+
1044+
IS_CONNECTED(self);
1045+
1046+
Py_BEGIN_ALLOW_THREADS
1047+
1048+
res= mysql_options(&self->session, MYSQL_OPT_LOAD_DATA_LOCAL_DIR , PyStringAsString(value));
1049+
1050+
Py_END_ALLOW_THREADS
1051+
1052+
if (res)
1053+
{
1054+
raise_with_session(&self->session, NULL);
1055+
return NULL;
1056+
}
1057+
1058+
Py_RETURN_NONE;
1059+
}
1060+
10181061
/**
10191062
Commit the current transaction.
10201063
@@ -1075,6 +1118,7 @@ PyObject*
10751118
MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
10761119
{
10771120
char *host= NULL, *user= NULL, *database= NULL, *unix_socket= NULL;
1121+
char *load_data_local_dir= NULL;
10781122
char *ssl_ca= NULL, *ssl_cert= NULL, *ssl_key= NULL,
10791123
*ssl_cipher_suites= NULL, *tls_versions= NULL,
10801124
*tls_cipher_suites= NULL;
@@ -1084,6 +1128,7 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
10841128
const char* auth_plugin;
10851129
unsigned long client_flags= 0;
10861130
unsigned int port= 3306, tmp_uint;
1131+
int local_infile= -1;
10871132
unsigned int protocol= 0;
10881133
Py_ssize_t pos= 0;
10891134
#if MYSQL_VERSION_ID >= 50711
@@ -1105,16 +1150,17 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
11051150

11061151
static char *kwlist[]=
11071152
{
1108-
"host", "user", "password", "database", "port", "unix_socket",
1153+
"host", "user", "password", "database", "port", "unix_socket",
11091154
"client_flags", "ssl_ca", "ssl_cert", "ssl_key", "ssl_cipher_suites",
1110-
"tls_versions", "tls_cipher_suites", "ssl_verify_cert",
1155+
"tls_versions", "tls_cipher_suites", "ssl_verify_cert",
11111156
"ssl_verify_identity", "ssl_disabled", "compress", "conn_attrs",
1157+
"local_infile", "load_data_local_dir",
11121158
NULL
11131159
};
11141160
#ifdef PY3
1115-
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|zzzzkzkzzzzzzO!O!O!O!O!", kwlist,
1161+
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|zzzzkzkzzzzzzO!O!O!O!O!iz", kwlist,
11161162
#else
1117-
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|zzOzkzkzzzzzzO!O!O!O!O!", kwlist,
1163+
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|zzOzkzkzzzzzzO!O!O!O!O!iz", kwlist,
11181164
#endif
11191165
&host, &user, &password, &database,
11201166
&port, &unix_socket,
@@ -1126,7 +1172,9 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
11261172
&PyBool_Type, &ssl_verify_identity,
11271173
&PyBool_Type, &ssl_disabled,
11281174
&PyBool_Type, &compress,
1129-
&PyDict_Type, &conn_attrs))
1175+
&PyDict_Type, &conn_attrs,
1176+
&local_infile,
1177+
&load_data_local_dir))
11301178
{
11311179
return NULL;
11321180
}
@@ -1141,6 +1189,26 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
11411189
mysql_init(&self->session);
11421190
Py_END_ALLOW_THREADS
11431191

1192+
if (local_infile == 1) {
1193+
unsigned int accept= 1;
1194+
mysql_options(&self->session, MYSQL_OPT_LOCAL_INFILE, &accept);
1195+
1196+
} else if (local_infile == 0 && load_data_local_dir != NULL) {
1197+
if (load_data_local_dir != NULL){
1198+
mysql_options(&self->session, MYSQL_OPT_LOAD_DATA_LOCAL_DIR,
1199+
load_data_local_dir);
1200+
}
1201+
1202+
} else {
1203+
unsigned int denied= 0;
1204+
mysql_options(&self->session, MYSQL_OPT_LOCAL_INFILE, &denied);
1205+
1206+
}
1207+
1208+
if (client_flags & CLIENT_LOCAL_FILES && (local_infile != 1)){
1209+
client_flags= client_flags & ~CLIENT_LOCAL_FILES;
1210+
}
1211+
11441212
#ifdef MS_WINDOWS
11451213
if (NULL == host)
11461214
{
@@ -1281,11 +1349,6 @@ MySQL_connect(MySQL *self, PyObject *args, PyObject *kwds)
12811349
client_flags= client_flags & ~CLIENT_CONNECT_WITH_DB;
12821350
}
12831351

1284-
if (client_flags & CLIENT_LOCAL_FILES) {
1285-
unsigned int val= 1;
1286-
mysql_options(&self->session, MYSQL_OPT_LOCAL_INFILE, &val);
1287-
}
1288-
12891352
if (conn_attrs != NULL)
12901353
{
12911354
while (PyDict_Next(conn_attrs, &pos, &key, &value)) {

src/mysql_connector.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ static PyMethodDef MySQL_methods[]=
217217
{"set_character_set", (PyCFunction)MySQL_set_character_set,
218218
METH_VARARGS,
219219
"Set the default character set for the current connection"},
220+
{"set_load_data_local_infile_option",
221+
(PyCFunction)MySQL_set_load_data_local_infile_option, METH_VARARGS,
222+
"Set the load_data_local_infile_option for the current connection"},
220223
{"shutdown", (PyCFunction)MySQL_shutdown,
221224
METH_VARARGS,
222225
"Ask MySQL server to shut down"},
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
10 c1_10 c2_10
2+
20 c1_20 c2_20
3+
30 c1_30 c2_30
4+
40 c1_40 c2_40
5+
50 c1_50 c2_50
6+
60 c1_60 c2_60

0 commit comments

Comments
 (0)