Skip to content

Commit 1fef461

Browse files
committed
WL13994: Support clear text passwords
The purpose of this worklog is to make sure that the use "mysql_clear_password" authentication plugin is only used when the connection is secure (as with SSL), to avoid passing the password in clear text over the wired.
1 parent e10c30d commit 1fef461

File tree

6 files changed

+276
-12
lines changed

6 files changed

+276
-12
lines changed

lib/mysql/connector/abstracts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,10 @@ def config(self, **kwargs):
508508
if "ssl_disabled" in config:
509509
self._ssl_disabled = config.pop("ssl_disabled")
510510

511+
if self._ssl_disabled and self._auth_plugin == "mysql_clear_password":
512+
raise errors.InterfaceError("Clear password authentication is not "
513+
"supported over insecure channels")
514+
511515
# Other configuration
512516
set_ssl_flag = False
513517
for key, value in config.items():

lib/mysql/connector/connection.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,14 @@ def _do_handshake(self):
159159
handshake['server_version_original'])
160160

161161
if not handshake['capabilities'] & ClientFlag.SSL:
162-
self._client_flags &= ~ClientFlag.SSL
162+
if self._auth_plugin == "mysql_clear_password":
163+
err_msg = ("Clear password authentication is not supported "
164+
"over insecure channels")
165+
raise errors.InterfaceError(err_msg)
163166
if self._ssl.get('verify_cert'):
164167
raise errors.InterfaceError("SSL is required but the server "
165168
"doesn't support it", errno=2026)
169+
self._client_flags &= ~ClientFlag.SSL
166170
elif not self._ssl_disabled:
167171
self._client_flags |= ClientFlag.SSL
168172

tests/__init__.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import errno
4444
import traceback
4545
from cpydist.utils import mysql_c_api_info
46+
from time import sleep
4647
from distutils.dist import Distribution
4748
from imp import load_source
4849
from functools import wraps
@@ -113,6 +114,7 @@
113114
MYSQL_SERVERS_NEEDED = 1
114115
MYSQL_SERVERS = []
115116
MYSQL_VERSION = ()
117+
MYSQL_LICENSE = ""
116118
MYSQL_VERSION_TXT = ''
117119
MYSQL_DUMMY = None
118120
MYSQL_DUMMY_THREAD = None
@@ -948,6 +950,75 @@ def check_c_extension(exc=None):
948950
LOGGER.error("C Extension not available: %s", error_msg)
949951
sys.exit(1)
950952

