[Lldb-commits] [lldb] [llvm] [lldb] Add HTTPS hardening tests for SymbolLocatorSymStore (PR #192274)

Stefan Gränitz via lldb-commits lldb-commits at lists.llvm.org
Fri Apr 17 03:39:14 PDT 2026


https://github.com/weliveindetail updated https://github.com/llvm/llvm-project/pull/192274

>From f49305e6fb7b46182564b609af24fa6e992f12f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Stefan=20Gr=C3=A4nitz?= <stefan.graenitz at gmail.com>
Date: Wed, 15 Apr 2026 13:08:27 +0200
Subject: [PATCH 1/2] [lldb] Add HTTPS hardening tests for
 SymbolLocatorSymStore

---
 .../SymStore/SymbolLocatorSymStore.cpp        |  22 ++-
 .../SymbolLocatorSymStoreProperties.td        |   3 +
 lldb/test/API/symstore/TestSymStore.py        | 177 +++++++++++++++++-
 llvm/include/llvm/HTTP/HTTPClient.h           |   4 +
 llvm/lib/HTTP/CMakeLists.txt                  |   2 +-
 llvm/lib/HTTP/HTTPClient.cpp                  |  66 ++++++-
 6 files changed, 266 insertions(+), 8 deletions(-)

diff --git a/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStore.cpp b/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStore.cpp
index 1344232917b33..86315c0305ccf 100644
--- a/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStore.cpp
+++ b/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStore.cpp
@@ -58,6 +58,25 @@ class PluginProperties : public Properties {
     m_collection_sp->GetPropertyAtIndexAsArgs(ePropertySymStoreURLs, urls);
     return urls;
   }
+
+  std::optional<std::string> GetTLSCertFingerprint() const {
+    OptionValueString *s =
+        m_collection_sp->GetPropertyAtIndexAsOptionValueString(
+            ePropertyTLSCertFingerprint);
+    if (!s)
+      return {};
+    llvm::StringRef val = s->GetCurrentValueAsRef();
+    if (val.empty())
+      return {};
+    if (val.size() != 64 || !llvm::all_of(val, llvm::isHexDigit)) {
+      Debugger::ReportWarning(llvm::formatv(
+          "plugin.symbol-locator.symstore.tls-cert-fingerprint: expected a "
+          "64-character hex string (SHA-256), but got '{0}', ignoring",
+          val));
+      return {};
+    }
+    return val.lower();
+  }
 };
 
 } // namespace
@@ -66,7 +85,6 @@ static PluginProperties &GetGlobalPluginProperties() {
   static PluginProperties g_settings;
   return g_settings;
 }
