Skip to content

Fix WinHttpProvider caching a null WinHTTP session permanently - #1010

Merged
Andy McCalib (amccalib) merged 1 commit into
mainfrom
user/amccalib/fix-winhttp-null-session-cache
Aug 4, 2026
Merged

Fix WinHttpProvider caching a null WinHTTP session permanently#1010
Andy McCalib (amccalib) merged 1 commit into
mainfrom
user/amccalib/fix-winhttp-null-session-cache

Conversation

@amccalib

@amccalib Andy McCalib (amccalib) commented Aug 4, 2026

Copy link
Copy Markdown
Member

Problem

WinHttpProvider::GetHSession() could return a null HINTERNET as a successful Result, and then cache it permanently.

Result<T>'s single-argument constructor is documented as "Construct successful result" (Source/Common/Result.h:13-14), so return hSession; with a null handle produces hr == S_OK. Both call sites check only RETURN_IF_FAILED(getHSessionResult.hr) and never null-check the payload, so the null was passed straight to WinHttpConnection::Initialize.

The cache makes it permanent:

  • m_hSessions is keyed only by securityProtocolFlags.
  • It is cleared only in Suspend(), which never runs on desktop platforms.
  • Once a null is stored, every later call short-circuits on the m_hSessions.find() early return.

So a single transient failure poisons the provider for the lifetime of the process, and does so silently — the short-circuit path emits no trace output whatsoever.

Why this is reachable in practice

WinHttpSetOption(WINHTTP_OPTION_SECURE_PROTOCOLS) fails with ERROR_ACCESS_DENIED whenever the session was opened with WINHTTP_FLAG_SECURE_DEFAULTS and the requested mask includes TLS 1.0/1.1 — and the Win32 default mask (winhttp_provider.cpp:306-309) does exactly that. SECURE_DEFAULTS requires TLS 1.2+ and rejects attempts to re-enable older protocols.

I observed this on stock Windows 11 with no fault injection at all. That means the close-and-reopen fallback is the normal path for every HTTPS session, not a rare edge case — so only the second WinHttpOpen has to fail to poison the cache.

Changes

All three are in GetHSession() and are the same defect family — a failed or null session being treated as success:

  1. Return on fallback-open failure. When the fallback WinHttpOpen(WINHTTP_FLAG_ASYNC) returns null, return the error instead of warning and falling through to cache the null. This is the actual fix for the bug above.
  2. Only read GetLastError() after a failure. It was read unconditionally after WinHttpOpen; its value is undefined after success. A stale ERROR_INVALID_PARAMETER could open a second session over a successful first one and leak the first handle.
  3. Map a zero GetLastError() to E_FAIL on the initial-open failure path. HRESULT_FROM_WIN32(0) is S_OK, which selects the Result(HRESULT) "failed result (no payload)" overload while reporting success — Payload() then asserts and ExtractPayload() is undefined.

Verification

A standalone Win32 harness injects WinHttpOpen / WinHttpSetOption failures by patching the harness's own import address table, so libHttpClient itself compiles and runs completely unmodified — no test hooks or #ifdefs in the library.

Phase Before After
1 — faults injected fails (expected) fails (expected)
2 — all faults removed, healthy network, x3 fails 3/3 succeeds 3/3

Before the fix, phase 2 produced zero trace output: the cached null short-circuits before any logging can happen. That silent-failure signature is what makes this so hard to diagnose from customer logs.

Built and compiles clean in both libHttpClient.143.Win32.C and libHttpClient.143.GDK.C.

Deliberately out of scope

Two adjacent issues found while investigating, left for separate changes to keep this reviewable:

  • TLS downgrade. The reopen drops WINHTTP_FLAG_SECURE_DEFAULTS and re-permits TLS 1.0/1.1, and also loses the "no TLS fallback" behavior. The better fix is to skip SECURE_PROTOCOLS entirely when the session already opened with SECURE_DEFAULTS.
  • Cache key is incomplete. m_hSessions is keyed only by securityProtocolFlags, but session setup also depends on isHttps — and on Win32 GetSecurityInformation() returns the same constant mask for both schemes. An HTTP request that populates the entry first will hand a later HTTPS request a session that never had secure protocols configured, making behavior order-dependent.

GetHSession() could return a null HINTERNET as a *successful* Result, which
was then cached in m_hSessions forever. Result<T>'s single-argument
constructor is the "construct successful result" overload, so callers'
RETURN_IF_FAILED(result.hr) checks passed and a null session was handed to
WinHttpConnection::Initialize. Because m_hSessions is keyed only by
securityProtocolFlags and is cleared only by Suspend() (which never runs on
desktop platforms), one transient failure poisoned the provider for the
lifetime of the process. Every later request short-circuited on the cached
null and failed silently, emitting no trace output at all.

Three related fixes, all in GetHSession():

1. When the fallback WinHttpOpen(WINHTTP_FLAG_ASYNC) returns null, return the
   error instead of warning and falling through to cache the null.

2. Only inspect GetLastError() after WinHttpOpen has actually failed. It was
   read unconditionally, so a stale ERROR_INVALID_PARAMETER from an unrelated
   call could cause a second session to be opened over a successfully opened
   first one, leaking the first handle.

3. Map a zero GetLastError() to E_FAIL on the initial open failure path.
   HRESULT_FROM_WIN32(0) is S_OK, which would construct a "successful" Result
   with no payload via the Result(HRESULT) overload, making Payload() assert
   and ExtractPayload() undefined.

Verified with a standalone Win32 harness that injects WinHttpOpen /
WinHttpSetOption failures via IAT patching, so the library builds unmodified.
Before: after a simulated outage the provider failed every subsequent request
against a healthy network. After: it recovers on the next request.

Note that WinHttpSetOption(WINHTTP_OPTION_SECURE_PROTOCOLS) fails with
ERROR_ACCESS_DENIED on current Windows whenever the session was opened with
WINHTTP_FLAG_SECURE_DEFAULTS and the requested mask includes TLS 1.0/1.1
(which the Win32 default mask does), so the close-and-reopen fallback is the
normal path for HTTPS sessions rather than a rare edge case. That reopen also
drops SECURE_DEFAULTS; addressing that is left to a separate change.
@amccalib
Andy McCalib (amccalib) force-pushed the user/amccalib/fix-winhttp-null-session-cache branch from cbe1cb0 to d94b749 Compare August 4, 2026 20:14
@amccalib
Andy McCalib (amccalib) merged commit 6a44a17 into main Aug 4, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants