diff --git a/eng/pipelines/pr-validation-pipeline.yml b/eng/pipelines/pr-validation-pipeline.yml index 8cc7ea8e9..ebef7f627 100644 --- a/eng/pipelines/pr-validation-pipeline.yml +++ b/eng/pipelines/pr-validation-pipeline.yml @@ -234,6 +234,10 @@ jobs: parameters: platform: windows + - template: steps/install-mock-tds.yml + parameters: + platform: windows + # Run tests for LocalDB - script: | python -m pytest -v --junitxml=test-results-localdb.xml --cov=. --cov-report=xml:coverage-localdb.xml --capture=tee-sys --cache-clear @@ -543,6 +547,10 @@ jobs: parameters: platform: unix + - template: steps/install-mock-tds.yml + parameters: + platform: unix + - script: | echo "Build successful, running tests now" python -m pytest -v --junitxml=test-results.xml --cov=. --cov-report=xml --capture=tee-sys --cache-clear @@ -799,6 +807,12 @@ jobs: containerName: test-container-$(distroName) venvActivate: 'source /opt/venv/bin/activate' + - template: steps/install-mock-tds.yml + parameters: + platform: container + containerName: test-container-$(distroName) + venvActivate: 'source /opt/venv/bin/activate' + - script: | # Uninstall ODBC Driver before running tests docker exec test-container-$(distroName) bash -c " @@ -1124,6 +1138,12 @@ jobs: containerName: test-container-$(distroName)-$(archName) venvActivate: 'source /opt/venv/bin/activate' + - template: steps/install-mock-tds.yml + parameters: + platform: container + containerName: test-container-$(distroName)-$(archName) + venvActivate: 'source /opt/venv/bin/activate' + - script: | # Uninstall ODBC Driver before running tests docker exec test-container-$(distroName)-$(archName) bash -c " @@ -1338,6 +1358,12 @@ jobs: containerName: test-container-rhel9 venvActivate: 'source myvenv/bin/activate' + - template: steps/install-mock-tds.yml + parameters: + platform: container + containerName: test-container-rhel9 + venvActivate: 'source myvenv/bin/activate' + - script: | # Uninstall ODBC Driver before running tests docker exec test-container-rhel9 bash -c " @@ -1563,6 +1589,12 @@ jobs: containerName: test-container-rhel9-arm64 venvActivate: 'source myvenv/bin/activate' + - template: steps/install-mock-tds.yml + parameters: + platform: container + containerName: test-container-rhel9-arm64 + venvActivate: 'source myvenv/bin/activate' + - script: | # Uninstall ODBC Driver before running tests docker exec test-container-rhel9-arm64 bash -c " @@ -1796,6 +1828,12 @@ jobs: containerName: test-container-alpine venvActivate: 'source /workspace/venv/bin/activate' + - template: steps/install-mock-tds.yml + parameters: + platform: container + containerName: test-container-alpine + venvActivate: 'source /workspace/venv/bin/activate' + - script: | # Uninstall ODBC Driver before running tests to use bundled libraries docker exec test-container-alpine bash -c " @@ -2047,6 +2085,12 @@ jobs: containerName: test-container-alpine-arm64 venvActivate: 'source /workspace/venv/bin/activate' + - template: steps/install-mock-tds.yml + parameters: + platform: container + containerName: test-container-alpine-arm64 + venvActivate: 'source /workspace/venv/bin/activate' + - script: | # Uninstall ODBC Driver before running tests to use bundled libraries docker exec test-container-alpine-arm64 bash -c " @@ -2398,6 +2442,10 @@ jobs: parameters: platform: unix + - template: steps/install-mock-tds.yml + parameters: + platform: unix + - script: | # Generate unified coverage (Python + C++) chmod +x ./generate_codecov.sh diff --git a/eng/pipelines/steps/install-mock-tds.yml b/eng/pipelines/steps/install-mock-tds.yml new file mode 100644 index 000000000..46078c314 --- /dev/null +++ b/eng/pipelines/steps/install-mock-tds.yml @@ -0,0 +1,64 @@ +# Step template: Install mssql-mock-tds (in-process mock TDS server) from the +# public mssql-rs_Public Azure Artifacts PyPI feed via eng/requirements-mock-tds.txt. +# +# This is required for tests/test_024_mock_tds_fedauth.py, which guards the +# deferred SQL_COPT_SS_ACCESS_TOKEN use-after-free fix (PR #596 / issue #594). +# The install fails the leg if no compatible wheel is available: the package +# version and feed are pinned in eng/requirements-mock-tds.txt. +# +# Usage: +# # Windows (host) +# - template: steps/install-mock-tds.yml +# parameters: +# platform: windows +# +# # macOS / Linux (host) +# - template: steps/install-mock-tds.yml +# parameters: +# platform: unix +# +# # Inside a Docker container +# - template: steps/install-mock-tds.yml +# parameters: +# platform: container +# containerName: test-container-$(distroName) +# venvActivate: 'source /opt/venv/bin/activate' + +parameters: + - name: platform + type: string + values: [windows, unix, container] + + - name: containerName + type: string + default: '' + + - name: venvActivate + type: string + default: 'source /opt/venv/bin/activate' + + - name: displaySuffix + type: string + default: '' + +steps: +# Windows host +- ${{ if eq(parameters.platform, 'windows') }}: + - script: | + python -m pip install -r eng/requirements-mock-tds.txt + displayName: 'Install mssql-mock-tds${{ parameters.displaySuffix }}' + +# Unix host (macOS, Linux without container) +- ${{ if eq(parameters.platform, 'unix') }}: + - script: | + python -m pip install -r eng/requirements-mock-tds.txt + displayName: 'Install mssql-mock-tds${{ parameters.displaySuffix }}' + +# Inside a Docker container +- ${{ if eq(parameters.platform, 'container') }}: + - script: | + docker exec ${{ parameters.containerName }} bash -c " + ${{ parameters.venvActivate }} + python -m pip install -r eng/requirements-mock-tds.txt + " + displayName: 'Install mssql-mock-tds in ${{ parameters.containerName }}${{ parameters.displaySuffix }}' diff --git a/eng/requirements-mock-tds.txt b/eng/requirements-mock-tds.txt new file mode 100644 index 000000000..9d619dda6 --- /dev/null +++ b/eng/requirements-mock-tds.txt @@ -0,0 +1,15 @@ +# Install requirements for the mssql-mock-tds in-process TDS server used by +# tests/test_024_mock_tds_fedauth.py (guards the deferred +# SQL_COPT_SS_ACCESS_TOKEN use-after-free fix, PR #596 / issue #594). +# +# cryptography is used by the test to mint the throwaway TLS identity the mock +# server needs. +# +# The package is published on the public mssql-rs_Public Azure Artifacts feed +# (no auth required). It ships wheels for linux glibc (x86_64/aarch64), +# musllinux/Alpine (x86_64/aarch64), macOS universal2, and Windows +# (amd64/arm64). This file is installed with `pip install -r` and fails the +# leg if no compatible wheel is available. +--extra-index-url https://pkgs.dev.azure.com/sqlclientdrivers/public/_packaging/mssql-rs_Public/pypi/simple/ +mssql-mock-tds==0.1.0.dev20260702159138 +cryptography diff --git a/tests/test_024_mock_tds_fedauth.py b/tests/test_024_mock_tds_fedauth.py new file mode 100644 index 000000000..4dcef6c7d --- /dev/null +++ b/tests/test_024_mock_tds_fedauth.py @@ -0,0 +1,299 @@ +""" +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. + +Integration tests for FedAuth (access token) connect attributes against a +*mock TDS server*. + +These tests guard the fix delivered in PR #596 / issue #594: + + SQL_COPT_SS_ACCESS_TOKEN (1256) is a *deferred* ODBC connect attribute. + The MS ODBC driver stashes the caller's pointer at ``SQLSetConnectAttr`` + time and only dereferences it later, during ``SQLDriverConnect``, to build + the FedAuth login packet. PR #568 briefly copied the value into a + stack-local buffer, which was freed before the deferred read -> a + use-after-free that variously produced SIGBUS, a server reset, or the + error "Authentication token is missing in the federated authentication + message". + + PR #596 stores the value in Connection-owned member buffers so it stays + valid for the deferred dereference. + +The regression is invisible to ordinary unit tests because it only manifests +once a real driver actually transmits the token to a server. The +``mssql-mock-tds`` package gives us exactly that: an in-process TDS server that +records the access token it received in the Login7 FedAuth feature. If the +deferred buffer is ever corrupted again, the token captured by the server will +not match the one we sent (or no token will arrive at all) and these tests +fail. + +Requirements (both optional; tests skip cleanly when missing): + * ``mssql-mock-tds`` -> the mock server Python bindings. Install from the + public feed, e.g.:: + + pip install --index-url \\ + https://pkgs.dev.azure.com/sqlclientdrivers/public/_packaging/mssql-rs_Public/pypi/simple/ \\ + mssql-mock-tds + + * ``cryptography`` -> used to generate the throwaway TLS identity the mock + server needs (already pulled in transitively by + ``azure-identity``). +""" + +import datetime +import os +import secrets +import struct + +import pytest + +from mssql_python.constants import ConstantsDDBC + +# --------------------------------------------------------------------------- +# Optional-dependency probing. The whole module is skipped (not failed) when a +# dependency is unavailable so the suite stays green on machines/CI legs that do +# not install the mock server. +# --------------------------------------------------------------------------- +try: + import mssql_mock_tds + + MOCK_TDS_AVAILABLE = True +except ImportError: + mssql_mock_tds = None + MOCK_TDS_AVAILABLE = False + +try: + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives.serialization import pkcs12, NoEncryption + from cryptography.x509.oid import NameOID + + CRYPTOGRAPHY_AVAILABLE = True +except ImportError: + CRYPTOGRAPHY_AVAILABLE = False + + +pytestmark = pytest.mark.skipif( + not (MOCK_TDS_AVAILABLE and CRYPTOGRAPHY_AVAILABLE), + reason=( + "Requires the 'mssql-mock-tds' package and 'cryptography'. " + "Install mssql-mock-tds from the mssql-rs public feed to run these tests." + ), +) + +# Bound each connect so a future protocol change can never hang the suite. With +# the current mock the FedAuth Login7 is answered and the connection completes +# quickly; this login timeout is just a safety net. +_LOGIN_TIMEOUT_SECONDS = 3 + + +def _write_test_identity(directory): + """Generate a self-signed TLS identity the mock server can load. + + The mock server looks for a TLS identity at ``/tests/test_certificates`` + (among a few relative candidates) and resolves it in this order: it first + tries ``valid_cert.pem`` + ``key.pem`` via ``create_test_identity`` (OpenSSL, + non-Windows only), then falls back to ``identity.pfx`` via + ``load_identity_from_file`` with an *empty* password. + + Those two paths need different files per platform: + + * **Non-Windows (Linux, macOS):** emit the PEM pair. ``create_test_identity`` + re-packs them into a 3DES-encrypted PKCS#12 with a non-empty password, + which macOS' Security framework accepts. A password-less ``identity.pfx`` + would *not* load on macOS -- ``Identity::from_pkcs12`` there rejects an + unencrypted PKCS#12 with "The user name or passphrase you entered is not + correct.", and we cannot influence the empty password the binding uses. + * **Windows:** ``create_test_identity`` is unavailable (no bundled OpenSSL), + so emit ``identity.pfx``. Schannel happily loads a password-less PKCS#12. + """ + cert_dir = os.path.join(directory, "tests", "test_certificates") + os.makedirs(cert_dir, exist_ok=True) + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.COMMON_NAME, "localhost"), + ] + ) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) + .sign(key, hashes.SHA256()) + ) + + if os.name == "nt": + pfx = pkcs12.serialize_key_and_certificates( + name=b"mock-tds", + key=key, + cert=cert, + cas=None, + encryption_algorithm=NoEncryption(), + ) + with open(os.path.join(cert_dir, "identity.pfx"), "wb") as handle: + handle.write(pfx) + else: + cert_pem = cert.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=NoEncryption(), + ) + with open(os.path.join(cert_dir, "valid_cert.pem"), "wb") as handle: + handle.write(cert_pem) + with open(os.path.join(cert_dir, "key.pem"), "wb") as handle: + handle.write(key_pem) + + +def _access_token_struct(raw_token): + """Build the ODBC SQL_COPT_SS_ACCESS_TOKEN value for *raw_token*. + + Mirrors ``mssql_python.auth.AADAuth.get_token_struct``: a little-endian + 4-byte length prefix followed by the UTF-16LE encoded token. The driver + unwraps this struct and transmits only the bare token in the Login7 FedAuth + feature, which is what the mock server records. + """ + token_bytes = raw_token.encode("utf-16-le") + return struct.pack(f"= 1, ( + "Mock server recorded no connection; the driver never reached " + f"Login7. Last connect error: {self_error!r}" + ) + assert mock_tls_server.has_received_token(token), ( + "Mock server did not receive the expected access token. This is the " + "#594/#596 deferred connect-attribute use-after-free signature " + f"(last token={mock_tls_server.get_last_access_token()!r})." + ) + assert mock_tls_server.get_last_access_token() == token + + def test_unique_access_token_transmitted_exactly(self, mock_tls_server): + """A random, unguessable token rules out any cached/constant fallback.""" + token = f"regression_token_{secrets.token_hex(24)}" + + _connect_with_token(mock_tls_server, token) + + assert mock_tls_server.get_last_access_token() == token, ( + "Unique access token was not transmitted byte-for-byte: sent " + f"{token!r}, server received " + f"{mock_tls_server.get_last_access_token()!r}." + ) + + def test_distinct_tokens_on_sequential_connects(self, mock_tls_server): + """Two connects with owned buffers must not clobber each other's token. + + PR #596 hardened the fix with per-attribute owned buffers. Exercising two + connects with different tokens ensures each Connection-owned buffer yields + its own token byte-for-byte. + + The access token is a *deferred* ODBC connect attribute and is not part of + the connection-pool key, so two connects sharing one connection string + would be served by the same pooled connection and the second token would + never be sent. ``mssql_python`` auto-enables pooling, so we deliberately + target two distinct mock servers: different ``Server=host:port`` values + give distinct pool keys and force two real logins, one carrying each + token. + """ + second_server = mssql_mock_tds.PyMockTdsServer(port=0, tls=True) + second_server.start() + try: + first = f"first_{secrets.token_hex(16)}" + second = f"second_{secrets.token_hex(16)}" + + _connect_with_token(mock_tls_server, first) + _connect_with_token(second_server, second) + + assert mock_tls_server.has_received_token( + first + ), f"First server did not receive the first token {first!r}." + assert second_server.has_received_token( + second + ), f"Second server did not receive the second token {second!r}." + + # Each connection's owned buffer must carry only its own token: a + # clobber (the #596 signature) would leak one token onto the other + # server. + assert not mock_tls_server.has_received_token(second), ( + "First server unexpectedly received the second token; the " + "per-connection token buffers were clobbered (#596 signature)." + ) + assert not second_server.has_received_token(first), ( + "Second server unexpectedly received the first token; the " + "per-connection token buffers were clobbered (#596 signature)." + ) + finally: + second_server.stop()