Back to Guzzle

cURL connection reuse and sharing (contributor reference)

docs/contributing/curl-connection-reuse.md

8.0.035.4 KB
Original Source

cURL connection reuse and sharing (contributor reference)

Guzzle's cURL handlers reuse network connections for performance, and can optionally share them across handlers. Reuse is normally invisible, but it interacts with proxy credentials, TLS client identity, and DNS in ways that have produced real security bugs in libcurl. This document explains, for someone new to the handler code, how connection reuse works, what can go wrong, and the safety rules Guzzle enforces — and why. It assumes general HTTP/TLS familiarity; libcurl and PHP-cURL specifics are introduced as they come up.

The connection-reuse safety code lives on both the 7.x maintenance branches and 8.0; this reference is maintained on 8.0, and 7.x differences are called out where they matter. For which exception type to throw, see exception-guidelines.md.

1. How the cURL handlers send a request

GuzzleHttp\Handler\CurlHandler (synchronous) and CurlMultiHandler (asynchronous) are the built-in cURL handlers. Both delegate to CurlFactory, which builds and pools cURL "easy handles."

A cURL easy handle (CurlHandle) is libcurl's per-transfer object. Creating one is cheap; the expensive thing is the network connection it opens. libcurl keeps connections alive in a connection cache and reuses them for later transfers on the same handle — or across handles, if you share them (§3).

CurlFactory pools easy handles: on release() it calls curl_reset() (which clears per-request options) and returns the handle to a small pool; create() pops a pooled handle and re-applies the next request's options. The key fact: curl_reset() does not close the underlying connection — reuse is the whole point — so a pooled handle can carry a live connection from one request into the next.

The option pipeline is: Guzzle request options (proxy, cert, …) → an internal $conf array of CURLOPT_* => valuecurl_setopt_array(). Low-level options passed through the curl request option are merged in too, but only those on an allow-list (supportedCurlOptions()); options that conflict with Guzzle-managed behavior are rejected. On 8.0 a non-allow-listed or conflicting raw option throws; on 7.x it is deprecated (and 8.0 will reject it). This gate is about option-key availability and known conflicts only. Unless Guzzle documents a specific mitigation, the meaning, safety, and runtime effects of raw cURL option values remain the caller's responsibility.

PHP detail — the \defined() guard. A CURLOPT_* constant is only defined when the linked libcurl/PHP build supports that option, so code that touches an optional option guards with \defined()/\constant(). This is safe by construction: PHP's curl_setopt() never forwards an unknown option integer to libcurl (PHP 8 throws ValueError; PHP 7 silently no-ops), and a constant is registered under the same #if LIBCURL_VERSION_NUM guard as its setopt handler, so an undefined constant can never become a live channel.

2. Why reuse is both good and dangerous

Good: skipping the TCP handshake, the TLS handshake, and (for a proxy tunnel) the CONNECT exchange is a large latency win.

Dangerous: a reused connection carries identity. If libcurl reuses a connection that was authenticated or encrypted under one identity for a request that expects a different one, that is a leak. libcurl decides whether to reuse a connection by matching connection attributes, and bugs in that matching have produced CVEs (§4, §7).

libcurl matches on host, port, scheme, proxy type/host/port, and — for TLS — its "primary" SSL config. There is an important split in libcurl's data model that several decisions below depend on:

  • ssl_primary_config (compared for reuse): client certificate and cert blob, CA info, TLS version, cipher lists, pinned key, TLS-SRP credentials, verify flags, issuer cert.
  • ssl_config_data (not compared): the private key, key blob, key passphrase, and the cert/key encoding (PEM/DER/…).

3. transport_sharing: sharing connections across handlers (CURLSH)

The transport_sharing client option attaches a cURL share handle (CURLSH, via CURLOPT_SHARE) to the pooled easy handles so they share state. Modes: NONE (default), HANDLER_PREFER/HANDLER_REQUIRE (share within one handler), and PERSISTENT_PREFER/PERSISTENT_REQUIRE (share a long-lived cache). *_REQUIRE errors if the requested sharing is unavailable. The *_PREFER modes degrade along a chain: HANDLER_PREFER falls back from handler-lifetime sharing to no sharing, and PERSISTENT_PREFER falls back from persistent sharing to handler-lifetime sharing and then to no sharing. When connection caps are configured, persistent preference begins at handler-lifetime sharing, because a shared connection pool cannot honor the cap guarantee (section 7).

