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..f827c9600 --- /dev/null +++ b/eng/pipelines/steps/install-mock-tds.yml @@ -0,0 +1,69 @@ +# Step template: Install mssql-mock-tds (in-process mock TDS server) from the +# public mssql-rs_Public Azure Artifacts PyPI feed. +# +# 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). +# Without the package the test skips. The backing scripts are best-effort: if a +# compatible wheel is ever missing or the sandbox feed is unavailable, they emit +# a pipeline warning and succeed, so the test simply skips on those legs. +# +# 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: run the PowerShell script directly on the host +- ${{ if eq(parameters.platform, 'windows') }}: + - task: PowerShell@2 + displayName: 'Install mssql-mock-tds${{ parameters.displaySuffix }}' + inputs: + targetType: 'filePath' + filePath: 'eng/scripts/install-mock-tds.ps1' + +# Unix host (macOS, Linux without container) +- ${{ if eq(parameters.platform, 'unix') }}: + - script: | + chmod +x eng/scripts/install-mock-tds.sh + ./eng/scripts/install-mock-tds.sh + 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 }} + chmod +x eng/scripts/install-mock-tds.sh + ./eng/scripts/install-mock-tds.sh + " + displayName: 'Install mssql-mock-tds in ${{ parameters.containerName }}${{ parameters.displaySuffix }}' diff --git a/eng/scripts/install-mock-tds.ps1 b/eng/scripts/install-mock-tds.ps1 new file mode 100644 index 000000000..941b4ffbc --- /dev/null +++ b/eng/scripts/install-mock-tds.ps1 @@ -0,0 +1,62 @@ +<# +.SYNOPSIS + Installs the mssql-mock-tds in-process TDS server (plus cryptography, used to + mint the throwaway TLS identity it needs) from the public mssql-rs_Public + Azure Artifacts PyPI feed so that tests/test_024_mock_tds_fedauth.py runs in + CI instead of skipping. + +.DESCRIPTION + The package is currently published only as a dev pre-release on a sandbox + feed. To keep pipeline legs green where no compatible wheel exists, a failed + install is reported as a pipeline *warning* (not an error): the test then + skips cleanly. + + The package version is read from eng/versions/mssql-mock-tds.version. + +.PARAMETER FeedUrl + The PyPI-format index URL for the mssql-rs_Public feed. Public -- no auth. +#> + +param( + [string]$FeedUrl = "https://pkgs.dev.azure.com/sqlclientdrivers/public/_packaging/mssql-rs_Public/pypi/simple/" +) + +# Best-effort install: never fail the pipeline leg over a sandbox-feed dependency. +$ErrorActionPreference = 'Continue' +$ScriptDir = $PSScriptRoot +$RepoRoot = (Get-Item "$ScriptDir\..\..").FullName + +function Write-PipelineWarning([string]$Message) { + # Surface as an Azure DevOps pipeline warning while keeping the leg green. + Write-Host "##vso[task.logissue type=warning]$Message" + Write-Host "WARNING: $Message" +} + +$versionFile = Join-Path $RepoRoot "eng\versions\mssql-mock-tds.version" +if (-not (Test-Path $versionFile)) { + Write-PipelineWarning "Version file not found: $versionFile -- skipping mssql-mock-tds install." + exit 0 +} +$packageVersion = (Get-Content $versionFile -Raw).Trim() +if (-not $packageVersion) { + Write-PipelineWarning "Version file is empty: $versionFile -- skipping mssql-mock-tds install." + exit 0 +} + +Write-Host "=== Install mssql-mock-tds ($packageVersion) from $FeedUrl ===" + +& python -m pip install --extra-index-url $FeedUrl "mssql-mock-tds==$packageVersion" cryptography +if ($LASTEXITCODE -ne 0) { + Write-PipelineWarning "Could not install mssql-mock-tds==$packageVersion (no compatible wheel on this platform or feed unavailable) -- the mock TDS test will skip." + exit 0 +} + +Write-Host "Verifying import..." +& python -c "import mssql_mock_tds; print('mssql_mock_tds import OK')" +if ($LASTEXITCODE -ne 0) { + Write-PipelineWarning "mssql-mock-tds installed but failed to import -- the mock TDS test will skip." + exit 0 +} + +Write-Host "=== mssql-mock-tds installed successfully ===" +exit 0 diff --git a/eng/scripts/install-mock-tds.sh b/eng/scripts/install-mock-tds.sh new file mode 100644 index 000000000..ab98adf68 --- /dev/null +++ b/eng/scripts/install-mock-tds.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Installs the mssql-mock-tds in-process TDS server (plus cryptography, used to +# mint the throwaway TLS identity it needs) from the public mssql-rs_Public +# Azure Artifacts PyPI feed so that tests/test_024_mock_tds_fedauth.py runs in CI +# instead of skipping. +# +# The package is currently published as a dev pre-release on a sandbox feed and +# ships wheels for linux glibc (x86_64/aarch64), musllinux/Alpine +# (x86_64/aarch64), macOS universal2, and Windows (amd64/arm64). To keep +# pipeline legs green if a compatible wheel is ever missing or the sandbox feed +# is unavailable, a failed install is reported as a pipeline *warning* (not an +# error): the test then skips cleanly. +# +# The package version is read from eng/versions/mssql-mock-tds.version (required). +# +# Usage: +# ./install-mock-tds.sh [--feed-url URL] + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +PYTHON="${PYTHON:-$(command -v python || command -v python3)}" + +# Public PyPI-format index for the mssql-rs_Public feed (no auth required). +FEED_URL="${FEED_URL:-https://pkgs.dev.azure.com/sqlclientdrivers/public/_packaging/mssql-rs_Public/pypi/simple/}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --feed-url) FEED_URL="$2"; shift 2 ;; + *) echo "Unknown argument: $1"; exit 1 ;; + esac +done + +warn() { + # Surface as an Azure DevOps pipeline warning while keeping the leg green. + echo "##vso[task.logissue type=warning]$1" + echo "WARNING: $1" +} + +version_file="$REPO_ROOT/eng/versions/mssql-mock-tds.version" +if [ ! -f "$version_file" ]; then + warn "Version file not found: $version_file -- skipping mssql-mock-tds install." + exit 0 +fi +PACKAGE_VERSION=$(tr -d '[:space:]' < "$version_file") +if [ -z "$PACKAGE_VERSION" ]; then + warn "Version file is empty: $version_file -- skipping mssql-mock-tds install." + exit 0 +fi + +echo "=== Install mssql-mock-tds ($PACKAGE_VERSION) from $FEED_URL ===" + +if "$PYTHON" -m pip install \ + --extra-index-url "$FEED_URL" \ + "mssql-mock-tds==$PACKAGE_VERSION" \ + cryptography; then + echo "Verifying import..." + if "$PYTHON" -c "import mssql_mock_tds; print('mssql_mock_tds import OK')"; then + echo "=== mssql-mock-tds installed successfully ===" + exit 0 + fi + warn "mssql-mock-tds installed but failed to import -- the mock TDS test will skip." + exit 0 +fi + +warn "Could not install mssql-mock-tds==$PACKAGE_VERSION (no compatible wheel on this platform or feed unavailable) -- the mock TDS test will skip." +exit 0 diff --git a/eng/versions/mssql-mock-tds.version b/eng/versions/mssql-mock-tds.version new file mode 100644 index 000000000..8afe319d5 --- /dev/null +++ b/eng/versions/mssql-mock-tds.version @@ -0,0 +1 @@ +0.1.0.dev20260701159015 diff --git a/tests/test_024_mock_tds_fedauth.py b/tests/test_024_mock_tds_fedauth.py new file mode 100644 index 000000000..2e9908484 --- /dev/null +++ b/tests/test_024_mock_tds_fedauth.py @@ -0,0 +1,317 @@ +""" +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 on owned buffers must not clobber each other's token. + + PR #596 hardened the fix with per-attribute owned buffers. Exercising + two sequential connects with different tokens ensures both tokens are + recorded byte-for-byte without one connection invalidating the other's + buffer ownership. + """ + first = f"first_{secrets.token_hex(16)}" + second = f"second_{secrets.token_hex(16)}" + + _connect_with_token(mock_tls_server, first) + _connect_with_token(mock_tls_server, second) + + # Both exact tokens must be present. We deliberately do NOT assert on + # get_last_access_token(): the mock stores connections in a HashMap keyed + # by client socket address, so "last" reflects non-deterministic hash + # ordering, not connect order. Byte-for-byte receipt of *both* distinct + # tokens is the property that actually guards the #596 owned buffers. + assert mock_tls_server.has_received_token( + first + ), f"Mock server did not receive the first token {first!r}." + assert mock_tls_server.has_received_token( + second + ), f"Mock server did not receive the second token {second!r}." + assert mock_tls_server.connection_count() >= 2, ( + "Expected at least two recorded connections for two sequential " + f"connects, got {mock_tls_server.connection_count()}." + )