Skip to content

Commit bab3889

Browse files
authored
Escape SQL Server passwords in connection strings (#24138)
* Escape SQL Server passwords in connection strings * Add changelog for SQL Server password escaping * Fix SQL Server semicolon password test cleanup * Document SQL Server password escaping rules * Clean up SQL Server password test sessions * Avoid SQL Server password test cleanup races * Clarify SQL Server password escaping comments * Cover SQL Server password delimiter characters * Stabilize SQL Server password escape test * Use one SQL Server special-character password test * Handle legacy FreeTDS password brace parsing * Select FreeTDS password escape directly * Scope FreeTDS password escape to configured driver * Reject unsupported FreeTDS password sequence
1 parent d42bb0e commit bab3889

3 files changed

Lines changed: 144 additions & 2 deletions

File tree

sqlserver/changelog.d/24138.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Escape SQL Server passwords when building connection strings.

sqlserver/datadog_checks/sqlserver/connection.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,47 @@ def parse_connection_string_properties(cs):
137137
return params
138138

139139

140+
def _escape_odbc_connection_string_value(value: str) -> str:
141+
"""Return a value escaped for an ODBC connection string."""
142+
# SQL Server passwords may contain symbols, but ODBC connection strings use
143+
# semicolons to separate key/value pairs. The ODBC string spec wraps such
144+
# values in braces and escapes a literal right brace as "}}"; the outer
145+
# braces are delimiters, not part of the password.
146+
# Password: pa;ss}word
147+
# Connection string: Server=host;UID=datadog;PWD={pa;ss}}word};
148+
# Sources: https://learn.microsoft.com/en-us/sql/relational-databases/security/strong-passwords
149+
# https://sqlprotocoldoc.z19.web.core.windows.net/MS-ODBCSTR/%5BMS-ODBCSTR%5D-100604.pdf
150+
return '{{{}}}'.format(value.replace('}', '}}'))
151+
152+
153+
def _escape_legacy_freetds_odbc_connection_string_value(value: str) -> str:
154+
"""Return a value escaped for legacy FreeTDS ODBC connection strings."""
155+
# FreeTDS 1.3.6 does not decode "}}" inside braced values. It treats "}" as
156+
# part of the password unless the next character is ";", so the FreeTDS path
157+
# keeps the brace raw.
158+
# Password: pa;ss}word
159+
# Connection string: Driver=FreeTDS;Server=host;UID=datadog;PWD={pa;ss}word};
160+
# Source: https://github.com/FreeTDS/freetds/blob/v1.3.6/src/odbc/connectparams.c
161+
return '{{{}}}'.format(value)
162+
163+
164+
def _is_freetds_driver(driver: str) -> bool:
165+
"""Return whether an ODBC driver value points to FreeTDS."""
166+
return 'freetds' in driver.lower() or 'libtdsodbc' in driver.lower()
167+
168+
169+
def _escape_adodbapi_connection_string_value(value: str) -> str:
170+
"""Return a value escaped for an adodbapi connection string."""
171+
# adodbapi uses an ADO/OLE DB connection string, where semicolons separate
172+
# key/value pairs. Quoting keeps semicolons inside the password value; a
173+
# literal quote inside that value is escaped by doubling it. The outer quotes
174+
# are delimiters, not part of the password.
175+
# Password: pa;ss"word
176+
# Connection string: Provider=MSOLEDBSQL;User ID=datadog;Password="pa;ss""word";
177+
# Docs: https://learn.microsoft.com/en-us/sql/connect/oledb/applications/using-connection-string-keywords-with-oledb-driver-for-sql-server
178+
return '"{}"'.format(value.replace('"', '""'))
179+
180+
140181
class Connection(object):
141182
"""Manages the connection to a SQL Server instance."""
142183

@@ -614,6 +655,11 @@ def _conn_string_odbc(self, db_key, conn_key=None, db_name=None):
614655
else:
615656
dsn, host, username, password, database, driver = self._get_access_info(db_key, db_name)
616657

658+
escape_func = _escape_odbc_connection_string_value
659+
is_freetds_driver = driver and _is_freetds_driver(driver)
660+
if is_freetds_driver:
661+
escape_func = _escape_legacy_freetds_odbc_connection_string_value
662+
617663
if self.managed_auth_enabled:
618664
# if managed_identity authentication is configured,
619665
# remove the username/password from the CS, if set
@@ -638,7 +684,12 @@ def _conn_string_odbc(self, db_key, conn_key=None, db_name=None):
638684
conn_str += 'UID={};'.format(username)
639685
self.log.debug("Connection string (before password) %s", conn_str)
640686
if password:
641-
conn_str += 'PWD={};'.format(password)
687+
if is_freetds_driver and '};' in password:
688+
raise ConfigurationError(
689+
"SQL Server passwords containing the sequence '};' cannot be represented in FreeTDS ODBC "
690+
"connection strings. Use Microsoft ODBC Driver for SQL Server or change the password."
691+
)
692+
conn_str += 'PWD={};'.format(escape_func(password))
642693
return conn_str
643694

644695
def _conn_string_adodbapi(self, db_key, conn_key=None, db_name=None):
@@ -659,7 +710,7 @@ def _conn_string_adodbapi(self, db_key, conn_key=None, db_name=None):
659710
conn_str += 'User ID={};'.format(username)
660711
self.log.debug("Connection string (before password) %s", conn_str)
661712
if password:
662-
conn_str += 'Password={};'.format(password)
713+
conn_str += 'Password={};'.format(_escape_adodbapi_connection_string_value(password))
663714
if not username and not password:
664715
conn_str += 'Integrated Security=SSPI;'
665716
return conn_str

sqlserver/tests/test_connection.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from .common import CHECK_NAME, SQLSERVER_YEAR
2727

2828
KEY_PREFIX = "dbm-test-"
29+
SPECIAL_CHARACTERS_PASSWORD_LOGIN = 'datadog_special_characters_password'
30+
SPECIAL_CHARACTERS_PASSWORD = 'Pa;ss}"word123!'
2931

3032

3133
@pytest.mark.unit
@@ -286,6 +288,94 @@ def test_config_with_and_without_port(instance_minimal_defaults, host, port, exp
286288
assert result_host == expected_host
287289

288290

291+
@pytest.mark.unit
292+
@pytest.mark.parametrize(
293+
'connector,driver,password,expected_password_parameter',
294+
[
295+
pytest.param('odbc', None, SPECIAL_CHARACTERS_PASSWORD, 'PWD={Pa;ss}}"word123!};', id='odbc'),
296+
pytest.param('odbc', 'FreeTDS', SPECIAL_CHARACTERS_PASSWORD, 'PWD={Pa;ss}"word123!};', id='odbc-freetds'),
297+
pytest.param('adodbapi', None, SPECIAL_CHARACTERS_PASSWORD, 'Password="Pa;ss}""word123!";', id='adodbapi'),
298+
],
299+
)
300+
def test_connection_string_escapes_password_special_characters(
301+
instance_minimal_defaults: dict[str, object],
302+
connector: str,
303+
driver: str | None,
304+
password: str,
305+
expected_password_parameter: str,
306+
) -> None:
307+
instance_minimal_defaults.update({'connector': connector, 'password': password})
308+
if driver:
309+
instance_minimal_defaults['driver'] = driver
310+
connection = Connection({}, instance_minimal_defaults, None)
311+
312+
if connector == 'odbc':
313+
conn_str = connection._conn_string_odbc('database')
314+
else:
315+
conn_str = connection._conn_string_adodbapi('database')
316+
317+
assert expected_password_parameter in conn_str
318+
319+
320+
@pytest.mark.unit
321+
def test_freetds_password_with_closing_brace_semicolon_raises_limitation(
322+
instance_minimal_defaults: dict[str, object],
323+
) -> None:
324+
password = 'pass};word'
325+
instance_minimal_defaults.update({'connector': 'odbc', 'driver': 'FreeTDS', 'password': password})
326+
connection = Connection({}, instance_minimal_defaults, None)
327+
328+
with pytest.raises(ConfigurationError) as exc_info:
329+
connection._conn_string_odbc('database')
330+
331+
error_message = str(exc_info.value)
332+
assert "'};'" in error_message
333+
assert 'FreeTDS' in error_message
334+
assert 'Microsoft ODBC Driver for SQL Server' in error_message
335+
assert password not in error_message
336+
337+
338+
@pytest.fixture
339+
def special_characters_password_login(sa_conn: object) -> tuple[str, str]:
340+
with sa_conn.cursor() as cursor:
341+
cursor.execute(
342+
"""
343+
IF NOT EXISTS (
344+
SELECT 1 FROM sys.server_principals WHERE name = N'{login}'
345+
)
346+
CREATE LOGIN [{login}] WITH PASSWORD = '{password}', CHECK_POLICY = OFF
347+
ELSE
348+
ALTER LOGIN [{login}] WITH PASSWORD = '{password}', CHECK_POLICY = OFF
349+
""".format(login=SPECIAL_CHARACTERS_PASSWORD_LOGIN, password=SPECIAL_CHARACTERS_PASSWORD)
350+
)
351+
cursor.execute(
352+
"""
353+
IF NOT EXISTS (
354+
SELECT 1 FROM sys.database_principals WHERE name = N'{login}'
355+
)
356+
CREATE USER [{login}] FOR LOGIN [{login}]
357+
""".format(login=SPECIAL_CHARACTERS_PASSWORD_LOGIN)
358+
)
359+
return SPECIAL_CHARACTERS_PASSWORD_LOGIN, SPECIAL_CHARACTERS_PASSWORD
360+
361+
362+
@pytest.mark.integration
363+
@pytest.mark.usefixtures('dd_environment')
364+
def test_connection_with_special_characters_in_password(
365+
instance_docker: dict[str, object], special_characters_password_login: tuple[str, str]
366+
) -> None:
367+
login_name, password = special_characters_password_login
368+
instance_docker['username'] = login_name
369+
instance_docker['password'] = password
370+
check = SQLServer(CHECK_NAME, {}, [instance_docker])
371+
check.initialize_connection()
372+
373+
with check.connection.open_managed_default_connection(KEY_PREFIX):
374+
with check.connection.get_managed_cursor(KEY_PREFIX) as cursor:
375+
cursor.execute("select 1")
376+
assert cursor.fetchall()
377+
378+
289379
@pytest.mark.flaky
290380
@pytest.mark.integration
291381
@pytest.mark.usefixtures('dd_environment')

0 commit comments

Comments
 (0)