Skip to content

Commit f10fb76

Browse files
authored
[feat] Update SSL option to connect securely by default (#1418)
* [feat] Update SSL option to connect securely by default * Added the --no-ssl option. Updated the changelog. Added to the .gitignore to be less annoying. * Moved connection params to dict to avoid repeating it * Added initial logic for a new ssl_mode config/cli option * Removed unused import * Updated logic to handle interaction between new ssl_mode and existing ssl options. Added tests to cover ssl_mode functionality. * Moved the new ssl_mode config option to the main section. Updated ssl/no-ssl deprecation warning. Updated changelog to match.
1 parent 051b85b commit f10fb76

File tree

7 files changed

+165
-18
lines changed

7 files changed

+165
-18
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@
1515

1616
.venv/
1717
venv/
18+
19+
.myclirc
20+
uv.lock

changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Upcoming (TBD)
44
Features
55
--------
66
* Update query processing functions to allow automatic show_warnings to work for more code paths like DDL.
7+
* Add new ssl_mode config / --ssl-mode CLI option to control SSL connection behavior. This setting will supercede the
8+
existing --ssl/--no-ssl CLI options, which are deprecated and will be removed in a future release.
79
* Rework reconnect logic to actually reconnect or create a new connection instead of simply changing the database (#746).
810
* Configurable string for missing values (NULLs) in outputs.
911

mycli/main.py

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from prompt_toolkit.lexers import PygmentsLexer
4040
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession
4141
import pymysql
42+
from pymysql.constants.ER import HANDSHAKE_ERROR
4243
from pymysql.cursors import Cursor
4344
import sqlglot
4445
import sqlparse
@@ -156,6 +157,14 @@ def __init__(
156157
self.post_redirect_command = c['main'].get('post_redirect_command')
157158
self.null_string = c['main'].get('null_string')
158159

160+
# set ssl_mode if a valid option is provided in a config file, otherwise None
161+
ssl_mode = c["main"].get("ssl_mode", None)
162+
if ssl_mode not in ("auto", "on", "off", None):
163+
self.echo(f"Invalid config option provided for ssl_mode ({ssl_mode}); ignoring.", err=True, fg="red")
164+
self.ssl_mode = None
165+
else:
166+
self.ssl_mode = ssl_mode
167+
159168
# read from cli argument or user config file
160169
self.auto_vertical_output = auto_vertical_output or c["main"].as_bool("auto_vertical_output")
161170
self.show_warnings = show_warnings or c["main"].as_bool("show_warnings")
@@ -568,6 +577,24 @@ def _connect() -> None:
568577
ssh_key_filename,
569578
init_command,
570579
)
580+
elif e.args[0] == HANDSHAKE_ERROR and ssl is not None and ssl.get("mode", None) == "auto":
581+
self.sqlexecute = SQLExecute(
582+
database,
583+
user,
584+
passwd,
585+
host,
586+
int_port,
587+
socket,
588+
charset,
589+
use_local_infile,
590+
None,
591+
ssh_user,
592+
ssh_host,
593+
int(ssh_port) if ssh_port else None,
594+
ssh_password,
595+
ssh_key_filename,
596+
init_command,
597+
)
571598
else:
572599
raise e
573600

@@ -1414,7 +1441,13 @@ def get_last_query(self) -> str | None:
14141441
@click.option("--ssh-key-filename", help="Private key filename (identify file) for the ssh connection.")
14151442
@click.option("--ssh-config-path", help="Path to ssh configuration.", default=os.path.expanduser("~") + "/.ssh/config")
14161443
@click.option("--ssh-config-host", help="Host to connect to ssh server reading from ssh configuration.")
1417-
@click.option("--ssl", "ssl_enable", is_flag=True, help="Enable SSL for connection (automatically enabled with other flags).")
1444+
@click.option(
1445+
"--ssl-mode",
1446+
"ssl_mode",
1447+
help="Set desired SSL behavior. auto=preferred, on=required, off=off.",
1448+
type=click.Choice(["auto", "on", "off"]),
1449+
)
1450+
@click.option("--ssl/--no-ssl", "ssl_enable", default=None, help="Enable SSL for connection (automatically enabled with other flags).")
14181451
@click.option("--ssl-ca", help="CA file in PEM format.", type=click.Path(exists=True))
14191452
@click.option("--ssl-capath", help="CA directory.")
14201453
@click.option("--ssl-cert", help="X509 cert in PEM format.", type=click.Path(exists=True))
@@ -1430,8 +1463,6 @@ def get_last_query(self) -> str | None:
14301463
is_flag=True,
14311464
help=("""Verify server's "Common Name" in its cert against hostname used when connecting. This option is disabled by default."""),
14321465
)
1433-
# as of 2016-02-15 revocation list is not supported by underling PyMySQL
1434-
# library (--ssl-crl and --ssl-crlpath options in vanilla mysql client)
14351466
@click.version_option(__version__, "-V", "--version", help="Output mycli's version.")
14361467
@click.option("-v", "--verbose", is_flag=True, help="Verbose output.")
14371468
@click.option("-D", "--database", "dbname", help="Database to use.")
@@ -1480,6 +1511,7 @@ def cli(
14801511
auto_vertical_output: bool,
14811512
show_warnings: bool,
14821513
local_infile: bool,
1514+
ssl_mode: str | None,
14831515
ssl_enable: bool,
14841516
ssl_ca: str | None,
14851517
ssl_capath: str | None,
@@ -1526,6 +1558,15 @@ def cli(
15261558
warn=warn,
15271559
myclirc=myclirc,
15281560
)
1561+
1562+
if ssl_enable is not None:
1563+
click.secho(
1564+
"Warning: The --ssl/--no-ssl CLI options are deprecated and will be removed in a future release. "
1565+
"Please use the ssl_mode config or --ssl-mode CLI options instead.",
1566+
err=True,
1567+
fg="yellow",
1568+
)
1569+
15291570
if list_dsn:
15301571
try:
15311572
alias_dsn = mycli.config["alias_dsn"]
@@ -1622,19 +1663,36 @@ def cli(
16221663
ssl_verify_server_cert = ssl_verify_server_cert or (params[0].lower() == 'true')
16231664
ssl_enable = True
16241665

1625-
ssl = {
1626-
"enable": ssl_enable,
1627-
"ca": ssl_ca and os.path.expanduser(ssl_ca),
1628-
"cert": ssl_cert and os.path.expanduser(ssl_cert),
1629-
"key": ssl_key and os.path.expanduser(ssl_key),
1630-
"capath": ssl_capath,
1631-
"cipher": ssl_cipher,
1632-
"tls_version": tls_version,
1633-
"check_hostname": ssl_verify_server_cert,
1634-
}
1635-
1636-
# remove empty ssl options
1637-
ssl = {k: v for k, v in ssl.items() if v is not None}
1666+
ssl_mode = ssl_mode or mycli.ssl_mode # cli option or config option
1667+
1668+
# if there is a mismatch between the ssl_mode value and other sources of ssl config, show a warning
1669+
# specifically using "is False" to not pickup the case where ssl_enable is None (not set by the user)
1670+
if ssl_enable and ssl_mode == "off" or ssl_enable is False and ssl_mode in ("auto", "on"):
1671+
click.secho(
1672+
f"Warning: The current ssl_mode value of '{ssl_mode}' is overriding the value provided by "
1673+
f"either the --ssl/--no-ssl CLI options or a DSN URI parameter (ssl={ssl_enable}).",
1674+
err=True,
1675+
fg="yellow",
1676+
)
1677+
1678+
# configure SSL if ssl_mode is auto/on or if
1679+
# ssl_enable = True (from --ssl or a DSN URI) and ssl_mode is None
1680+
if ssl_mode in ("auto", "on") or (ssl_enable and ssl_mode is None):
1681+
ssl = {
1682+
"mode": ssl_mode,
1683+
"enable": ssl_enable,
1684+
"ca": ssl_ca and os.path.expanduser(ssl_ca),
1685+
"cert": ssl_cert and os.path.expanduser(ssl_cert),
1686+
"key": ssl_key and os.path.expanduser(ssl_key),
1687+
"capath": ssl_capath,
1688+
"cipher": ssl_cipher,
1689+
"tls_version": tls_version,
1690+
"check_hostname": ssl_verify_server_cert,
1691+
}
1692+
# remove empty ssl options
1693+
ssl = {k: v for k, v in ssl.items() if v is not None}
1694+
else:
1695+
ssl = None
16381696

16391697
if ssh_config_host:
16401698
ssh_config = read_ssh_config(ssh_config_path).lookup(ssh_config_host)

mycli/myclirc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
# after executing a SQL statement when applicable.
66
show_warnings = False
77

8+
# Sets the desired behavior for handling secure connections to the database server.
9+
# Possible values:
10+
# auto = SSL is preferred. Will attempt to connect via SSL, but will fallback to cleartext as needed.
11+
# on = SSL is required. Will attempt to connect via SSL and will fail if a secure connection is not established.
12+
# off = do not use SSL. Will fail if the server requires a secure connection.
13+
ssl_mode = auto
14+
815
# Enables context sensitive auto-completion. If this is disabled the all
916
# possible completions will be listed.
1017
smart_completion = True

test/features/db_utils.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ def create_cn(hostname, port, password, username, dbname):
4040
4141
"""
4242
cn = pymysql.connect(
43-
host=hostname, port=port, user=username, password=password, db=dbname, charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor
43+
host=hostname,
44+
port=port,
45+
user=username,
46+
password=password,
47+
db=dbname,
48+
charset="utf8mb4",
49+
cursorclass=pymysql.cursors.DictCursor,
4450
)
4551

4652
return cn
@@ -57,7 +63,13 @@ def drop_db(hostname="localhost", port=3306, username=None, password=None, dbnam
5763
5864
"""
5965
cn = pymysql.connect(
60-
host=hostname, port=port, user=username, password=password, db=dbname, charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor
66+
host=hostname,
67+
port=port,
68+
user=username,
69+
password=password,
70+
db=dbname,
71+
charset="utf8mb4",
72+
cursorclass=pymysql.cursors.DictCursor,
6173
)
6274

6375
with cn.cursor() as cr:

test/myclirc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
# after executing a SQL statement when applicable.
66
show_warnings = False
77

8+
# Sets the desired behavior for handling secure connections to the database server.
9+
# Possible values:
10+
# auto = SSL is preferred. Will attempt to connect via SSL, but will fallback to cleartext as needed.
11+
# on = SSL is required. Will attempt to connect via SSL and will fail if a secure connection is not established.
12+
# off = do not use SSL. Will fail if the server requires a secure connection.
13+
ssl_mode = auto
14+
815
# Enables context sensitive auto-completion. If this is disabled the all
916
# possible completions will be listed.
1017
smart_completion = True

test/test_main.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# type: ignore
22

33
from collections import namedtuple
4+
import csv
45
import os
56
import shutil
67
from tempfile import NamedTemporaryFile
@@ -38,6 +39,61 @@
3839
]
3940

4041

42+
@dbtest
43+
def test_ssl_mode_on(executor, capsys):
44+
runner = CliRunner()
45+
ssl_mode = "on"
46+
sql = "select * from performance_schema.session_status where variable_name = 'Ssl_cipher'"
47+
result = runner.invoke(cli, args=CLI_ARGS + ["--csv", "--ssl-mode", ssl_mode], input=sql)
48+
result_dict = next(csv.DictReader(result.stdout.split("\n")))
49+
ssl_cipher = result_dict["VARIABLE_VALUE"]
50+
assert ssl_cipher
51+
52+
53+
@dbtest
54+
def test_ssl_mode_auto(executor, capsys):
55+
runner = CliRunner()
56+
ssl_mode = "auto"
57+
sql = "select * from performance_schema.session_status where variable_name = 'Ssl_cipher'"
58+
result = runner.invoke(cli, args=CLI_ARGS + ["--csv", "--ssl-mode", ssl_mode], input=sql)
59+
result_dict = next(csv.DictReader(result.stdout.split("\n")))
60+
ssl_cipher = result_dict["VARIABLE_VALUE"]
61+
assert ssl_cipher
62+
63+
64+
@dbtest
65+
def test_ssl_mode_off(executor, capsys):
66+
runner = CliRunner()
67+
ssl_mode = "off"
68+
sql = "select * from performance_schema.session_status where variable_name = 'Ssl_cipher'"
69+
result = runner.invoke(cli, args=CLI_ARGS + ["--csv", "--ssl-mode", ssl_mode], input=sql)
70+
result_dict = next(csv.DictReader(result.stdout.split("\n")))
71+
ssl_cipher = result_dict["VARIABLE_VALUE"]
72+
assert not ssl_cipher
73+
74+
75+
@dbtest
76+
def test_ssl_mode_overrides_ssl(executor, capsys):
77+
runner = CliRunner()
78+
ssl_mode = "off"
79+
sql = "select * from performance_schema.session_status where variable_name = 'Ssl_cipher'"
80+
result = runner.invoke(cli, args=CLI_ARGS + ["--csv", "--ssl-mode", ssl_mode, "--ssl"], input=sql)
81+
result_dict = next(csv.DictReader(result.stdout.split("\n")))
82+
ssl_cipher = result_dict["VARIABLE_VALUE"]
83+
assert not ssl_cipher
84+
85+
86+
@dbtest
87+
def test_ssl_mode_overrides_no_ssl(executor, capsys):
88+
runner = CliRunner()
89+
ssl_mode = "on"
90+
sql = "select * from performance_schema.session_status where variable_name = 'Ssl_cipher'"
91+
result = runner.invoke(cli, args=CLI_ARGS + ["--csv", "--ssl-mode", ssl_mode, "--no-ssl"], input=sql)
92+
result_dict = next(csv.DictReader(result.stdout.split("\n")))
93+
ssl_cipher = result_dict["VARIABLE_VALUE"]
94+
assert ssl_cipher
95+
96+
4197
@dbtest
4298
def test_reconnect_no_database(executor, capsys):
4399
m = MyCli()
@@ -509,6 +565,7 @@ def __init__(self, **args):
509565
self.destructive_warning = False
510566
self.main_formatter = Formatter()
511567
self.redirect_formatter = Formatter()
568+
self.ssl_mode = "auto"
512569

513570
def connect(self, **args):
514571
MockMyCli.connect_args = args
@@ -673,6 +730,7 @@ def __init__(self, **args):
673730
self.destructive_warning = False
674731
self.main_formatter = Formatter()
675732
self.redirect_formatter = Formatter()
733+
self.ssl_mode = "auto"
676734

677735
def connect(self, **args):
678736
MockMyCli.connect_args = args

0 commit comments

Comments
 (0)