Safeguard
Supply Chain Security•

Pingora CVE-2026-105299: Connection Header Strips X-Forwarded-For

Pingora before 0.9.0 let a client's Connection header make a later proxy drop X-Forwarded-For, bypassing IP checks. Who is affected and how to fix it.

Safeguard Security Team
35 min read

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:34pingora-proxy 0.1.0, the first affected version, published to crates.io
2026-06-03 21:44Fix commit 28c18e6 authored
2026-06-04 20:10pingora-proxy 0.8.1 published, without the fix
2026-06-19 22:05Commit 28c18e6 merged to main
2026-07-23 20:00Hardening commit ca23f16 authored
2026-08-25 17:30Commit ca23f16 merged
2026-09-09 23:32pingora-proxy 0.9.0 published (release notes are headed 4 September)
2026-09-09 23:34GitHub release 0.9.0 lists the stripping behavior as a potential breaking change
2026-10-05 04:19CVE-2026-105299 reserved by Cloudflare
2026-10-20 00:40 and 00:46Sibling CVEs CVE-2026-105297 and CVE-2026-105298 published
2026-10-20 01:07CVE-2026-105299 published, CVSS 3.1 score 8.7
2026-10-20 02:17NVD receives the record
2026-10-20 03:32Global advisory GHSA-r9g2-75m7-w9wg published, unreviewed, no package
2026-10-20 16:25Repository advisory GHSA-vx4q-2c55-cvmg published, CVSS 4.0 score 7.0
2026-10-20 19:18CISA ADP adds SSVC and CWE-444
2026-10-23KEV catalog 2026.10.23 does not list the CVE
2026-10-24 02:49OSV 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:

  1. Pingora at the edge receives Connection: keep-alive, X-Forwarded-For. The application's upstream_request_filter adds X-Forwarded-For with the real client address. The client's Connection header goes along too.
  2. An HTTP/1 proxy in the middle follows the RFC. It deletes X-Forwarded-For as told, then adds its own X-Forwarded-For with the address it received the request from, which is Pingora's.
  3. 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

FieldValue
CVE IDCVE-2026-105299
TitlePingora forwards client-nominated Connection headers to HTTP/1 upstreams, allowing ACL bypass in specific chained deployments
CNACloudflare
Reserved / published2026-10-05 04:19 UTC / 2026-10-20 01:07:12 UTC
Affected0.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 commits28c18e6b6b04dd0bd937234e5950049e6d74cded, 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.

SettingDefaultWhat it does
strip_hop_by_hoptrueRemoves standard hop by hop fields from the downstream request
strip_connection_nominatedtrueRemoves fields named in Connection and rejects protected nominations
h1_upgradeH1UpgradePolicy::WebSocketOnlyForwards 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:

  1. Splits every Connection value into trimmed tokens.
  2. Rejects the request at ten or more nominations, a cap the code comment says keeps token processing from becoming "a request-time DoS vector".
  3. Rejects any token that starts with a colon (a pseudo header) or equals host, x-forwarded-for, x-forwarded-host or x-forwarded-proto, ignoring case, with "protected header cannot be nominated by the Connection header".
  4. Removes every nominated header, then the standard set: keep-alive, proxy-connection, proxy-authenticate, proxy-authorization, te, trailer, transfer-encoding, connection, upgrade and http2-settings.
  5. For a valid WebSocket upgrade from an HTTP/1.1 client, puts back exactly Connection: Upgrade and Upgrade: 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:

SourceWhat it saysWill tools flag pingora-proxy from it?
CVE recordProduct is the GitHub repository; 0.1.0 up to 0.9.0Only tools that map repositories to crates
GHSA-vx4q-2c55-cvmg (repository)pingora-proxy before 0.9.0, patched 0.9.0Not 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
OSVA git commit range, extracted as 0.1.0 to 0.9.0Not for crates.io lookups
RustSecNo identifier found in OSV's crates.io dataNo
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 versionsPublishedStatus
0.1.0, 0.1.1, 0.2.0, 0.3.0, 0.4.0February to November 2024Affected
0.5.0, 0.6.0May and August 2025Affected
0.7.0, 0.8.0January and March 2026Affected
0.8.14 June 2026Affected (fixes CVE-2026-105297 only)
0.9.09 September 2026Fixed

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.

ConditionHow to check
pingora-proxy before 0.9.0, directly or via pingora with proxycargo tree -i pingora-proxy
At least one upstream reached over HTTP/1HttpPeer options and ALPN settings for each upstream
An HTTP/1 hop between Pingora and the origin that honors ConnectionMap sidecars, internal gateways, Go services built on httputil.ReverseProxy
Your filter adds a header something downstream trustsRead upstream_request_filter for X-Forwarded-For, X-Real-IP, identity headers
The origin makes a security decision from itLook 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 = false disables both stripping and the protected check.
  • h1_upgrade = H1UpgradePolicy::Preserve skips normalization for any request with an Upgrade header, 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-For from 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, or safeguard 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 ship pingora-proxy before 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-proxy before 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 DerefMut errors and searching for preserve(), under the /permissions mode 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 audit as 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 = false to 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-For at 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 situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingThe Gold scan GitHub Action in CI, plus Safeguard policies and gates
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch (early access)
You want to start clean on new projectsPull 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

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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.