953+
954+
def is_host_reachable(host):
955+
"""Attempts to reach a host, by using the ping command.
956+
Returns True if success else False.
957+
"""
958+
param_attemps = "-n" if os.name == "nt" else "-c"
959+
attemps = "1"
960+
command = ["ping", param_attemps, attemps, host]
961+
try:
962+
return subprocess.call(command) == 0
963+
except OSError:
964+
return False
965+
966+
967+
def is_plugin_available(plugin_name, config_vars=None, in_server=None):
968+
"""Checks if the plugin name is available
969+
970+
The given plugin must be able to be load in the server and his status must
971+
be "active" be mark as available, in either result Server will be restarted
972+
with the default configuration.
973+
974+
Returns True if plugin an success else False.
975+
"""
976+
available = False
977+
server = in_server if in_server else MYSQL_SERVERS[0]
978+
plugin_config_vars = config_vars if config_vars else []
979+
server_cnf = server._cnf
980+
config = get_mysql_config(server.name)
981+
982+
ext = "dll" if os.name == "nt" else "so"
983+
plugin_full_name = "{name}.{ext}".format(name=plugin_name, ext=ext)
984+
985+
plugin_config = {
986+
"plugin-load-add": plugin_full_name,
987+
}
988+
plugin_config.update(plugin_config_vars)
989+
cnf = "\n# is_plugin_available vars:"
990+
for key in plugin_config:
991+
cnf = "{}\n{}={}".format(cnf, key, plugin_config[key])
992+
server_cnf += cnf
993+
994+
server.stop()
995+
server.wait_down()
996+
997+
try:
998+
server.start(my_cnf=server_cnf)
999+
server.wait_up()
1000+
sleep(1)
1001+
from mysql.connector import MySQLConnection
1002+
cnx = MySQLConnection(**config)
1003+
cnx.cmd_query("SHOW PLUGINS")
1004+
res = cnx.get_rows()
1005+
for row in res[0]:
1006+
if row[0] == plugin_name:
1007+
if row[1] == "ACTIVE":
1008+
available = True
1009+
cnx.cmd_query("UNINSTALL PLUGIN {}".format(plugin_name))
1010+
cnx.close()
1011+
return available
1012+
except:
1013+
pass
1014+
finally:
1015+
server.stop()
1016+
server.wait_down()
1017+
server.start(my_cnf=server_cnf)
1018+
server.wait_up()
1019+
sleep(1)
1020+
return available
1021+
9511022
def check_tls_versions_support(tls_versions):
9521023
"""Check whether we can connect with given TLS version
9531024

tests/mysqld.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2009, 2018, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2009, 2018, 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
@@ -169,7 +169,9 @@ def __init__(self, basedir, option_file=None, sharedir=None):
169169
self._process = None
170170
self._lc_messages_dir = None
171171
self._init_mysql_install()
172-
self._version = self._get_version()
172+
ver, lic = self._get_version()
173+
self._version = ver
174+
self._license = lic
173175

174176
if option_file and os.access(option_file, 0):
175177
MySQLBootstrapError("Option file not accessible: {name}".format(
@@ -242,7 +244,7 @@ def _init_mysql_install(self):
242244
afile == 'innodb_memcached_config.sql':
243245
self._scriptdir = root
244246

245-
version = self._get_version()
247+
version = self._get_version()[0]
246248
if not self._lc_messages_dir or (version < (8, 0, 13) and
247249
not self._scriptdir):
248250
raise MySQLBootstrapError(
@@ -296,8 +298,10 @@ def _get_version(self):
296298
"""Get the MySQL server version
297299
298300
This method executes mysqld with the --version argument. It parses
299-
the output looking for the version number and returns it as a
300-
tuple with integer values: (major,minor,patch)
301+
the output looking for the version number and license, returns it as a
302+
tuple with version number as fisrt element as a tuple with integer
303+
values and a string as second element indicating the version in the
304+
form: ((major,minor,patch), "license as shown by mysqld")
301305
302306
Returns a tuple.
303307
"""
@@ -308,9 +312,13 @@ def _get_version(self):
308312

309313
prc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
310314
verstr = str(prc.communicate()[0])
311-
matches = re.match(r'.*Ver (\d)\.(\d).(\d{1,2}).*', verstr)
315+
matches = re.match(r'.*Ver (\d)\.(\d).(\d{1,2})-*(\S*).*', verstr)
312316
if matches:
313-
return tuple([int(v) for v in matches.groups()])
317+
matches_groups = matches.groups()
318+
ver = tuple([int(v) for v in matches_groups[0:-1]])
319+
lic = matches_groups[-1]
320+
LOGGER.debug("MySQL version: %s license: %s", ver, lic)
321+
return (ver, lic)
314322
else:
315323
raise MySQLServerError(
316324
'Failed reading version from mysqld --version')
@@ -323,6 +331,14 @@ def version(self):
323331
"""
324332
return self._version
325333

334+
@property
335+
def license(self):
336+
"""Returns the MySQL server license type
337+
338+
Returns a tuple.
339+
"""
340+
return self._license
341+
326342
def _start_server(self):
327343
"""Start the MySQL server"""
328344
try:
@@ -632,16 +648,18 @@ def update_config(self, **kwargs):
632648
'ssl': 1,
633649
}
634650

651+
cnf = kwargs.pop("my_cnf", self._cnf)
635652
for arg in self._extra_args:
636653
if self._version < arg["version"]:
637654
options.update(dict([(key, '') for key in
638655
arg["options"].keys()]))
639656
else:
640657
options.update(arg["options"])
641658
options.update(**kwargs)
659+
642660
try:
643661
fp = open(self._option_file, 'w')
644-
fp.write(self._cnf.format(**options))
662+
fp.write(cnf.format(**options))
645663
fp.close()
646664
except Exception as ex:
647665
LOGGER.error("Failed to write config file {0}".format(ex))

tests/test_connection.py

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import socket
4141
import subprocess
4242
import sys
43+
from time import sleep
4344