What a share handle can share, and the libcurl floor Guzzle requires for each (see CurlVersion):

Shared stateCURL_LOCK_DATA_*FloorConstant
Share handles usable at all7.35.0HANDLER_SHARING_VERSION
DNS cacheDNS7.35.0(with handler sharing)
TLS session cacheSSL_SESSION8.6.0SSL_SESSION_SHARING_VERSION
Connection cacheCONNECT8.12.0CONNECTION_SHARING_VERSION

The connection cache is shared only on 8.0 and only from 8.12.0; 7.x never shares the connection cache. The DNS/handler and TLS-session floors are each the version at which that share class became safe (§7). The connection-cache floor is set differently. 8.12.0 is the lowest libcurl that meets two conditions. First, it fixes every connection-reuse and TLS-session-reuse defect reachable by a default https:// request, meaning default verification with no client certificate and no proxy authentication; the binding fix is CVE-2024-0853, released in 8.6.0. Second, its session-cache rewrite in curl PR #16245 lets the co-shared SSL_SESSION lock resume sessions across easy handles, where before 8.12.0 the share is accepted but sessions stay largely handle-local. Proxy-credential reuse fixes that would otherwise argue for 8.20.0 do not hold this floor up, because Guzzle sections proxy credentials independently under PROXY_CREDENTIAL_REUSE_VERSION (§8). Direct mTLS client-certificate reuse stays incomplete below 8.21.0 under CVE-2026-8932, a documented caveat for client-cert users rather than a floor-mover for the majority. The absolute hard floor is 7.83.1, from CVE-2022-27782, below which a shared connection cache must never be used.

4. The proxy-tunnel credential hazard (why proxyTunnelSignature exists)

When you tunnel a request through an HTTP(S) proxy, libcurl sends a CONNECT to the proxy carrying the proxy credentials (Proxy-Authorization), then keeps that established tunnel in the connection cache.

The bug (CVE-2026-3784 family). libcurl 7.7–8.18.0 matched a proxy connection by type/host/port but not by the proxy credentials. A pooled tunnel established with credentials A could therefore be reused for a later request carrying credentials B — leaking A's tunnel to B. curl 8.19.0 added a credential comparison to proxy_info_matches(); 8.20.0 fixed related proxy-credential leaks (credentials surviving a redirect, or a port/scheme change).

Guzzle's mitigation. CurlFactory::proxyTunnelSignature() computes a per-request signature over the proxy identity. When it changes between requests, Guzzle forces a fresh connection — purging pooled idle handles that might hold a foreign tunnel — so libcurl cannot hand one request a tunnel established under a different identity.

CurlMultiHandler active-attachment isolation. A single CurlMultiHandler reuses one libcurl multi handle — and therefore one connection cache — across transfers. It tracks the proxy tunnel section that owns that idle cache in a scalar $proxyTunnelOwner, handed over only when the handle is fully idle. Concurrency is governed separately by a reference-counted map of the proxy tunnel signatures currently attached to the multi handle. While a foreign signature is attached, even an owner-compatible transfer is isolated with CURLOPT_FRESH_CONNECT + CURLOPT_FORBID_REUSE — the A / B / A case — because the latched owner records who inherited the idle cache, not which sections are in flight right now.

5. The signature: what it covers and why

Domain (when a signature is computed at all). Two proxy families section: a request that establishes a proxy CONNECT tunnel through an HTTP(S) proxy — usesProxyTunnel() is true for an https:// target, an explicit CURLOPT_HTTPPROXYTUNNEL, or an http:// target with a non-empty CURLOPT_CONNECT_TO (§6) — and any request through a SOCKS proxy on libcurl older than 7.69.0 (below). On 8.0 the cURL handlers additionally reject HTTP-proxy CONNECT tunnels below libcurl 7.54.0 (PROXY_TUNNEL_VERSION), so the hashed tunnel regime described here spans 7.54.0 and newer there; on 7.x it extends down to the oldest supported libcurl. Direct and non-tunnel HTTP-proxy requests get a null signature and never disturb the pool. Real delegated tunnels on fixed libcurl get a non-null sentinel, so they stay distinct from genuine non-tunnels and from literal proxy-header tunnel owners.

Non-tunnel proxy requests get a null signature and are deliberately left unsectioned. For per-request (HTTP Basic) proxy auth this is safe: proxy URL userinfo and the allow-listed CURLOPT_PROXYUSERPWD re-authenticate on every request, so a reused non-tunnel proxy connection cannot inherit a foreign identity. Connection-oriented schemes (NTLM, Negotiate) bind identity to the TCP connection, and a caller can still express one through a literal Proxy-Authorization header — sent as a PSR header or via the allow-listed raw CURLOPT_PROXYHEADER — even though raw CURLOPT_PROXYAUTH is rejected. A non-tunnel request carrying such a header is therefore an accepted residual: it is left unsectioned, because sectioning non-tunnel proxy connections would extend the signature domain beyond CONNECT tunnels, which is intentionally out of scope. (A single static header value cannot complete NTLM's multi-leg handshake, but a single-leg Negotiate token can, so the residual is real.)

SOCKS proxies (sectioned below 7.69.0). SOCKS authentication is connection-scoped: the credentials are negotiated once, right after TCP connect, and the proxy attributes everything else sent on that connection to that identity — the same identity binding as an authenticated CONNECT tunnel, one layer down. libcurl only started comparing SOCKS credentials when matching connections for reuse in 7.69.0 (curl #4835); older libcurl matches a SOCKS proxy by type, host, and port only, so a pooled connection authenticated as one user could be reused for another — or for a request carrying no credentials at all. proxyTunnelSignature() therefore routes every SOCKS proxy (the socks, socks4, socks4a, socks5, and socks5h schemes, or an http-scheme or scheme-less proxy with a SOCKS CURLOPT_PROXYTYPE — libcurl preserves the raw proxy type behind an http scheme, while every other scheme overrides it) to socksProxySignature() ahead of the tunnel domain checks, because SOCKS binds plain http:// requests as much as https:// ones. From 7.69.0 the signature is null: libcurl keys reuse on the parsed SOCKS credentials itself, both URL userinfo and the CURLOPT_PROXYUSERPWD family feed the compared fields, and SOCKS has no opaque-header analogue of Proxy-Authorization, so unlike CONNECT tunnels there is no channel libcurl cannot key. Below 7.69.0 every SOCKS request is sectioned by a hash of the effective proxy URL and the proxy credential options; the credential-less state hashes too, distinctly, because an anonymous request would otherwise match — and inherit — an authenticated pooled connection. The SOCKS5 auth-method mask, the SOCKS GSSAPI options, and CURLOPT_PRE_PROXY are not hashed: they are rejected raw options on 8.0, and branches that still apply them as deprecated raw options leave them the caller's responsibility — an accepted residual.

The channels hashed: the effective proxy URL, the proxy credential and TLS-identity options, and any literal Proxy-Authorization header value.

PSR Proxy-Authorization headers involve two distinct decisions. Recipient selection is delegated to libcurl and is route-independent in Guzzle: a first-class value never enters CURLOPT_HTTPHEADER and is always configured in the proxy-header channel (CURLOPT_PROXYHEADER) with CURLOPT_HEADEROPT => CURLHEADER_SEPARATE, regardless of Guzzle's route prediction, and libcurl decides whether that proxy-only list is used. An empty first-class value carries no credential and does not section. Its cURL Proxy-Authorization; form suppresses proxy authorization that would otherwise be generated from URL userinfo. Connection-reuse sectioning, by contrast, intentionally keeps using Guzzle's broad proxy approximation, because over-sectioning is safe and under-sectioning is not. A non-empty literal Proxy-Authorization: <value> header is never something libcurl can key connection reuse on, even on versions that key parsed proxy credentials (8.20.0+), so a tunnel carrying one always sections; its signature is hashed, never the delegated owner. On libcurl older than 7.37.0 (or a build missing the proxy-header constants) no proxy-only list can be represented safely, so a request carrying any first-class value or a raw CURLOPT_PROXYHEADER list is rejected up front with a RequestException, whatever the predicted route. That all-route rejection is specific to 8.0. Guzzle 7.14.2 and newer instead omit first-class values on known direct, bypassed, and SOCKS routes, rejecting them only when the route may use an HTTP(S) proxy.

Proxy TLS credential coverage stays tunnel-only and private: it is reflection-tested hardening for CONNECT tunnels, not public non-tunneled HTTPS-proxy behavior, because raw proxy TLS cURL options are rejected as public inputs in 8.0.

Necessary vs defense-in-depth.

  • Necessary: the proxy credentials (PROXYUSERPWD/PROXYUSERNAME/ PROXYPASSWORD) — the exact channel the CVE missed — and the non-empty literal Proxy-Authorization header (CURLOPT_PROXYHEADER), which libcurl never keys reuse on at any version (it matches parsed credentials, not opaque request headers). The non-empty header must therefore be sectioned even on fixed libcurl.
  • Mostly defense-in-depth: the proxy-TLS options (client cert, cert blob, TLS version, TLS-SRP). libcurl keys reuse on these via ssl_primary_config for an HTTPS proxy — but only from 7.52.0 for the proxy client cert (CVE-2016-5420 in 7.50.1 is the origin-cert precedent, predating HTTPS-proxy support) and 7.83.1 for TLS-SRP (CVE-2022-27782). Below those they are load-bearing; above, redundant-but-free. They are hashed unconditionally rather than version-gated.

Deliberate omissions. PROXY_SSLKEY_BLOB, PROXY_SSLKEYTYPE, and PROXY_SSLCERTTYPE are not hashed. The private-key file and passphrase (PROXY_SSLKEY, PROXY_KEYPASSWD) are hashed on the non-delegated signature path as fallback hardening — libcurl's mTLS private-key matching on reuse was incomplete before 8.21.0 (CVE-2026-8932), see §10. This is not a complete pre-8.21.0 mitigation: it does not cover the delegated path (>= 8.20.0 with no non-empty literal proxy-auth header) or configured share handles. The blob and the two encoding types are an accepted residual, not proven-safe.

The golden rule — over-sectioning is safe. A non-null, changed signature only ever forces a fresh connection; it never relaxes reuse, so it can never cause a leak. Over-sectioning therefore remains safe for credential isolation, but it is not operationally free once connection caps are configured: a forced-fresh transfer can evict an eligible idle connection, and otherwise stays pending behind the applicable per-host or total cap, reducing throughput; on libcurl older than 8.8.0 a pending transfer does not run timeout checks, and on newer libcurl it can time out while pending. Under-covering (omitting a channel libcurl ignores) is the only way to leak. When in doubt, include the channel.

Raw CURLOPT_PROXY/CURLOPT_NOPROXY are rejected before this logic in 8.0. On branches that still accept raw CURLOPT_NOPROXY, effective-proxy detection must model only the exact, untrimmed CURLOPT_NOPROXY === '*' bypass-all case. Host, domain, CIDR, and port values must be treated as still proxied; that can over-section a request libcurl would route direct, but it cannot under-section a real proxy tunnel.

6. CONNECT_TO implicit tunnels

CURLOPT_CONNECT_TO redirects the origin connection to a different host:port (without changing the Host header, SNI, or cert verification). With an HTTP proxy, when the connect-to host or port differs, libcurl automatically switches to tunnel mode (sets tunnel_proxy) — so even a plain http:// request becomes a credential-bearing CONNECT tunnel, the same hazard class as §4.

usesProxyTunnel() therefore treats an http:// target with a non-empty CURLOPT_CONNECT_TO as a possible tunnel. The check is deliberately conservative — any non-empty value, not a full parse of CONNECT_TO's host:port:host:port grammar (including its wildcard and IPv6 forms). By the over-section rule, a false positive only forces a needless fresh connection, never a leak; reimplementing libcurl's parser to avoid that would be a liability, not a feature.

7. Connection-sharing safety decisions

Authenticated proxy + a configured share handle → blanket force-fresh. When transport_sharing is configured, the shared connection cache hides which tunnel a pooled connection holds, so the signature cannot reason about provenance. For an authenticated proxy tunnel Guzzle then sets CURLOPT_FRESH_CONNECT / CURLOPT_FORBID_REUSE (and PERSISTENT_REQUIRE turns the conflict into an error rather than silently degrading).

From libcurl 7.57.0 an opaque-provenance share, meaning an externally supplied handle or Guzzle's worker-global persistent pool, additionally forces every anonymous HTTP/HTTPS tunnel fresh: a foreign tunnel seeded with a literal Proxy-Authorization header carries an empty credential key that an anonymous request would match at every later version. Guzzle-created handler-lifetime states are known not to share connections and are exempt from this anonymous arm; requests carrying recognized credential channels keep the gates below.

"Authenticated" here mirrors the signature's channels, each gated to the libcurl version below which libcurl does not itself key reuse on it: a non-empty literal Proxy-Authorization header (every version), Basic/Digest proxy credentials (below 8.20.0, PROXY_CREDENTIAL_REUSE_VERSION), and a proxy TLS credential — a client certificate or TLS-SRP (below 7.83.1, PROXY_TLS_CREDENTIAL_REUSE_VERSION). libcurl matches the proxy client cert from 7.52.0 and TLS-SRP only from 7.83.1 (CVE-2022-27782), so both are keyed under the single 7.83.1 floor. The floor matters more here than for the signature, because forceFreshConnectionForAuthenticatedProxy throws under PERSISTENT_REQUIRE rather than degrading to a fresh connection, and persistent sharing, and therefore that throw, is reachable from CONNECTION_SHARING_VERSION at 8.12.0. So the relationship between each channel's gate and 8.12.0 decides whether the throw can fire:

  • The proxy TLS credential gate at 7.83.1 sits strictly below 8.12.0, so a proxy client cert or TLS-SRP only forces fresh on builds older than 7.83.1, where persistent sharing does not exist. A shareable mTLS-proxy request on a persistent-capable build is never turned into a force-fresh or a PERSISTENT_REQUIRE error by this path; forcing fresh at every version would have done exactly that, which is why the 7.83.1 gate is correct.
  • The proxy Basic/Digest gate at 8.20.0 sits above 8.12.0, and a non-empty literal Proxy-Authorization header forces fresh at every version. So on libcurl 8.12.0–8.19.x a PERSISTENT_REQUIRE request that carries proxy Basic/Digest credentials, or a non-empty literal Proxy-Authorization header, reaches forceFreshConnectionForAuthenticatedProxy and throws: persistent sharing is active there, but libcurl on those builds does not yet key reuse on the proxy credential, so the only safe options are a fresh connection or, under PERSISTENT_REQUIRE, rejection. This is intended; it rejects unsafe persistent reuse rather than silently sharing a tunnel across credentials, and it became reachable when the connection-sharing floor moved from 8.20.0 down to 8.12.0. From 8.20.0 the credential is keyed by libcurl, so the throw no longer applies to parsed credentials; the non-empty literal-header case still does, since libcurl can never key on an opaque request header.
  • The opaque-share anonymous-tunnel arm has no version ceiling, and persistent provenance is always opaque, so an anonymous PERSISTENT_REQUIRE tunnel reaches forceFreshConnectionForAuthenticatedProxy and throws on every persistent-capable build; recognized credential channels keep the gates above.

Multiplexed joins are same-multi only (why Multiplexing::NONE composes with persistent sharing). libcurl never adds a transfer to an in-use connection owned by a different multi handle: curl 7.62.0's ConnectionExists() skips in-use connections whose attached transfer belongs to another multi (check->data->multi != needle->data->multi in lib/url.c), curl 8.12.1's url_match_conn returns false for the same case, and curl master's url_match_multi() compares conn->attached_multi against the joining transfer's multi. The CURLSHOPT_SHARE documentation states the same rule for shared connection caches: multiplexed connections "only get additional transfers added to them if the existing connection is held by the same multi or easy handle". Under CURL_LOCK_DATA_CONNECT sharing, idle connections migrate between handlers as sequential reuse, while in-use connections are join-protected by this rule on every libcurl generation. A Multiplexing::NONE handler therefore keeps its guarantee under persistent sharing without any sharing guard: its own multi never multiplexes (CURLMOPT_PIPELINING = 0), and no other handler's transfer can join its in-use connections. If a future libcurl relaxed the same-multi rule, this reasoning would need re-evaluation.

SOCKS proxies under a share handle → every request forces fresh below 7.69.0. A configured share handle suppresses proxyTunnelSignature(), and below 7.69.0 the connection pools match a SOCKS proxy credential-blind. requiresFreshConnectionForAuthenticatedProxy() therefore has a SOCKS rule ahead of its tunnel checks: below 7.69.0, every SOCKS request, authenticated and anonymous alike, is forced onto a fresh non-reusable connection. The old argument that CURLOPT_FORBID_REUSE keeps authenticated SOCKS connections out of the pools, so anonymous requests need no forcing, only covered connections this factory created: the constructor accepts externally built share handles whose cached contents cannot be inspected from PHP, and libcurl supports CURL_LOCK_DATA_CONNECT from 7.57.0, so on libcurl 7.57.0 through 7.68.x such a handle may already hold an authenticated SOCKS connection Guzzle never saw. Guzzle-managed shares lock only the DNS cache on those versions (§3) yet deliberately pay the same conservative cost: the HTTP opaque-share arm in §7 distinguishes handler-state provenance, and a matching SOCKS relaxation is left as future work. The connection cache Guzzle itself shares requires libcurl 8.12.0 or newer (§3), above the 7.69.0 floor, so wherever a Guzzle-shared connection cache can exist libcurl already keys SOCKS credentials and PERSISTENT_REQUIRE can never throw for SOCKS credentials.

SSL session sharing floor = 8.6.0 — why it is safe. Sharing the TLS session cache could, in theory, let two handles resume each other's TLS session across different client certificates. It cannot: libcurl matches the client certificate before reusing a cached session on every version Guzzle shares the cache on (≥ 8.6.0) — via match_ssl_primary_config before 8.12.0 and cf_ssl_scache_match_auth from 8.12.0 — and this runs for shared caches too. 8.6.0 is also the CVE-2024-0853 fix release. curl's 8.12.0 ssl_peer_key rework is a refactor of that same client-cert-aware matching, not a fix 8.6.0 lacks, so the floor stays at 8.6.0. This safety covers TLS-session resumption only; libcurl's broader connection-reuse matching of the client private key is not complete below 8.21.0. See the direct-mTLS note below and CVE-2026-8932.

Direct (non-proxy) mTLS client certificate under sharing → accepted opt-in risk, not force-freshed. The connection-cache floor at 8.12.0 activates a shared CURL_LOCK_DATA_CONNECT cache on libcurl 8.12.0–8.20.x, but libcurl's connection-reuse matching does not fully account for client-certificate private-key options below 8.21.0, under CVE-2026-8932. Unlike the proxy-TLS-credential path above, Guzzle deliberately does not force a fresh connection or reject a request for the direct cert and ssl_key options here: a persistent pool that sends requests with different client-certificate identities to the same host on libcurl older than 8.21.0 can reuse a connection authenticated with a different private key. This is an accepted opt-in risk for this release. It requires PERSISTENT_PREFER or PERSISTENT_REQUIRE, which are off by default, and is documented in the persistent-sharing caveat in handlers.md. Run libcurl 8.21.0 or newer, or do not mix client-certificate identities under one shared connection cache.

CURLOPT_RESOLVE under sharing → no special handling. A request-level CURLOPT_RESOLVE writes the shared DNS cache, so its host→IP entries are visible to later requests on the same share handle. Guzzle leaves this as-is, on purpose: it has a legitimate use (client-wide DNS pinning via the shared cache, typically set as a client-level curl default), it is advanced custom configuration that remains the caller's responsibility (like other raw cURL options), and Guzzle cannot distinguish an intentional client-wide pin from an accidental per-request one. For per-request routing that does not touch the DNS cache, use CURLOPT_CONNECT_TO. (Contrast CURLOPT_SHARE, which is rejected under a configured share handle: a second, request-level share handle is simply incoherent, with no legitimate use.)

Connection caps and tunnel sectioning. The max_host_connections and max_total_connections options apply to the handler's single multi handle: busy foreign tunnel sections stay attached to that same multi handle and compete for the same total budget, and an idle owner handover closes the old multi handle before lazily creating the next one, so sections never hold concurrent independent budgets. CURLOPT_FRESH_CONNECT does not bypass either cap; when no eligible idle connection can be evicted, the isolated transfer stays pending. max_host_connections counts libcurl's connection-bundle grouping, not a fixed notion of host: in modern libcurl, non-tunnel HTTP proxy forwarding is keyed by the proxy endpoint while CONNECT tunnels and SOCKS connections are keyed by the target destination, and older implementations differed (the original 7.30.0 code keyed even forwarding by the target hostname), so the cap is not a portable per-proxy or per-credential limit.

Connection caps and shared pools. With connection caps configured, PERSISTENT_PREFER transport sharing degrades to handler-lifetime sharing, PERSISTENT_REQUIRE is rejected, and a preconstructed persistent CurlShareHandleState of either persistent mode is rejected. libcurl 7.57.0 through 8.12.x checked the requesting transfer's multi-handle limits against all connections in the share-owned pool; different sharers could use different multi-handle limits, so this never provided a coherent per-handler or global cap. From libcurl 8.13.0, share-owned pools have no associated multi-handle limits and those caps are skipped entirely. A custom handle_factory is caller-controlled: it is responsible for not attaching an external connection-sharing CURLOPT_SHARE pool when multi-handle connection caps must be enforced, because Guzzle cannot inspect that native handle state.

8. The version trust floor

PROXY_CREDENTIAL_REUSE_VERSION = 8.20.0. Below it, hash the credential channels: libcurl < 8.19.0 ignored proxy credentials when matching connections, and 8.19.x still carried related proxy-credential leaks fixed in 8.20.0. At or above it, libcurl keys reuse on option- and URL-supplied proxy credentials itself, so proxyTunnelSignature() returns a shared delegated-owner sentinel — except when a non-empty literal Proxy-Authorization header is present, which always sections because libcurl can never key on an opaque request header (§5).

SOCKS_PROXY_CREDENTIAL_REUSE_VERSION = 7.69.0. Below it, every SOCKS-proxied request is sectioned by its credential state, because libcurl matched a SOCKS proxy by type, host, and port only (curl #4835). At or above it the signature is null — full delegation — since libcurl compares the parsed SOCKS credentials itself and SOCKS has no literal-header channel it cannot key.

This proxy-credential floor (8.20.0) is distinct from the connection-cache sharing floor (CONNECTION_SHARING_VERSION = 8.12.0, §3). The former gates how Guzzle sections proxy tunnels; the latter gates whether persistent sharing puts the connection cache on the share handle at all. They are deliberately decoupled: the proxy hazard 8.20.0 addresses is mitigated by Guzzle regardless of the connection floor, so it does not hold the connection floor up to 8.20.0. The mechanism depends on whether a share handle is configured (§7): with no share handle Guzzle sections the pool via proxyTunnelSignature(), while with a configured share handle, which persistent sharing always uses, it instead forces a fresh tunnel with CURLOPT_FRESH_CONNECT and CURLOPT_FORBID_REUSE, or rejects the request under PERSISTENT_REQUIRE.

9. How the tests enforce this

tests/Handler/CurlFactoryTest.php covers the signature by channel and the pool's purge/reuse behavior. In particular, testProxyTlsAuthCredentialChangesProxyTunnelSignature pins the TLS-SRP channel as load-bearing; it is a reflection test, so it exercises proxyTunnelSignature() directly without going through the option allow-list (which would otherwise reject the raw proxy-TLS option on 8.0). When you add or remove a channel, add or adjust a test — a silently dropped channel is exactly how a leak gets reintroduced, and CI is the backstop the comments point at.

testSocksProxyCredentialsChangeSocksProxySignatureOnAffectedCurlVersion, the SOCKS cases in proxyTunnelSectionProvider, and the scheme-less and http-scheme CURLOPT_PROXYTYPE reflection tests pin the SOCKS credential channels, the credential-less sectioning, and the 7.69.0 delegation. The share-handle SOCKS tests assert the blanket force-fresh: every SOCKS request below 7.69.0, authenticated (a plain http:// target included) or anonymous, forces a fresh non-reusable connection, while fixed libcurl does not. The HTTP opaque-share arm is pinned by testOpaqueShareHandleForcesFreshProxyTunnels, the handler-state exemption by testHandlerShareStateRetainsAnonymousProxyTunnelReuse, and the persistent modes by the prefer/require opaque-tunnel tests.

testProxyTlsCredentialsRequireFreshConnectionOnAffectedCurlVersion does the same for the share-handle force-fresh path: it asserts requiresFreshConnectionForAuthenticatedProxy forces a fresh tunnel for a proxy TLS credential below 7.83.1 and not at or above it.

10. Hard rules (summary)

  • Never compute a null signature for a credential-bearing proxy tunnel. Over-section freely; under-sectioning is the only way to leak.
  • Always hash the proxy credentials and the non-empty literal Proxy-Authorization header; the non-empty header sections on every libcurl version.
  • Use a non-null delegated sentinel for real proxy tunnels whose parsed proxy credentials, if any, are trusted to libcurl.
  • Never trust libcurl < 8.20 to distinguish proxy credentials itself.
  • Section every SOCKS-proxied request below 7.69.0 by its credential state, including the credential-less state; from 7.69.0 libcurl compares SOCKS credentials itself and SOCKS requests are deliberately unsectioned.
  • On the non-delegated signature path, key on the proxy private-key file and passphrase as fallback hardening (libcurl's mTLS private-key matching on reuse was incomplete below 8.21.0, CVE-2026-8932; the delegated path and configured share handles are not covered). The key blob and cert/key encoding are an accepted residual, not proven-safe.
  • usesProxyTunnel() must treat http:// + a non-empty CURLOPT_CONNECT_TO as a tunnel.
  • Share the TLS session cache only from 8.6.0 and the connection cache only from 8.12.0 (see §3 for why 8.12.0, not 8.20.0).
  • Under a configured share handle, force a fresh tunnel for a proxy TLS credential (client cert / TLS-SRP) below 7.83.1, mirroring the signature path; the 7.83.1 gate keeps it below the version where PERSISTENT_REQUIRE would throw.
  • Under a configured share handle, force a fresh non-reusable connection for every SOCKS request below 7.69.0, anonymous ones included; an externally built share handle may already hold an authenticated SOCKS connection this factory never created.
  • Under an opaque-provenance connection share (an externally supplied handle or a persistent pool), force a fresh non-reusable connection for every anonymous HTTP/HTTPS proxy tunnel from libcurl 7.57.0, with no upper version bound; a foreign literal-header tunnel is never credential-keyed.
  • Treat Guzzle-created handler-lifetime share states as no-CONNECT: they keep ordinary HTTP tunnel reuse, while raw handles passed to the factory stay opaque.

References

curl — CVE-2026-3784 (proxy CONNECT credential reuse), CVE-2016-5420 (origin client-cert reuse), CVE-2022-27782 (TLS / TLS-SRP config not compared on reuse), CVE-2024-0853 (client cert / OCSP on session reuse), CVE-2026-6253/6429/7168 (proxy-credential leaks on reuse, fixed 8.20.0), CVE-2026-8932 (incomplete mTLS private-key matching on reuse, fixed 8.21.0), and curl issue #4835 (SOCKS proxy credentials not compared on connection reuse, fixed 7.69.0); the 8.12.0 connection-cache floor also draws on curl's 8.12.0 session-cache rewrite (curl PR #16245). Source of record: lib/url.c (proxy_info_matches, the connection matcher, tunnel_proxy) and lib/vtls/ (ssl_primary_config vs ssl_config_data, the session cache and ssl_peer_key). Docs: CURLOPT_CONNECT_TO, CURLOPT_RESOLVE, and the share interface.

php — the cURL extension (curl_setopt, curl_reset, curl_share_*). CURLOPT_* constants exist only when the linked libcurl version supports the option, and curl_setopt() rejects unknown option integers (PHP 8 ValueError), which is what makes the \defined() guard safe.

guzzlesrc/Handler/CurlFactory.php (proxyTunnelSignature, socksProxySignature, usesProxyTunnel, isHttpProxyForConnectionReuse), src/Handler/CurlVersion.php (the version floors), and exception-guidelines.md for exception types.