CVE-2026-105299 affects Pingora, Cloudflare's open source Rust proxy framework, from 0.1.0 up to 0.9.0. A client could name X-Forwarded-For in its Connection header, and a later HTTP/1 proxy would delete the address Pingora added. Origins that grant access by IP can be bypassed. Upgrade pingora-proxy to 0.9.0.
This post is for teams that build proxies or gateways on Pingora, and for anyone who runs more than one proxy in front of an application that trusts forwarded headers. It covers the exact conditions, both fix commits, every affected version, the gaps in advisory data that will hide this CVE from some tools, a localhost lab, a workaround for teams that cannot upgrade yet, and the origin changes that stop this class of bug from mattering.
The short answer
CVE-2026-105299 affects the pingora-proxy crate from 0.1.0 up to, but not including, 0.9.0. On the HTTP/1 upstream path, Pingora did not remove the client's Connection header or the headers it named, which RFC 9110 requires of intermediaries. When a second HTTP/1 proxy that honors Connection sits between Pingora and the origin, a client can make that proxy strip headers Pingora added, such as X-Forwarded-For. If the origin uses that header for IP based authorization, the attacker may get through.
The fix shipped in 0.9.0 on 9 September 2026, six weeks before the CVE was published. Cloudflare scored it 8.7 (High) under CVSS 3.1 in the CVE record and 7.0 (High) under CVSS 4.0 in repository advisory GHSA-vx4q-2c55-cvmg. As of 25 October there is no report of exploitation, no CISA KEV listing and no newer release. Upgrade to 0.9.0, do not opt back into the old behavior with HttpUpstreamRequestPolicy::preserve(), and if you cannot upgrade yet, remove Connection and every header it names inside upstream_request_filter before you add trusted headers.
What changed since 20 October
Our first look went out on the morning of 20 October, hours after the record was published. Several things moved after that.
- Pingora now has a repository advisory. We wrote that the only GitHub entry was the unreviewed global advisory GHSA-r9g2-75m7-w9wg. At 16:25 UTC that day the maintainers published repository advisory GHSA-vx4q-2c55-cvmg, naming the crate (
pingora-proxy, affected before 0.9.0, patched in 0.9.0) and crediting @thesmartshadow. - A second score appeared. That advisory scores the bug 7.0 (High) under CVSS 4.0. The CVE record keeps its CVSS 3.1 score of 8.7. Both come from Cloudflare.
- CISA added its assessment. At 19:18 UTC, CISA's ADP container recorded SSVC Exploitation "none", Automatable "no", Technical Impact "total", and tagged the record CWE-444.
- The databases still lag. GHSA-r9g2-75m7-w9wg is still unreviewed with no package listed, and GitHub says Dependabot alerts are not supported for it. OSV's crates.io data, which includes RustSec, has no entry for this CVE. NVD shows "Awaiting Analysis".
- Two corrections. The first fix commit already rejects nominations of protected headers; the second tightens that check. And the commits were authored on 3 June and 23 July but merged on 19 June and 25 August.
Timeline
All times are UTC, from GitHub commit metadata, crates.io, the CVE Program API, GitHub and NVD.
| Date and time (UTC) | Event |
|---|---|
| 2024-02-28 03:34 | pingora-proxy 0.1.0, the first affected version, published to crates.io |
| 2026-06-03 21:44 | Fix commit 28c18e6 authored |
| 2026-06-04 20:10 | pingora-proxy 0.8.1 published, without the fix |
| 2026-06-19 22:05 | Commit 28c18e6 merged to main |
| 2026-07-23 20:00 | Hardening commit ca23f16 authored |
| 2026-08-25 17:30 | Commit ca23f16 merged |
| 2026-09-09 23:32 | pingora-proxy 0.9.0 published (release notes are headed 4 September) |
| 2026-09-09 23:34 | GitHub release 0.9.0 lists the stripping behavior as a potential breaking change |
| 2026-10-05 04:19 | CVE-2026-105299 reserved by Cloudflare |
| 2026-10-20 00:40 and 00:46 | Sibling CVEs CVE-2026-105297 and CVE-2026-105298 published |
| 2026-10-20 01:07 | CVE-2026-105299 published, CVSS 3.1 score 8.7 |
| 2026-10-20 02:17 | NVD receives the record |
| 2026-10-20 03:32 | Global advisory GHSA-r9g2-75m7-w9wg published, unreviewed, no package |
| 2026-10-20 16:25 | Repository advisory GHSA-vx4q-2c55-cvmg published, CVSS 4.0 score 7.0 |
| 2026-10-20 19:18 | CISA ADP adds SSVC and CWE-444 |
| 2026-10-23 | KEV catalog 2026.10.23 does not list the CVE |
| 2026-10-24 02:49 | OSV record last modified, still a git commit range only |
Two gaps stand out. The fix sat merged on main for 82 days before it shipped, and the release then shipped for 41 days before the CVE gave anyone a reason to treat it as urgent. Teams that took 0.9.0 in September were protected without knowing it. Teams that skipped it because the release notes flagged breaking changes were not, and nothing in their tooling said so.
How it works, in plain language
HTTP/1.1 has two kinds of headers. End to end headers, like Authorization or X-Forwarded-For, travel the whole chain. Hop by hop headers, like Keep-Alive, describe only the current connection and must not be passed on.
Connection is how a sender marks extra headers as hop by hop. If a request carries Connection: keep-alive, X-Custom, an intermediary must delete X-Custom and then Connection itself before forwarding. RFC 9110 section 7.6.1 makes this a "MUST".
The rule works when every hop follows it. Before 0.9.0, Pingora forwarded the client's Connection header unchanged to HTTP/1 upstreams, so the client decided what the next proxy would delete. Picture three hops:
- Pingora at the edge receives
Connection: keep-alive, X-Forwarded-For. The application'supstream_request_filteraddsX-Forwarded-Forwith the real client address. The client'sConnectionheader goes along too. - An HTTP/1 proxy in the middle follows the RFC. It deletes
X-Forwarded-Foras told, then adds its ownX-Forwarded-Forwith the address it received the request from, which is Pingora's. - The origin sees only an internal address. If it allows internal addresses into an admin endpoint, the attacker is in.
No hop did anything strange on its own. The bypass lives between them, which is why the same bug keeps showing up in different proxies years apart.
Technical details
The CVE record
| Field | Value |
|---|---|
| CVE ID | CVE-2026-105299 |
| Title | Pingora forwards client-nominated Connection headers to HTTP/1 upstreams, allowing ACL bypass in specific chained deployments |
| CNA | Cloudflare |
| Reserved / published | 2026-10-05 04:19 UTC / 2026-10-20 01:07:12 UTC |
| Affected | 0.1.0 up to 0.9.0 (semver lessThan) |
| CVSS 3.1 (CNA) | 8.7 High, CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N |
| CVSS 4.0 (GHSA-vx4q-2c55-cvmg) | 7.0 High, CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N |
| CWE (CISA ADP) | CWE-444, Inconsistent Interpretation of HTTP Requests |
| SSVC (CISA ADP) | Exploitation: none; Automatable: no; Technical Impact: total |
| Credit | "Reported by thesmartshadow (GitHub)" |
| Fix commits | 28c18e6b6b04dd0bd937234e5950049e6d74cded, ca23f1668eca8e78be7ba62ef0edbd458b40eeee |
The description says the reporter demonstrated access to an IP restricted endpoint that validated clients by X-Forwarded-For. It names Go's net/http/httputil.ReverseProxy as an example of an intermediary that processes nominations, and calls the bug "the same class of issue as CVE-2022-31813 in Apache HTTP Server."
The record also states that, according to Cloudflare, its CDN customers were unaffected, because no such chained deployment exists with its internal Pingora services and Connection headers are sanitized before they reach its Pingora gateways. That is Cloudflare's own assessment of its network, which we cannot check independently. It says nothing about the many teams that build their own proxies on the open source crates.
Two scores, three weakness labels
The CVSS 3.1 vector sets Attack Complexity to High, reflecting how specific the deployment must be, and Scope to Changed, because the flaw is in Pingora but the impact lands on the origin. The repository advisory's CVSS 4.0 vector, CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N, moves the deployment conditions into Attack Requirements (AT:P) and records no impact on Pingora itself, only on the subsequent system. It comes out at 7.0. Both are High; which one you see depends on which source your tools read.
CISA tagged the record CWE-444, the request smuggling class, for the "two hops read one message differently" shape. The repository advisory lists CWE-345 (insufficient verification of data authenticity) and CWE-346 (origin validation error), for the consequence: the origin trusts an address it cannot verify.
The vulnerable path in pingora-proxy
Pingora is a framework. Applications implement the ProxyHttp trait, and upstream_request_filter receives a mutable copy of the request header just before it goes upstream. This is where applications add X-Forwarded-For, X-Real-IP or internal identity headers.
Before 0.9.0, the HTTP/1 path in pingora-proxy/src/proxy_h1.rs cloned the downstream request, ran the filter and wrote the result upstream. Nothing removed the client's Connection header or its nominations. The HTTP/2 path in proxy_h2.rs already removed Connection, Upgrade, Keep-Alive, Proxy-Connection and Transfer-Encoding, because HTTP/2 forbids connection specific fields, so a later hop had nothing to act on.
Why a Go reverse proxy completes the bypass
In Go's net/http/httputil/reverseproxy.go, removeHopByHopHeaders splits every Connection value on commas and deletes each named header, then deletes a fixed list including Connection, Keep-Alive, Te, Trailer, Transfer-Encoding and Upgrade.
NewSingleHostReverseProxy, the helper many Go services start from, builds a proxy with a Director function. In that mode, after hop by hop removal, Go appends the address the request arrived from to whatever X-Forwarded-For is left. If the nomination already deleted Pingora's header, the result holds only Pingora's address.
Go's own documentation now warns about this. Since Go 1.26 the Director field is deprecated, and its comment says hop by hop removal after Director "permits a malicious client to remove any headers that may be added by Director." The newer Rewrite mode fixes that for the Go hop's own headers, but not for headers an earlier hop added. The right place to fix it is the first hop, which is what Pingora 0.9.0 does.
The first fix commit: a request policy for upstreams
Commit 28c18e6, "Sanitize hop-by-hop headers on HTTP upstream requests", was authored by Edward Wang of Cloudflare and touches 10 files, 1,318 lines added and 27 removed, mostly tests. It adds HttpUpstreamRequestPolicy to pingora-core/src/upstreams/peer.rs and a field on PeerOptions, http_upstream_request_policy.
| Setting | Default | What it does |
|---|---|---|
strip_hop_by_hop | true | Removes standard hop by hop fields from the downstream request |
strip_connection_nominated | true | Removes fields named in Connection and rejects protected nominations |
h1_upgrade | H1UpgradePolicy::WebSocketOnly | Forwards only valid WebSocket upgrades, normalized |
Presets: standard() is the default, deny_upgrades() forwards no HTTP/1 upgrade, and preserve() restores the old passthrough behavior. Its doc comment calls preserve() "RFC-non-compliant".
A new function, sanitize_h1_upstream_request in pingora-proxy/src/proxy_common.rs, runs before the application's filter, so headers your filter adds deliberately are never stripped. It:
- Splits every
Connectionvalue into trimmed tokens. - Rejects the request at ten or more nominations, a cap the code comment says keeps token processing from becoming "a request-time DoS vector".
- Rejects any token that starts with a colon (a pseudo header) or equals
host,x-forwarded-for,x-forwarded-hostorx-forwarded-proto, ignoring case, with "protected header cannot be nominated by the Connection header". - Removes every nominated header, then the standard set:
keep-alive,proxy-connection,proxy-authenticate,proxy-authorization,te,trailer,transfer-encoding,connection,upgradeandhttp2-settings. - For a valid WebSocket upgrade from an HTTP/1.1 client, puts back exactly
Connection: UpgradeandUpgrade: websocket.
A rejection is an InvalidHTTPHeader error marked as downstream, which Pingora's default error handling turns into a 400. The same commit also sends every HTTP/1 upstream request as HTTP/1.1, adds Transfer-Encoding: chunked when a request with a body is left unframed after your filter, fails a 101 Switching Protocols response when the two sides disagree about an upgrade, and applies the same nomination checks on the HTTP/2 path. Its 25 new integration tests, such as test_h1_upstream_rejects_sensitive_fields_nominated_by_connection, are a good map of the new behavior.
The second fix commit: token grammar
Commit ca23f16, "pingora-proxy: reject malformed Connection nominations", fixed a gap in the first. The first commit compared raw tokens against the protected list and separately dropped tokens that did not parse as header names. A quoted nomination such as "X-Forwarded-For" matched neither, was silently dropped, and the request went ahead instead of being rejected.
The fix validates each token against the HTTP token grammar in RFC 9110 section 5.6.2 first, rejecting failures with "invalid token nominated by the Connection header", and runs the protected check on the parsed lowercase name. The commit says a protected header "can never actually be stripped via nomination either way", since a quoted token names a different header, so this hardens the rejection rather than fixing a second bypass. It adds reject_malformed_connection_nominations, default true.
Where the advisory data lives, and where it does not
This is the part most likely to cause a missed upgrade. What each source held on 25 October:
| Source | What it says | Will tools flag pingora-proxy from it? |
|---|---|---|
| CVE record | Product is the GitHub repository; 0.1.0 up to 0.9.0 | Only tools that map repositories to crates |
| GHSA-vx4q-2c55-cvmg (repository) | pingora-proxy before 0.9.0, patched 0.9.0 | Not until it reaches the global database; its ID did not yet resolve there |
| GHSA-r9g2-75m7-w9wg (global) | Unreviewed; "No package listed" | No; GitHub says Dependabot alerts are not supported |
| OSV | A git commit range, extracted as 0.1.0 to 0.9.0 | Not for crates.io lookups |
| RustSec | No identifier found in OSV's crates.io data | No |
| NVD | "Awaiting Analysis" | Depends on CPE data not yet added |
OSV's crates.io data does list earlier Pingora advisories such as RUSTSEC-2026-0033 (CVE-2026-2833). So a clean cargo audit run today tells you nothing about this CVE. Check the version yourself.
Two sibling CVEs published the same night
CVE-2026-105297, HTTP/2 memory exhaustion through unbounded default limits, affects versions before 0.8.1 and scores 7.5 under CVSS 3.1. CVE-2026-105298, an HTTP/2 :authority and Host mismatch enabling cache poisoning and host based access bypass, affects versions before 0.9.0 and scores 6.3 under CVSS 4.0. Upgrading to 0.9.0 fixes all three; CVE-2026-105297 was already fixed in 0.8.1.
Affected and fixed versions
The vulnerable code is in pingora-proxy. The pingora umbrella crate pulls it in through its proxy feature (or lb, which implies proxy), and the policy type lives in pingora-core. The crates move in lockstep.
pingora-proxy versions | Published | Status |
|---|---|---|
| 0.1.0, 0.1.1, 0.2.0, 0.3.0, 0.4.0 | February to November 2024 | Affected |
| 0.5.0, 0.6.0 | May and August 2025 | Affected |
| 0.7.0, 0.8.0 | January and March 2026 | Affected |
| 0.8.1 | 4 June 2026 | Affected (fixes CVE-2026-105297 only) |
| 0.9.0 | 9 September 2026 | Fixed |
As of 25 October, 0.9.0 is the latest version and nothing is yanked. On crates.io, 44 crates depend on pingora-proxy and 60 on pingora; private services are not in that count. A fork taken before 19 June 2026 is affected unless it picked up commit 28c18e6.
Who is affected
Every condition below must hold. If any is false, this bypass does not work, though upgrading is still right.
| Condition | How to check |
|---|---|
pingora-proxy before 0.9.0, directly or via pingora with proxy | cargo tree -i pingora-proxy |
| At least one upstream reached over HTTP/1 | HttpPeer options and ALPN settings for each upstream |
An HTTP/1 hop between Pingora and the origin that honors Connection | Map sidecars, internal gateways, Go services built on httputil.ReverseProxy |
| Your filter adds a header something downstream trusts | Read upstream_request_filter for X-Forwarded-For, X-Real-IP, identity headers |
| The origin makes a security decision from it | Look for IP allowlists and admin paths gated by address |
The common shape is a Pingora edge, an internal Go or other RFC following gateway, and an admin endpoint that allows internal ranges. It also generalizes: any header your filter adds, such as a tenant ID or an authenticated user header, could be nominated and deleted. If the origin treats a missing header as a default that grants more access, that is another path to the same outcome.
Reproduce it safely
This lab runs three toy servers on 127.0.0.1. The edge adds X-Forwarded-For as a Pingora filter would, the middle honors nominations and appends its own X-Forwarded-For as Go's Director mode does, and the origin echoes what it got. The edge runs once in passthrough mode and once with a sanitize() that strips nominations, rejects protected ones and rejects malformed tokens, as 0.9.0 does. It uses only the Python standard library and sends nothing off the machine. 203.0.113.7 is a documentation address.
#!/usr/bin/env python3
"""Show how a client-nominated Connection header strips a proxy-added header.
Three toy servers on localhost, standard library only:
edge adds X-Forwarded-For for the "real" client, then forwards
middle honors Connection nominations (RFC 9110 7.6.1), appends its own
X-Forwarded-For, then forwards
origin echoes the X-Forwarded-For it received
The edge runs once without sanitizing (Pingora before 0.9.0) and once with
sanitize(). Nothing leaves 127.0.0.1.
"""
import http.client
import http.server
import threading
CLIENT_IP = "203.0.113.7" # documentation address standing in for the real client
HOP_BY_HOP = {"connection", "keep-alive", "proxy-connection", "te", "trailer",
"transfer-encoding", "upgrade", "proxy-authenticate", "proxy-authorization"}
PROTECTED = {"host", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"}
TCHAR = set("!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
def nominated(headers):
names = set()
for value in headers.get_all("Connection") or []:
names |= {t.strip().lower() for t in value.split(",") if t.strip()}
return names
def sanitize(headers):
"""Drop Connection, the headers it names and fixed hop-by-hop headers.
Raise if the client nominates a header the proxy relies on, or sends a
nomination that is not a valid HTTP token (RFC 9110 5.6.2)."""
names = nominated(headers)
bad = sorted(n for n in names if not set(n) <= TCHAR)
if bad:
raise ValueError(f"Connection nominates malformed token(s): {bad}")
if names & PROTECTED:
raise ValueError(f"Connection nominates protected header(s): {sorted(names & PROTECTED)}")
return [(k, v) for k, v in headers.items() if k.lower() not in names | HOP_BY_HOP]
def forward(port, path, headers):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
conn.putrequest("GET", path, skip_host=True, skip_accept_encoding=True)
for k, v in headers:
conn.putheader(k, v)
conn.endheaders()
resp = conn.getresponse()
return resp.status, resp.read()
def serve(handler):
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv
class Origin(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = f"origin saw X-Forwarded-For: {self.headers.get('X-Forwarded-For')}".encode()
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
def middle_handler(origin_port):
class Middle(Origin):
def do_GET(self):
names = nominated(self.headers) | HOP_BY_HOP
out = [(k, v) for k, v in self.headers.items() if k.lower() not in names]
prior = [v for k, v in out if k.lower() == "x-forwarded-for"]
out = [(k, v) for k, v in out if k.lower() != "x-forwarded-for"]
out.append(("X-Forwarded-For", ", ".join(prior + [self.client_address[0]])))
status, body = forward(origin_port, self.path, out)
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return Middle
def edge_handler(middle_port, sanitizing):
class Edge(Origin):
def do_GET(self):
try:
out = sanitize(self.headers) if sanitizing else list(self.headers.items())
except ValueError as e:
body = str(e).encode()
self.send_response(400)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
out = [(k, v) for k, v in out if k.lower() != "x-forwarded-for"]
out.append(("X-Forwarded-For", CLIENT_IP))
status, body = forward(middle_port, self.path, out)
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return Edge
def main():
origin = serve(Origin)
middle = serve(middle_handler(origin.server_port))
for sanitizing in (False, True):
edge = serve(edge_handler(middle.server_port, sanitizing))
for conn_header in ("keep-alive", "keep-alive, X-Forwarded-For",
'keep-alive, "X-Forwarded-For"'):
status, body = forward(edge.server_port, "/admin",
[("Host", "app.example"), ("Connection", conn_header)])
mode = "sanitizing edge " if sanitizing else "passthrough edge"
print(f"{mode} | Connection: {conn_header:<29} | {status} {body.decode()}")
edge.shutdown()
if __name__ == "__main__":
main()
Run it with any Python 3.8 or later:
$ python3 hop_by_hop_lab.py
passthrough edge | Connection: keep-alive | 200 origin saw X-Forwarded-For: 203.0.113.7, 127.0.0.1
passthrough edge | Connection: keep-alive, X-Forwarded-For | 200 origin saw X-Forwarded-For: 127.0.0.1
passthrough edge | Connection: keep-alive, "X-Forwarded-For" | 200 origin saw X-Forwarded-For: 203.0.113.7, 127.0.0.1
sanitizing edge | Connection: keep-alive | 200 origin saw X-Forwarded-For: 203.0.113.7, 127.0.0.1
sanitizing edge | Connection: keep-alive, X-Forwarded-For | 400 Connection nominates protected header(s): ['x-forwarded-for']
sanitizing edge | Connection: keep-alive, "X-Forwarded-For" | 400 Connection nominates malformed token(s): ['"x-forwarded-for"']
Line 2 is the bug: the edge added 203.0.113.7, but the client told the middle proxy to delete it, so the origin only sees 127.0.0.1, the hop in front of it. On a real network that is an internal address the origin may trust. Line 3 is the quoted case the second commit addresses; the quoted name matches no real header, so nothing is deleted, as the commit says. With the sanitizing edge, both attempts get a 400 before anything is forwarded. The sanitize() function is easy to adapt to any proxy you write yourself.
What to do now
Step 1: find every Pingora version you build
# Which crates pull in pingora-proxy, and at what version
cargo tree -i pingora-proxy -e normal
# If several versions are in the graph, name one
cargo tree -i pingora-proxy@0.8.1 -e normal
# Straight from the lockfile
grep -A1 '^name = "pingora-proxy"' Cargo.lock
# Every Pingora crate and version in the workspace
cargo metadata --format-version 1 \
| jq -r '.packages[] | select(.name | startswith("pingora")) | "\(.name) \(.version)"' \
| sort -u
# Forks and vendored copies
grep -rn --include=Cargo.toml -E 'pingora.*(git|path) *=' .
find . -path '*/pingora-proxy/src/proxy_h1.rs' -not -path './target/*'
Anything older than 0.9.0 is affected. In a fork, the fix is missing if pingora-proxy/src/proxy_common.rs has no sanitize_h1_upstream_request.
For binaries you did not build, release builds often embed cargo registry paths in panic messages. This is a heuristic and fails on binaries built with path remapping:
strings -a ./edge-proxy | grep -oE 'pingora-proxy-[0-9]+\.[0-9]+\.[0-9]+' | sort -u
Step 2: upgrade to 0.9.0
Pingora is pre 1.0, so Cargo treats 0.8 to 0.9 as breaking and cargo update alone will not cross it. Edit the requirement first.
[dependencies]
pingora = { version = "0.9", features = ["proxy"] }
# or, if you depend on the crates directly
pingora-core = "0.9"
pingora-proxy = "0.9"
pingora-http = "0.9"
cargo update -p pingora -p pingora-proxy -p pingora-core
cargo build --release --locked
cargo test --locked
The release notes list other breaking changes. The likeliest to touch proxy code: RequestHeader and ResponseHeader no longer implement DerefMut, so direct mutation of req.headers must move to insert_header, append_header and remove_header; some crates need Rust 1.85 (pingora-foundations declares 1.88); and Prometheus moved to pingora-prometheus. Then test behavior through a real upstream: WebSocket upgrades, request bodies, and whether any legitimate client now earns a 400.
Step 3: make sure nothing switches the fix back off
grep -rn -E 'HttpUpstreamRequestPolicy|http_upstream_request_policy|strip_connection_nominated|strip_hop_by_hop|reject_malformed_connection_nominations|H1UpgradePolicy::Preserve' --include='*.rs' .
HttpUpstreamRequestPolicy::preserve()restores the vulnerable behavior. The CVE record says not to use it if the origin relies on headers Pingora adds.strip_connection_nominated = falsedisables both stripping and the protected check.h1_upgrade = H1UpgradePolicy::Preserveskips normalization for any request with anUpgradeheader, which a client can add to anything.
If a legacy upstream needs one of these, scope it to that peer and confirm nothing behind it trusts forwarded headers.
Step 4: if you cannot upgrade yet, sanitize in your filter
The CVE record's advice for older versions is to remove Connection and every header it nominates inside upstream_request_filter before adding trusted headers. This sketch for the 0.8 API goes further and only accepts the options ordinary clients send. Test it against your own traffic first.
use async_trait::async_trait;
use http::header::{CONNECTION, UPGRADE};
use pingora_core::{Error, HTTPStatus, Result};
use pingora_http::RequestHeader;
use pingora_proxy::{ProxyHttp, Session};
/// Workaround for pingora-proxy before 0.9.0 (CVE-2026-105299).
fn sanitize_connection(req: &mut RequestHeader) -> Result<()> {
let mut wants_upgrade = false;
for value in req.headers.get_all(CONNECTION).iter() {
let value = value
.to_str()
.or_else(|_| Error::e_explain(HTTPStatus(400), "non-ASCII Connection header"))?;
for token in value.split(',').map(str::trim).filter(|t| !t.is_empty()) {
match token.to_ascii_lowercase().as_str() {
"keep-alive" | "close" | "te" => {}
"upgrade" => wants_upgrade = true,
_ => return Error::e_explain(HTTPStatus(400), "unexpected Connection option"),
}
}
}
req.remove_header(&CONNECTION);
req.remove_header("keep-alive");
req.remove_header("proxy-connection");
req.remove_header("te");
if wants_upgrade && req.headers.contains_key(UPGRADE) {
req.insert_header(CONNECTION, "Upgrade")?;
}
Ok(())
}
pub struct Edge;
#[async_trait]
impl ProxyHttp for Edge {
type CTX = ();
fn new_ctx(&self) -> Self::CTX {}
// upstream_peer() and the rest of your implementation stay as they are.
async fn upstream_request_filter(
&self,
session: &mut Session,
upstream_request: &mut RequestHeader,
_ctx: &mut Self::CTX,
) -> Result<()> {
// 1. Clean up the client's Connection header first.
sanitize_connection(upstream_request)?;
// 2. Then set trusted headers, replacing anything the client sent.
if let Some(addr) = session.client_addr().and_then(|a| a.as_inet()) {
upstream_request.insert_header("X-Forwarded-For", addr.ip().to_string())?;
}
Ok(())
}
}
It replaces any client supplied X-Forwarded-For rather than appending, which is right at an internet facing edge, and returns HTTPStatus(400) so a rejection is a clear 400 rather than a generic 500.
Step 5: map the chain and test it
List every hop behind each Pingora route: gateways, sidecars, Go services that forward requests, legacy load balancers. Note whether Pingora reaches each over HTTP/1 or HTTP/2 and whether it removes nominated headers. Most RFC following proxies do; that is what the standard asks.
Then point a staging edge at an upstream that echoes headers and nominate the header your filter adds. Only do this against systems you own.
curl -sS https://edge.staging.example.internal/debug/echo-headers
curl -sS -H 'Connection: keep-alive, X-Forwarded-For' \
https://edge.staging.example.internal/debug/echo-headers -o /dev/null -w '%{http_code}\n'
On 0.9.0 the second request should get a 400. On older versions, if the echo upstream saw no client address, your chain has the problem. Repeat for X-Real-IP and identity headers, and keep the test in your integration suite.
Step 6: change how the origin trusts forwarded addresses
Upgrading closes this hole; fixing the origin closes the class.
- Parse
X-Forwarded-Forfrom the right, skipping addresses of proxies you operate, and take the first one you do not. - Treat "ran out of entries" as unknown, not internal. In this attack every address belongs to your own proxies. Fall back to deny.
- Do not make internal address ranges a credential. Put real authentication on admin endpoints.
- Authenticate the hops with mutual TLS or a header the last trusted hop signs. See our guide to mutual TLS between microservices.
More on these headers: which client IP to log behind a proxy, Forwarded versus X-Forwarded-For and protecting proxy set headers from override.
Step 7: look for attempts
Before the upgrade, a successful attempt looks internal at the origin, so look at the edge. If your access log records request headers, search for nominations of forwarding or identity headers:
grep -iE 'connection:[^|]*(x-forwarded-|x-real-ip|host)' /var/log/edge/access.log
After the upgrade, attempts become 400s carrying "protected header cannot be nominated by the Connection header" or "invalid token nominated by the Connection header", which may reach your error log depending on your logging setup.
Open questions
- When the issue was reported. The fix was authored on 3 June and the CVE reserved on 5 October; neither source gives a report date.
- When the databases catch up. A reviewed global advisory with a package, a RustSec entry or an NVD score would let common tools flag affected lockfiles. None existed on 25 October.
- How common the vulnerable chain is. Cloudflare says its CDN does not have it. No public data shows how many other deployments do.
The fair view
This is a narrow bug. It needs Pingora on an HTTP/1 upstream, a second proxy that follows the RFC, and an origin that grants access by forwarded address, a design many security teams already discourage. The High Attack Complexity and CISA's "Exploitation: none" say the same: a real risk for one deployment shape, not an internet wide emergency. Apache rated its own version of this bug, CVE-2022-31813, as low when it fixed mod_proxy in 2.4.54 in June 2022.
Cloudflare's fix is stricter than the RFC. RFC 9110 only asks intermediaries to strip nominations; Pingora now rejects protected ones and caps the count. That may cost a few clients with odd Connection values, and in return makes attempts visible.
The lesson is about chains. Each proxy passes its own tests; the bug exists only between two of them. We have covered the same header class in Fabio, CVE-2025-48865, and earlier Pingora issues CVE-2026-2833 and CVE-2026-2835 followed the same pattern of two components reading one message differently, the territory of CWE-444.
What would have helped
Practices any team can use
- Take framework releases on a schedule, even when they say "breaking". Teams that took 0.9.0 within weeks were protected before the CVE existed. A monthly slot for proxy framework upgrades, with release notes read by the proxy's owner, makes this routine.
- Do not wait for a tool to complain. For edge components, follow the project's releases and advisories directly. Here, crate level data lagged the CVE by days.
- Test the chain, not just the hop. Integration tests that send nominated headers from edge to origin catch this and its relatives.
- Stop using addresses as identity. Authenticated hops and users make header games far less useful.
- Keep an inventory of what runs at the edge, so "do we run Pingora before 0.9.0 anywhere" is a query, forks included.
Where Safeguard fits
Safeguard helps with inventory, prioritization and the upgrade itself. It cannot change how your proxy chain is designed.
- Finding every copy.
safeguard scan --dir .on a repository,safeguard scan --image <name>on a container image, orsafeguard scan --sbom <file>on an existing SBOM produces a dependency inventory and vulnerability matches, written as JSON under./output. Cargo is a supported ecosystem. The platform shows which projects contain a given component and version, so "where do we shippingora-proxybefore 0.9.0" is one search. - Matching, with a caveat. Safeguard matches components against vulnerability databases. The crate level records for this CVE were incomplete on 25 October, and any tool relying on them inherits that gap; the inventory still answers the version question directly.
- Policies and gates. Policies and gates evaluated in the platform turn "no
pingora-proxybefore 0.9.0" into a requirement rather than a reminder. - Doing the upgrade with you. The Safeguard CLI's assistant can edit code and run commands with your approval, such as moving a workspace to Pingora 0.9, fixing the
DerefMuterrors and searching forpreserve(), under the/permissionsmode you choose.
Where Safeguard does not help: it cannot see how your proxies are chained, whether a middle hop honors Connection, or whether your origin trusts internal addresses. Those are design reviews and integration tests.
Common mistakes
- Treating a clean
cargo auditas safe. There was no RustSec entry for this CVE on 25 October. - Assuming HTTP/2 at the edge means safe. What matters is the protocol Pingora uses upstream.
- Re-enabling passthrough to fix a compatibility break.
preserve()reopens the CVE for everything behind that peer. - Setting
strip_connection_nominated = falseto quiet 400s. That disables the protected check too. - Sanitizing after adding trusted headers. In a workaround, strip first, then add
X-Forwarded-For. - Appending to client supplied
X-Forwarded-Forat the edge. Replace it; appending keeps whatever the client invented. - Upgrading only the binary you know about. Internal tools, staging proxies and forks often lag.
FAQ
Is CVE-2026-105299 being exploited?
There was no public report of exploitation as of 25 October. CISA's SSVC assessment records Exploitation as "none", and the CVE is not in the KEV catalog. The CVE record says the reporter demonstrated the bypass against an IP restricted endpoint, so the technique is known.
Which version fixes it?
pingora-proxy 0.9.0, published on 9 September 2026, with pingora-core 0.9.0. If you use the pingora crate, upgrade it to 0.9.0 with the proxy feature. It is still the latest release.
What is the CVSS score?
The CVE record, from Cloudflare as CNA, gives CVSS 3.1 8.7 (High), vector CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N. The repository advisory gives CVSS 4.0 7.0 (High). NVD has not scored it.
Were Cloudflare CDN customers affected?
According to Cloudflare's statement in the CVE record, no: it says no such chained deployment exists with its internal Pingora services and Connection headers are sanitized before reaching its Pingora gateways. That is Cloudflare's own assessment. The CVE matters to teams that build their own proxies on the open source crates.
My Pingora service speaks HTTP/2 to its upstream. Am I exposed?
Not through this path; the HTTP/2 upstream path already removed Connection. Upgrade anyway, because a fallback to HTTP/1 would put you on the vulnerable path, and CVE-2026-105298 affects the HTTP/2 side.
Why does my dependency scanner not show this CVE?
The crate level data is incomplete. The global advisory lists no package, and GitHub says Dependabot alerts are not supported for it, and OSV's crates.io data had no entry on 25 October. The repository advisory names pingora-proxy but had not reached the global database. Check versions directly until tools catch up.
Does the fix break anything?
It can. The release notes list the stripping as a potential breaking change. Upstreams that relied on hop by hop headers, clients with unusual Connection values and non WebSocket HTTP/1 upgrades are likeliest to notice, and the release has unrelated API changes such as dropping DerefMut.
Is this the same as CVE-2022-31813 in Apache?
The same class, as the CVE record says. Apache HTTP Server 2.4.53 and earlier could drop X-Forwarded-* headers when a client's Connection header named them, allowing IP based authentication bypass at the origin. Apache fixed it in 2.4.54.
For why Rust advisories reach different tools at different times, see the cargo audit and Dependabot advisory gap. If you want to try the scan and upgrade flow on your own code, the Safeguard apps are at safeguard.sh/download and the CLI guide is at docs.safeguard.sh/docs/cli.
Fix it without waiting for upstream
For CVE-2026-105299 a fixed release has existed since 9 September, so the gap that matters is the first one: the fix exists, but many builds still carry 0.8.1 or older. The proxy chain and origin changes above are yours to make; the steps below cover the dependency side.
Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.
1. Autonomous remediation: pull requests, not tickets
When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.
2. Gold Open Source: the safe version in seconds, free
gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.
3. Gold Registry: start from components that are already fixed
registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.
When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.
| Your situation | What to use |
|---|---|
| A fixed version exists | Autonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version |
| You need to know today whether you are affected | Gold Open Source lookup and "Am I affected?" checker, free and without login |
| You want vulnerable versions to stop shipping | The Gold scan GitHub Action in CI, plus Safeguard policies and gates |
| Upstream has no fix yet, or will not ship one | A custom Gold request; fork and patch (early access) |
| You want to start clean on new projects | Pull hardened components and images from Gold Registry |
One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.
See how Safeguard would have helped
These demos are recordings of real sessions on other fixes. None of them shows this CVE or a Pingora upgrade. Each one shows a flow you would use the same way here.
The CLI demo shows the assistant fixing a lodash CVE in a JavaScript repository: it reads the finding and the advisory, asks before any command that changes something, edits package.json to the fixed version, runs the install and the tests with your approval, and pushes nothing. For CVE-2026-105299 you would ask the same assistant to move your Rust workspace to Pingora 0.9, update Cargo.toml, run cargo update and cargo test, fix the DerefMut compile errors and check the code for preserve(), approving each edit and command as it goes.
Click through it at your own pace, or open it full screen.
The local runner demo shows setting up the runner on your own machine, approving it with its device code, and scanning from the terminal. Here, that is how you would scan each proxy repository and each edge container image for pingora-proxy versions before 0.9.0, including internal tools and forks that never made it onto the main inventory.
Click through it at your own pace, or open it full screen.
The desktop app demo shows a CLI session appearing in the conversation list, asking for work on your computer, picking the folder, and approving or denying each command and file edit until the upgrade is done and the tests pass. For this CVE, the same flow covers running the nominated header test from Step 5 against your staging edge after the upgrade, with you approving any command that changes something before it runs.
Click through it at your own pace, or open it full screen.
Quick check
Clients reach your Pingora 0.8.1 edge over HTTP/2, and Pingora forwards to a Go gateway over HTTP/1.1. Are you on the vulnerable path?
Yes, if the gateway honors Connection nominations and the origin trusts a header your filter adds. What matters is the upstream protocol, and Pingora was not stripping Connection on its HTTP/1 upstream path before 0.9.0. Upgrade to 0.9.0.
cargo audit reports nothing for your workspace, which uses pingora-proxy 0.8.0. Are you safe from CVE-2026-105299?
No. On 25 October there was no RustSec entry for this CVE, and the global GitHub advisory listed no package, so tools built on that data cannot flag it. Version 0.8.0 is affected; check versions directly and upgrade.
After upgrading to 0.9.0, a legacy upstream breaks, and a teammate proposes HttpUpstreamRequestPolicy::preserve() on every peer. What is the risk?
It restores the old passthrough behavior and reopens CVE-2026-105299 for every route behind those peers. Fix the specific header the legacy upstream needs, or apply a narrower setting to that one peer after confirming nothing behind it trusts forwarded headers.
Sources
- CVE-2026-105299 record, CVE Program API
- CVE-2026-105299 on cve.org
- Pingora repository advisory GHSA-vx4q-2c55-cvmg
- GitHub Advisory Database, GHSA-r9g2-75m7-w9wg (unreviewed)
- Pingora security advisories list
- Pingora commit 28c18e6, "Sanitize hop-by-hop headers on HTTP upstream requests"
- Pingora commit ca23f16, reject malformed Connection nominations
- Pingora 0.9.0 release notes
- Pingora 0.9.0 pingora-proxy/src/proxy_h1.rs
- Pingora 0.9.0 pingora/Cargo.toml (proxy feature)
- Pingora 0.8.1 pingora-proxy/src/proxy_trait.rs
- pingora-proxy on crates.io (API)
- pingora on crates.io (API)
- pingora-core on crates.io (API)
- OSV entry for CVE-2026-105299
- NVD entry for CVE-2026-105299
- CISA Known Exploited Vulnerabilities catalog (JSON feed)
- CVE-2026-105298 record, CVE Program API
- CVE-2026-105297 record, CVE Program API
- RFC 9110, section 7.6.1, Connection
- Go net/http/httputil reverseproxy.go, go1.27.3
- Go net/http/httputil reverseproxy.go, go1.26.0
- CVE-2022-31813 record, CVE Program API
- Apache HTTP Server 2.4 vulnerabilities
Last reviewed: 25 October 2026. Advisory databases were still catching up on this CVE; check the Pingora advisory and crates.io for the latest status.