diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index cd3a45fa..7342c0f5 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -26,6 +26,18 @@ // Macro definitions //------------------------------------------------------------------------------------------------- +#ifdef _WIN32 +// Constrained DLL search flags (Windows 8+ / Win7 + KB2533623). Defined +// defensively in case the build's SDK headers gate them behind an older +// _WIN32_WINNT than this project targets. +#ifndef LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR +#define LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR 0x00000100 +#endif +#ifndef LOAD_LIBRARY_SEARCH_DEFAULT_DIRS +#define LOAD_LIBRARY_SEARCH_DEFAULT_DIRS 0x00001000 +#endif +#endif // _WIN32 + #ifndef SQL_C_DATE #define SQL_C_DATE (9) #endif @@ -1047,9 +1059,17 @@ DriverHandle LoadDriverLibrary(const std::string& driverPath) { // fs::path::c_str() returns wchar_t* on Windows with correct encoding namespace fs = std::filesystem; fs::path pathObj(driverPath); - HMODULE handle = LoadLibraryW(pathObj.c_str()); + // Resolve the vendored driver's dependencies with a constrained search + // path. LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR adds the driver's own folder for + // its dependency lookups, and LOAD_LIBRARY_SEARCH_DEFAULT_DIRS restricts the + // rest of the search to System32 and the application directory -- excluding + // the current working directory and %PATH%, which the legacy LoadLibraryW + // search order would otherwise include. + HMODULE handle = LoadLibraryExW( + pathObj.c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DEFAULT_DIRS | LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR); if (!handle) { - LOG("LoadDriverLibrary: LoadLibraryW failed for path='%s' - %s", driverPath.c_str(), + LOG("LoadDriverLibrary: LoadLibraryExW failed for path='%s' - %s", driverPath.c_str(), GetLastErrorMessage().c_str()); ThrowStdException("Failed to load library: " + driverPath); } @@ -1199,7 +1219,9 @@ DriverHandle LoadDriverOrThrowException() { fs::path authDllPath = dllDir / "mssql-auth.dll"; if (fs::exists(authDllPath)) { // Use fs::path::c_str() which returns wchar_t* on Windows with proper encoding - HMODULE hAuth = LoadLibraryW(authDllPath.c_str()); + HMODULE hAuth = LoadLibraryExW( + authDllPath.c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DEFAULT_DIRS | LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR); if (hAuth) { LOG("LoadDriverOrThrowException: mssql-auth.dll loaded " "successfully from '%s'", diff --git a/tests/test_026_windows_dll_search.py b/tests/test_026_windows_dll_search.py new file mode 100644 index 00000000..5a57bda6 --- /dev/null +++ b/tests/test_026_windows_dll_search.py @@ -0,0 +1,59 @@ +""" +Regression guard for the Windows package-local DLL load path. + +The vendored ODBC driver (``msodbcsql18.dll``) and Entra auth DLL +(``mssql-auth.dll``) must be loaded with a constrained search path -- +``LoadLibraryExW`` with ``LOAD_LIBRARY_SEARCH_DEFAULT_DIRS | +LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR`` -- rather than the legacy ``LoadLibraryW`` +search order, which also consults the current working directory and ``%PATH%`` +when resolving those DLLs' dependencies. + +This is a source-contract test on purpose. The restriction only manifests at +DLL-resolution time on Windows, which cannot be observed without dropping a +file on disk; a success-path "does it still load" check passes on the +unhardened code too (any host with ``msvcp140.dll`` in System32), so it guards +nothing. Asserting the loader keeps using the constrained API is the +deterministic, platform-independent way to fail if the hardening is reverted. +""" + +import re +from pathlib import Path + +_LOADER_SRC = Path(__file__).resolve().parents[1] / "mssql_python" / "pybind" / "ddbc_bindings.cpp" + + +def _code_without_comments(text): + # Drop // line comments so prose that mentions LoadLibraryW is not matched. + return "\n".join(re.sub(r"//.*", "", line) for line in text.splitlines()) + + +def test_loader_source_present(): + assert _LOADER_SRC.is_file(), f"loader source not found at {_LOADER_SRC}" + + +def test_no_unhardened_loadlibrary_call(): + code = _code_without_comments(_LOADER_SRC.read_text(encoding="utf-8")) + # A bare LoadLibraryW(...) call resolves dependencies via the legacy search + # order, which includes the current directory and %PATH%. + assert re.search(r"\bLoadLibraryW\s*\(", code) is None, ( + "ddbc_bindings.cpp contains a bare LoadLibraryW call; the vendored " + "driver and auth DLLs must be loaded with LoadLibraryExW and the " + "constrained search flags instead." + ) + + +def test_driver_and_auth_loads_use_constrained_search(): + code = _code_without_comments(_LOADER_SRC.read_text(encoding="utf-8")) + # Match each LoadLibraryExW( ... ); call and require BOTH flags inside that + # call's own argument list -- not merely somewhere in the file (the flag + # names also appear in the #define block, so a file-wide substring check + # would still pass if a call's flags were replaced with 0). + calls = re.findall(r"LoadLibraryExW\s*\(.*?\)\s*;", code, re.DOTALL) + assert len(calls) >= 2, "expected LoadLibraryExW for both the driver and the auth DLL loads" + for call in calls: + assert "LOAD_LIBRARY_SEARCH_DEFAULT_DIRS" in call, ( + "a LoadLibraryExW call is missing LOAD_LIBRARY_SEARCH_DEFAULT_DIRS: " + call + ) + assert "LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR" in call, ( + "a LoadLibraryExW call is missing LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR: " + call + )