4445
import tests
4546
from . import check_tls_versions_support
@@ -57,7 +58,7 @@
5758
from mysql.connector import (connect, connection, network, errors,
5859
constants, cursor, abstracts, catch23,
5960
HAVE_DNSPYTHON)
60-
from mysql.connector.errors import InterfaceError
61+
from mysql.connector.errors import InterfaceError, ProgrammingError
6162
from mysql.connector.optionfiles import read_option_files
6263
from mysql.connector.network import TLS_V1_3_SUPPORTED
6364
from mysql.connector.utils import linux_distribution
@@ -2531,6 +2532,170 @@ def test_connect_with_can_handle_expired_pw_flag(self):
25312532
_ = self.cnx.__class__(**cnx_config)
25322533

25332534

2535+
@unittest.skipIf(tests.MYSQL_VERSION < (5, 7),
2536+
"Authentication with ldap_simple not supported")
2537+
@unittest.skipIf(not tests.is_plugin_available("authentication_ldap_simple"),
2538+
"Plugin authentication_ldap_simple not available")
2539+
#Skip if remote ldap server is not reachable.
2540+
@unittest.skipIf(not tests.is_host_reachable("100.103.18.98"),
2541+
"ldap server is not reachable")
2542+
class WL13994(tests.MySQLConnectorTests):
2543+
"""WL#13994: Support clear text passwords
2544+
"""
2545+
def setUp(self):
2546+
self.server = tests.MYSQL_SERVERS[0]
2547+
self.server_cnf = self.server._cnf
2548+
self.config = tests.get_mysql_config()
2549+
self.config.pop("unix_socket", None)
2550+
self.user = "[email protected]"
2551+
self.host = "%"
2552+
2553+
cnx = connection.MySQLConnection(**self.config)
2554+
ext = "dll" if os.name == "nt" else "so"
2555+
plugin_name = "authentication_ldap_simple.{}".format(ext)
2556+
2557+
ldap_simple_config = {
2558+
"plugin-load-add": plugin_name,
2559+
"authentication_ldap_simple_auth_method_name": "simple",
2560+
"authentication_ldap_simple_bind_base_dn": '"dc=MYSQL,dc=local"',
2561+
"authentication_ldap_simple_init_pool_size": 1,
2562+
"authentication_ldap_simple_bind_root_dn": "",
2563+
"authentication_ldap_simple_bind_root_pwd": "",
2564+
"authentication_ldap_simple_ca_path": '""',
2565+
"authentication_ldap_simple_log_status": 6,
2566+
"authentication_ldap_simple_server_host": "100.103.18.98",
2567+
"authentication_ldap_simple_user_search_attr": "cn",
2568+
"authentication_ldap_simple_group_search_attr": "cn",
2569+
}
2570+
cnf = "\n# ldap_simple"
2571+
for key in ldap_simple_config:
2572+
cnf = "{}\n{}={}".format(cnf, key, ldap_simple_config[key])
2573+
self.server_cnf += cnf
2574+
2575+
cnx.close()
2576+
self.server.stop()
2577+
self.server.wait_down()
2578+
2579+
self.server.start(my_cnf=self.server_cnf)
2580+
self.server.wait_up()
2581+
sleep(1)
2582+
2583+
cnx = connection.MySQLConnection(**self.config)
2584+
2585+
identified_by = "CN=test1,CN=Users,DC=mysql,DC=local"
2586+
2587+
cnx.cmd_query("CREATE USER '{}'@'{}' IDENTIFIED "
2588+
"WITH authentication_ldap_simple AS"
2589+
"'{}'".format(self.user, self.host, identified_by))
2590+
cnx.cmd_query("GRANT ALL ON *.* TO '{}'@'{}'"
2591+
"".format(self.user, self.host))
2592+
cnx.cmd_query("FLUSH PRIVILEGES")
2593+
cnx.close()
2594+
2595+
def tearDown(self):
2596+
cnx = connection.MySQLConnection(**self.config)
2597+
try:
2598+
cnx.cmd_query("DROP USER '{}'@'{}'".format(self.user, self.host))
2599+
except:
2600+
pass
2601+
cnx.cmd_query("UNINSTALL PLUGIN authentication_ldap_simple")
2602+
cnx.cmd_query('show variables like "have_ssl"')
2603+
res = cnx.get_rows()[0][0]
2604+
cnx.close()
2605+
if res == ('have_ssl', 'DISABLED'):
2606+
self._enable_ssl()
2607+
2608+
def _disable_ssl(self):
2609+
self.server.stop()
2610+
self.server.wait_down()
2611+
2612+
self.server.start(ssl_ca='', ssl_cert='', ssl_key='', ssl=0,
2613+
my_cnf=self.server_cnf)
2614+
self.server.wait_up()
2615+
sleep(1)
2616+
cnx = connection.MySQLConnection(**self.config)
2617+
cnx.cmd_query('show variables like "have_ssl"')
2618+
res = cnx.get_rows()[0][0]
2619+
self.assertEqual(res, ('have_ssl', 'DISABLED'),
2620+
"can not dissable ssl: {}".format(res))
2621+
2622+
def _enable_ssl(self):
2623+
self.server.stop()
2624+
self.server.wait_down()
2625+
self.server.start()
2626+
self.server.wait_up()
2627+
sleep(1)
2628+
2629+
@tests.foreach_cnx()
2630+
def test_clear_text_pass(self):
2631+
"""test_clear_text_passwords_without_secure_connection"""
2632+
conn_args = {
2633+
"user": "[email protected]",
2634+
"host": self.config["host"],
2635+
"port": self.config["port"],
2636+
"password": "Testpw1",
2637+
"auth_plugin": "mysql_clear_password",
2638+
}
2639+
2640+
# Atempt connection with wrong password
2641+
bad_pass_args = conn_args.copy()
2642+
bad_pass_args["password"] = "wrong_password"
2643+
with self.assertRaises(ProgrammingError) as context:
2644+
_ = self.cnx.__class__(**bad_pass_args)
2645+
self.assertIn("Access denied for user", context.exception.msg,
2646+
"not the expected error {}".format(context.exception.msg))
2647+
2648+
# connect using mysql clear password and ldap simple auth method
2649+
cnx = self.cnx.__class__(**conn_args)
2650+
cnx.cmd_query('SELECT USER()')
2651+
res = cnx.get_rows()[0][0][0]
2652+
self.assertIn(self.user, res, "not the expected user {}".format(res))
2653+
cnx.close()
2654+
2655+
# Disabling ssl must raise an error.
2656+
conn_args["ssl_disabled"] = True
2657+
with self.assertRaises(InterfaceError) as context:
2658+
_ = self.cnx.__class__(**conn_args)
2659+
self.assertEqual("Clear password authentication is not supported over "
2660+
"insecure channels", context.exception.msg,
2661+
"Unexpected exception message found: {}"
2662+
"".format(context.exception.msg))
2663+
2664+
# Unix socket is used in unix by default if not popped or set to None
2665+
conn_args["unix_socket"] = tests.get_mysql_config().get("unix_socket", None)
2666+
with self.assertRaises(InterfaceError) as context:
2667+
_ = self.cnx.__class__(**conn_args)
2668+
self.assertEqual("Clear password authentication is not supported over "
2669+
"insecure channels", context.exception.msg,
2670+
"Unexpected exception message found: {}"
2671+
"".format(context.exception.msg))
2672+
2673+
# Attempt connection with verify certificate set to True
2674+
conn_args.pop("ssl_disabled")
2675+
conn_args.pop("unix_socket")
2676+
conn_args.update({
2677+
'ssl_ca': os.path.abspath(
2678+
os.path.join(tests.SSL_DIR, 'tests_CA_cert.pem')),
2679+
'ssl_cert': os.path.abspath(
2680+
os.path.join(tests.SSL_DIR, 'tests_client_cert.pem')),
2681+
'ssl_key': os.path.abspath(
2682+
os.path.join(tests.SSL_DIR, 'tests_client_key.pem')),
2683+
})
2684+
conn_args["ssl_verify_cert"] = True
2685+
cnx = self.cnx.__class__(**conn_args)
2686+
cnx.cmd_query('SELECT USER()')
2687+
res = cnx.get_rows()[0][0][0]
2688+
self.assertIn(self.user, res, "not the expected user {}".format(res))
2689+
cnx.close()
2690+
2691+
if CMySQLConnection is not None and isinstance(cnx, CMySQLConnection):
2692+
# Not testing cext without ssl
2693+
return
2694+
self._disable_ssl()
2695+
# Error must be raised to avoid send the password insecurely
2696+
self.assertRaises(InterfaceError, self.cnx.__class__, **conn_args)
2697+
2698+
25342699
class WL13334(tests.MySQLConnectorTests):
25352700
"""WL#13334: Failover and multihost
25362701
"""

0 commit comments

Comments
 (0)