|
| 1 | +""" |
| 2 | +Regression guard for the Windows package-local DLL load path. |
| 3 | +
|
| 4 | +The vendored ODBC driver (``msodbcsql18.dll``) and Entra auth DLL |
| 5 | +(``mssql-auth.dll``) must be loaded with a constrained search path -- |
| 6 | +``LoadLibraryExW`` with ``LOAD_LIBRARY_SEARCH_APPLICATION_DIR | |
| 7 | +LOAD_LIBRARY_SEARCH_SYSTEM32 | LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR`` -- rather than |
| 8 | +the legacy ``LoadLibraryW`` search order, which also consults the current |
| 9 | +working directory and ``%PATH``. The broader ``LOAD_LIBRARY_SEARCH_DEFAULT_DIRS`` |
| 10 | +is intentionally avoided because it also searches user DLL directories |
| 11 | +(registered process-wide via ``AddDllDirectory`` / ``SetDllDirectory``). |
| 12 | +
|
| 13 | +This is a source-contract test on purpose. The restriction only manifests at |
| 14 | +DLL-resolution time on Windows, which cannot be observed without dropping a |
| 15 | +file on disk; a success-path "does it still load" check passes on the |
| 16 | +unhardened code too (any host with ``msvcp140.dll`` in System32), so it guards |
| 17 | +nothing. Asserting the loader keeps using the constrained API is the |
| 18 | +deterministic, platform-independent way to fail if the hardening is reverted. |
| 19 | +""" |
| 20 | + |
| 21 | +import re |
| 22 | +from pathlib import Path |
| 23 | + |
| 24 | +_LOADER_SRC = Path(__file__).resolve().parents[1] / "mssql_python" / "pybind" / "ddbc_bindings.cpp" |
| 25 | + |
| 26 | + |
| 27 | +def _code_without_comments(text): |
| 28 | + # Drop // line comments so prose that mentions LoadLibraryW is not matched. |
| 29 | + return "\n".join(re.sub(r"//.*", "", line) for line in text.splitlines()) |
| 30 | + |
| 31 | + |
| 32 | +def test_loader_source_present(): |
| 33 | + assert _LOADER_SRC.is_file(), f"loader source not found at {_LOADER_SRC}" |
| 34 | + |
| 35 | + |
| 36 | +def test_no_unhardened_loadlibrary_call(): |
| 37 | + code = _code_without_comments(_LOADER_SRC.read_text(encoding="utf-8")) |
| 38 | + # A bare LoadLibraryW(...) call resolves dependencies via the legacy search |
| 39 | + # order, which includes the current directory and %PATH%. |
| 40 | + assert re.search(r"\bLoadLibraryW\s*\(", code) is None, ( |
| 41 | + "ddbc_bindings.cpp contains a bare LoadLibraryW call; the vendored " |
| 42 | + "driver and auth DLLs must be loaded with LoadLibraryExW and the " |
| 43 | + "constrained search flags instead." |
| 44 | + ) |
| 45 | + |
| 46 | + |
| 47 | +def test_driver_and_auth_loads_use_constrained_search(): |
| 48 | + code = _code_without_comments(_LOADER_SRC.read_text(encoding="utf-8")) |
| 49 | + # Match each LoadLibraryExW( ... ); call and require the constrained flag |
| 50 | + # set inside that call's own argument list -- not merely somewhere in the |
| 51 | + # file (the flag names also appear in the #define block, so a file-wide |
| 52 | + # substring check would still pass if a call's flags were replaced). |
| 53 | + calls = re.findall(r"LoadLibraryExW\s*\(.*?\)\s*;", code, re.DOTALL) |
| 54 | + assert len(calls) >= 2, "expected LoadLibraryExW for both the driver and the auth DLL loads" |
| 55 | + required = ( |
| 56 | + "LOAD_LIBRARY_SEARCH_APPLICATION_DIR", |
| 57 | + "LOAD_LIBRARY_SEARCH_SYSTEM32", |
| 58 | + "LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR", |
| 59 | + ) |
| 60 | + for call in calls: |
| 61 | + for flag in required: |
| 62 | + assert flag in call, f"a LoadLibraryExW call is missing {flag}: {call}" |
| 63 | + # DEFAULT_DIRS re-introduces LOAD_LIBRARY_SEARCH_USER_DIRS (process-wide |
| 64 | + # AddDllDirectory/SetDllDirectory dirs), which is outside the trusted |
| 65 | + # set; the loads must name the individual directories instead. |
| 66 | + assert "LOAD_LIBRARY_SEARCH_DEFAULT_DIRS" not in call, ( |
| 67 | + "a LoadLibraryExW call uses LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, which " |
| 68 | + "includes user DLL directories: " + call |
| 69 | + ) |
0 commit comments