-
 SymbolLocatorSymStore::SymbolLocatorSymStore() : SymbolLocator() {}
 
 void SymbolLocatorSymStore::Initialize() {
@@ -184,6 +202,8 @@ RequestFileFromSymStoreServerHTTP(llvm::StringRef base_url, llvm::StringRef key,
       client);
 
   llvm::HTTPRequest request(source_url);
+  request.PinnedCertFingerprint =
+      GetGlobalPluginProperties().GetTLSCertFingerprint();
   if (llvm::Error Err = client.perform(request, Handler)) {
     Debugger::ReportWarning(
         llvm::formatv("failed to download from SymStore '{0}': {1}", source_url,
diff --git a/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStoreProperties.td b/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStoreProperties.td
index 0cd631a80b90b..7d9b1cbae5f8e 100644
--- a/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStoreProperties.td
+++ b/lldb/source/Plugins/SymbolLocator/SymStore/SymbolLocatorSymStoreProperties.td
@@ -4,4 +4,7 @@ let Definition = "symbollocatorsymstore", Path = "plugin.symbol-locator.symstore
   def SymStoreURLs : Property<"urls", "Array">,
     ElementType<"String">,
     Desc<"List of local symstore directories to query for symbols">;
+  def TLSCertFingerprint : Property<"tls-cert-fingerprint", "String">,
+    DefaultStringValue<"">,
+    Desc<"SHA-256 fingerprint (lowercase hex, no separators) of the symbol server's TLS certificate. When set, LLDB will accept an HTTPS server that presents this self-signed certificate even if it is not trusted by the system certificate store (Windows only).">;
 }
diff --git a/lldb/test/API/symstore/TestSymStore.py b/lldb/test/API/symstore/TestSymStore.py
index b5c8fbb58324f..34c0ac186f49b 100644
--- a/lldb/test/API/symstore/TestSymStore.py
+++ b/lldb/test/API/symstore/TestSymStore.py
@@ -1,7 +1,11 @@
+import datetime
 import http.server
+import ipaddress
 import os
 import shutil
 import socketserver
+import ssl
+import sys
 import threading
 from functools import partial
 
@@ -90,6 +94,119 @@ def __exit__(self, *exc_info):
             self._thread.join()
 
 
+class HTTPSServer:
+    """
+    Context Manager to serve a local directory tree via HTTPS.
+    """
+
+    class ErrorAwareTCPServer(socketserver.ThreadingTCPServer):
+        """TCP layer that will suppress errors of the given type."""
+
+        def __init__(self, address, handler, err):
+            self.err = err
+            super().__init__(address, handler)
+
+        def handle_error(self, request, client_address):
+            if isinstance(sys.exc_info()[1], self.err):
+                return
+            super().handle_error(request, client_address)
+
+    def __init__(self, dir=None, handler=None, cert=None):
+        if handler is None:
+            handler = partial(http.server.SimpleHTTPRequestHandler, directory=dir)
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ctx.load_cert_chain(cert.file, cert.key_file)
+        address = ("localhost", 0)  # auto-select free port
+        self._server = self.ErrorAwareTCPServer(address, handler, ssl.SSLError)
+        self._server.socket = ctx.wrap_socket(self._server.socket, server_side=True)
+        self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
+
+    def __enter__(self):
+        self._thread.start()
+        host, port = self._server.server_address
+        return f"https://{host}:{port}"
+
+    def __exit__(self, *exc_info):
+        if self._server:
+            self._server.shutdown()
+            self._server.server_close()
+        if self._thread:
+            self._thread.join()
+
+
+# FIXME: Drop this once the package is rolled out on all build bots
+def skipUnlessPackageAvailable(name):
+    available = True
+    try:
+        __import__(name)
+    except ImportError:
+        available = False
+    import unittest
+
+    return unittest.skipUnless(available, f"requires the '{name}' package")
+
+
+class SelfSignedCert:
+    """
+    Self-signed cert/key pair for localhost.
+    """
+
+    def __init__(self, tmpdir):
+        from cryptography import x509
+        from cryptography.x509.oid import NameOID
+        from cryptography.hazmat.primitives import hashes, serialization
+        from cryptography.hazmat.primitives.asymmetric import rsa
+
+        key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+        name = x509.Name([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)
+            .not_valid_after(now + datetime.timedelta(days=1))
+            .add_extension(
+                x509.SubjectAlternativeName(
+                    [
+                        x509.DNSName("localhost"),
+                        x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
+                    ]
+                ),
+                critical=False,
+            )
+            .sign(key, hashes.SHA256())
+        )
+        os.makedirs(tmpdir, exist_ok=False)
+        self.key_file = os.path.join(tmpdir, "server.key")
+        self.file = os.path.join(tmpdir, "server.crt")
+        with open(self.key_file, "wb") as f:
+            f.write(
+                key.private_bytes(
+                    serialization.Encoding.PEM,
+                    serialization.PrivateFormat.TraditionalOpenSSL,
+                    serialization.NoEncryption(),
+                )
+            )
+        with open(self.file, "wb") as f:
+            f.write(cert.public_bytes(serialization.Encoding.PEM))
+        self.fingerprint = cert.fingerprint(hashes.SHA256()).hex()
+
+
+class RedirectHandler(http.server.BaseHTTPRequestHandler):
+    base_url = None
+
+    def do_GET(self):
+        self.send_response(301)
+        self.send_header("Location", self.base_url + self.path)
+        self.end_headers()
+
+    def log_message(self, *args):
+        pass  # suppress request logs
+
+
 class SymStoreTests(TestBase):
     TEST_WITH_PDB_DEBUG_INFO = True
 
@@ -157,8 +274,6 @@ def test_http_not_found(self):
                     warnings = err_file.read().decode()
                 self.assertEqual(warnings, "")
 
-    # TODO: Add test coverage for common HTTPS security scenarios, e.g. self-signed
-    # certs, non-HTTPS redirects, etc.
     def test_http(self):
         """
         Check that breakpoint hits with remote SymStore.
@@ -168,3 +283,61 @@ def test_http(self):
             with HTTPServer(dir) as url:
                 self.runCmd(f"settings set plugin.symbol-locator.symstore.urls {url}")
                 self.try_breakpoint(exe, should_have_loc=True)
+
+    @skipUnlessPackageAvailable("cryptography")
+    def test_https(self):
+        """
+        Check that breakpoint resolves with remote SymStore via HTTPS.
+        """
+        exe, sym = self.build_inferior()
+        with MockedSymStore(self, exe, sym) as symstore_dir:
+            cert = SelfSignedCert(self.getBuildArtifact("cert"))
+            with HTTPSServer(dir=symstore_dir, cert=cert) as https_url:
+                # We accept only the self-signed certificate with this fingerprint
+                self.runCmd(
+                    f"settings set plugin.symbol-locator.symstore.tls-cert-fingerprint {cert.fingerprint}"
+                )
+                self.runCmd(
+                    f"settings set plugin.symbol-locator.symstore.urls {https_url}"
+                )
+                self.try_breakpoint(exe, should_have_loc=True)
+
+    @skipUnlessPackageAvailable("cryptography")
+    def test_https_reject_selfsigned_cert(self):
+        """
+        Check that LLDB rejects an HTTPS server with an untrusted self-signed cert.
+        """
+        exe, sym = self.build_inferior()
+        with MockedSymStore(self, exe, sym) as symstore_dir:
+            cert = SelfSignedCert(self.getBuildArtifact("cert"))
+            with HTTPSServer(dir=symstore_dir, cert=cert) as https_url:
+                # No fingerprint set
+                self.runCmd(
+                    f"settings set plugin.symbol-locator.symstore.urls {https_url}"
+                )
+                self.try_breakpoint(exe, should_have_loc=False)
+                # Incorrect fingerprint set
+                bogus = "DEADBEEFCAFEBABE"
+                self.runCmd(
+                    f"settings set plugin.symbol-locator.symstore.tls-cert-fingerprint {bogus}{bogus}{bogus}{bogus}"
+                )
+                self.try_breakpoint(exe, should_have_loc=False)
+
+    @skipUnlessPackageAvailable("cryptography")
+    def test_https_reject_redirect_http(self):
+        """
+        Check that LLDB does not retrieve symbols from servers that redirect with security downgrades.
+        """
+        exe, sym = self.build_inferior()
+        with MockedSymStore(self, exe, sym) as symstore_dir:
+            with HTTPServer(symstore_dir) as http_url:
+                RedirectHandler.base_url = http_url
+                cert = SelfSignedCert(self.getBuildArtifact("cert"))
+                with HTTPSServer(handler=RedirectHandler, cert=cert) as https_url:
+                    self.runCmd(
+                        f"settings set plugin.symbol-locator.symstore.urls {https_url}"
+                    )
+                    self.runCmd(
+                        f"settings set plugin.symbol-locator.symstore.tls-cert-fingerprint {cert.fingerprint}"
+                    )
+                    self.try_breakpoint(exe, should_have_loc=False)
diff --git a/llvm/include/llvm/HTTP/HTTPClient.h b/llvm/include/llvm/HTTP/HTTPClient.h
index a2ba4251d35ce..a3db41c1a9034 100644
--- a/llvm/include/llvm/HTTP/HTTPClient.h
+++ b/llvm/include/llvm/HTTP/HTTPClient.h
@@ -21,6 +21,7 @@
 #include "llvm/Support/MemoryBuffer.h"
 
 #include <chrono>
+#include <optional>
 
 namespace llvm {
 
@@ -31,7 +32,10 @@ struct HTTPRequest {
   SmallString<128> Url;
   SmallVector<std::string, 0> Headers;
   HTTPMethod Method = HTTPMethod::GET;
+  // Follow redirects without security downgrades.
   bool FollowRedirects = true;
+  // Allow self-signed TLS certificates with this SHA-256 (WinHTTP only).
+  std::optional<std::string> PinnedCertFingerprint;
   HTTPRequest(StringRef Url);
 };
 
diff --git a/llvm/lib/HTTP/CMakeLists.txt b/llvm/lib/HTTP/CMakeLists.txt
index e3b71434333a1..9dab6ad200066 100644
--- a/llvm/lib/HTTP/CMakeLists.txt
+++ b/llvm/lib/HTTP/CMakeLists.txt
@@ -10,7 +10,7 @@ endif()
 
 # Use WinHTTP on Windows
 if (WIN32)
-  set(imported_libs ${imported_libs} winhttp.lib)
+  set(imported_libs ${imported_libs} winhttp.lib crypt32.lib)
 endif()
 
 # This is no component library so the LLVM dylib does not gain a dependency on curl
diff --git a/llvm/lib/HTTP/HTTPClient.cpp b/llvm/lib/HTTP/HTTPClient.cpp
index 8517c8cc7c7a0..c69aecfa7d619 100644
--- a/llvm/lib/HTTP/HTTPClient.cpp
+++ b/llvm/lib/HTTP/HTTPClient.cpp
@@ -33,7 +33,8 @@ HTTPRequest::HTTPRequest(StringRef Url) { this->Url = Url.str(); }
 
 bool operator==(const HTTPRequest &A, const HTTPRequest &B) {
   return A.Url == B.Url && A.Method == B.Method &&
-         A.FollowRedirects == B.FollowRedirects;
+         A.FollowRedirects == B.FollowRedirects &&
+         A.PinnedCertFingerprint == B.PinnedCertFingerprint;
 }
 
 HTTPResponseHandler::~HTTPResponseHandler() = default;
@@ -144,8 +145,13 @@ unsigned HTTPClient::responseCode() {
 #else
 
 #ifdef _WIN32
+
+// We cannot sort these headers alphabetically.
+// clang-format off
 #include <windows.h>
+#include <wincrypt.h>
 #include <winhttp.h>
+// clang-format on
 
 namespace {
 
@@ -236,6 +242,41 @@ void HTTPClient::setTimeout(std::chrono::milliseconds Timeout) {
   }
 }
 
+static Error VerifyTLSCertWinHTTP(HINTERNET RequestHandle,
+                                  const std::string &PinnedFingerprint) {
+  // Decode the expected fingerprint from hex into binary.
+  BYTE Expected[32];
+  DWORD ExpectedSize = sizeof(Expected);
+  if (!CryptStringToBinaryA(
+          PinnedFingerprint.c_str(), (DWORD)PinnedFingerprint.size(),
+          CRYPT_STRING_HEXRAW, Expected, &ExpectedSize, nullptr, nullptr))
+    return createStringError(errc::invalid_argument,
+                             "Invalid certificate fingerprint format");
+
+  // Retrieve the server certificate and compute its SHA-256 hash.
+  PCCERT_CONTEXT CertCtx = nullptr;
+  DWORD CertCtxSize = sizeof(CertCtx);
+  if (!WinHttpQueryOption(RequestHandle, WINHTTP_OPTION_SERVER_CERT_CONTEXT,
+                          &CertCtx, &CertCtxSize))
+    return createStringError(errc::io_error,
+                             "Failed to retrieve server certificate");
+
+  std::array<BYTE, 32> Actual;
+  DWORD ActualSize = Actual.size();
+  bool GotHash = CertGetCertificateContextProperty(
+      CertCtx, CERT_SHA256_HASH_PROP_ID, Actual.data(), &ActualSize);
+  CertFreeCertificateContext(CertCtx);
+  if (!GotHash)
+    return createStringError(errc::io_error,
+                             "Failed to compute certificate fingerprint");
+
+  if (memcmp(Actual.data(), Expected, Actual.size()) != 0)
+    return createStringError(errc::permission_denied,
+                             "Certificate fingerprint mismatch");
+
+  return Error::success();
+}
+
 Error HTTPClient::perform(const HTTPRequest &Request,
                           HTTPResponseHandler &Handler) {
   if (Request.Method != HTTPMethod::GET)
@@ -272,6 +313,14 @@ Error HTTPClient::perform(const HTTPRequest &Request,
                         &SecureProtocols, sizeof(SecureProtocols)))
     return createStringError(errc::io_error, "Failed to set secure protocols");
 
+  // Disallow redirects in general or HTTPS to HTTP only.
+  DWORD RedirectPolicy = WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP;
+  if (!Request.FollowRedirects)
+    RedirectPolicy = WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
+  if (!WinHttpSetOption(Session->SessionHandle, WINHTTP_OPTION_REDIRECT_POLICY,
+                        &RedirectPolicy, sizeof(RedirectPolicy)))
+    return createStringError(errc::io_error, "Failed to set redirect policy");
+
   // Use HTTP/2 if available
   DWORD EnableHttp2 = WINHTTP_PROTOCOL_FLAG_HTTP2;
   WinHttpSetOption(Session->SessionHandle, WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL,
@@ -296,6 +345,7 @@ Error HTTPClient::perform(const HTTPRequest &Request,
   if (!Session->RequestHandle)
     return createStringError(errc::io_error, "Failed to open HTTP request");
 
+  DWORD SecurityFlags = 0;
   if (Secure) {
     // Enforce checks that certificate wasn't revoked.
     DWORD EnableRevocationChecks = WINHTTP_ENABLE_SSL_REVOCATION;
@@ -305,9 +355,11 @@ Error HTTPClient::perform(const HTTPRequest &Request,
       return createStringError(
           errc::io_error, "Failed to enable certificate revocation checks");
 
-    // Explicitly enforce default validation. This protects against insecure
-    // overrides like SECURITY_FLAG_IGNORE_UNKNOWN_CA.
-    DWORD SecurityFlags = 0;
+    // Bypass certificate chain validation with pinned certificates so
+    // that self-signed certificates are accepted at the WinHTTP level. Manual
+    // verification happens right after receiving the response.
+    if (Request.PinnedCertFingerprint)
+      SecurityFlags = (SecurityFlags | SECURITY_FLAG_IGNORE_UNKNOWN_CA);
     if (!WinHttpSetOption(Session->RequestHandle, WINHTTP_OPTION_SECURITY_FLAGS,
                           &SecurityFlags, sizeof(SecurityFlags)))
       return createStringError(errc::io_error,
@@ -333,6 +385,12 @@ Error HTTPClient::perform(const HTTPRequest &Request,
   if (!WinHttpReceiveResponse(Session->RequestHandle, nullptr))
     return createStringError(errc::io_error, "Failed to receive HTTP response");
 
+  // Verify the server certificate fingerprint if one was pinned.
+  if ((SecurityFlags & SECURITY_FLAG_IGNORE_UNKNOWN_CA) != 0)
+    if (Error Err = VerifyTLSCertWinHTTP(Session->RequestHandle,
+                                         *Request.PinnedCertFingerprint))
+      return Err;
+
   // Get response code
   DWORD CodeSize = sizeof(Session->ResponseCode);
   if (!WinHttpQueryHeaders(Session->RequestHandle,

>From 2490b5ca55e97c2c734026c2abcb573c041e1171 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Stefan=20Gr=C3=A4nitz?= <stefan.graenitz at gmail.com>
Date: Fri, 17 Apr 2026 12:37:19 +0200
Subject: [PATCH 2/2] Move skipUnlessPackageAvailable() to
 lldbsuite/test/decorators.py

---
 lldb/packages/Python/lldbsuite/test/decorators.py | 11 +++++++++++
 lldb/test/API/symstore/TestSymStore.py            | 12 ------------
 2 files changed, 11 insertions(+), 12 deletions(-)

diff --git a/lldb/packages/Python/lldbsuite/test/decorators.py b/lldb/packages/Python/lldbsuite/test/decorators.py
index 1ea984aa235cb..bc2b4e75ac067 100644
--- a/lldb/packages/Python/lldbsuite/test/decorators.py
+++ b/lldb/packages/Python/lldbsuite/test/decorators.py
@@ -1366,3 +1366,14 @@ def can_build_and_run_arm64e():
         return None
 
     return skipTestIfFn(can_build_and_run_arm64e)(func)
+
+
+def skipUnlessPackageAvailable(name):
+    """Skip the test case if the named package is not available on the system."""
+    available = True
+    try:
+        __import__(name)
+    except ImportError:
+        available = False
+
+    return unittest.skipUnless(available, f"requires the '{name}' package")
diff --git a/lldb/test/API/symstore/TestSymStore.py b/lldb/test/API/symstore/TestSymStore.py
index 34c0ac186f49b..a249d5cf9ac54 100644
--- a/lldb/test/API/symstore/TestSymStore.py
+++ b/lldb/test/API/symstore/TestSymStore.py
@@ -134,18 +134,6 @@ def __exit__(self, *exc_info):
             self._thread.join()
 
 
-# FIXME: Drop this once the package is rolled out on all build bots
-def skipUnlessPackageAvailable(name):
-    available = True
-    try:
-        __import__(name)
-    except ImportError:
-        available = False
-    import unittest
-
-    return unittest.skipUnless(available, f"requires the '{name}' package")
-
-
 class SelfSignedCert:
     """
     Self-signed cert/key pair for localhost.



More information about the lldb-commits mailing list