Zentinel builds on Pingora, Cloudflare’s open-source proxy framework. Pingora handles the parts of proxying that are hard to get right and dangerous to get wrong: TCP connection management, TLS termination, HTTP parsing, connection pooling. Zentinel is the security and routing layer on top. When Pingora’s HTTP parser has a bug, Zentinel inherits it. There is no firewall between the two. The parser runs, the bytes are framed, and by the time Zentinel’s request filters see the request, the framing decisions have already been made.
On March 4, Cloudflare published a detailed post disclosing three CVEs in Pingora’s HTTP handling, all reported by security researcher Rajat Raghav. The vulnerabilities are real, they are exploitable under default configuration, and they affect anyone running Pingora prior to 0.8.0. That includes Zentinel, with one exception we will get to.
This post walks through each vulnerability in enough detail that you can assess your own exposure, explains what Zentinel operators could have done to reduce risk before upgrading, and documents how the upgrade itself played out.
How the vulnerabilities were found
Between December 2025 and January 2026, Rajat Raghav reported four separate issues to Cloudflare’s security team. Three received CVE identifiers. The fourth, a cache key construction weakness, was addressed in the same release but is closely related to the third CVE.
The disclosure timeline is worth reading in full because it shows responsible disclosure working as intended:
| Date | Event |
|---|---|
| December 2, 2025 | Upgrade-based request smuggling reported to Cloudflare |
| January 13, 2026 | Transfer-Encoding and HTTP/1.0 parsing issues reported |
| January 18, 2026 | Cache key construction vulnerability reported |
| January 29 to February 13 | Fixes developed and validated with the researcher |
| February 25 | Final round of fixes validated, including cache key removal and additional RFC checks |
| March 2 | Pingora 0.8.0 released with all fixes included |
| March 4 | CVE advisories published publicly |
| March 5 | Zentinel upgrades to Pingora 0.8.0 |
Two things stand out here. First, Cloudflare’s usual release cadence for Pingora is somewhere between six and twelve months. Pingora 0.7.0 shipped on January 30. Pingora 0.8.0 followed just 31 days later. A release cycle that compressed tells you the team considered these fixes urgent enough to break from their normal schedule. Second, the CVE advisories were published two days after the release, giving downstream consumers a window to upgrade before the vulnerabilities were widely known. Zentinel used that window.
Cloudflare noted in their disclosure that their own CDN was not affected by these vulnerabilities because Pingora is not used as an ingress proxy in Cloudflare’s CDN infrastructure. That distinction matters: Cloudflare uses Pingora internally for specific services, not as the front door. But for projects like Zentinel, where Pingora is the front door, these bugs sit directly in the request path.
The vulnerabilities
All three CVEs are variations on the same theme: the proxy and the backend disagree about where one HTTP message ends and the next one begins. This class of bug is called HTTP request smuggling, and it has been a persistent source of security issues in proxy software for over twenty years. The basic mechanic is always the same. A proxy reads a request and decides how many bytes belong to it. The backend reads the same bytes and comes to a different conclusion. The leftover bytes, the ones the proxy thought were part of the first request but the backend thinks are a second request, get interpreted as a new, attacker-controlled request that bypasses whatever security the proxy was supposed to enforce.
What makes smuggling bugs particularly unpleasant is that they are invisible. The proxy logs show one normal request. The backend logs show two. The second one, the smuggled one, never passed through the proxy’s security filters, its routing logic, or its agent chain. It just appeared on the backend as if it came from nowhere.
CVE-2026-2833: HTTP/1.0 and Transfer-Encoding
HTTP/1.0 does not define Transfer-Encoding. The header was introduced in HTTP/1.1 alongside chunked transfer coding, and the two are inseparable: chunked encoding only exists in the context of HTTP/1.1 persistent connections where you need a way to delimit messages without closing the connection. HTTP/1.0 has no such need because every response ends with the server closing the connection.
Pingora, prior to 0.8.0, did not enforce this distinction. When it received an HTTP/1.0 request with a Transfer-Encoding: chunked header, it accepted it and attempted to parse the body as chunked. This is where the disagreement starts. Many backend servers, following the spec correctly, ignore Transfer-Encoding on HTTP/1.0 requests entirely. They look at Content-Length instead, or they assume no body at all.
The result is a classic CL.TE desync. The proxy reads the body using chunked encoding and consumes some number of bytes. The backend reads the body using Content-Length and consumes a different number of bytes. The bytes that the proxy consumed but the backend did not, or vice versa, become the smuggled request.
There were actually several related issues bundled into this CVE. Beyond the HTTP/1.0 problem, Pingora also had incomplete chunked encoding recognition in some code paths and could incorrectly enter close-delimited body mode, where it would read until the connection closed rather than looking for explicit message boundaries. Each of these created a different flavor of the same fundamental problem: ambiguous message framing.
The fix in Pingora 0.8.0 is straightforward in principle. HTTP/1.0 requests with Transfer-Encoding are now rejected. Chunked encoding recognition is consistent across all code paths. Close-delimited mode is only entered when the protocol actually calls for it. The complexity is in getting every edge case right, which is why the validation period with the researcher took several weeks.
CVE-2026-2835: Premature upgrade passthrough
HTTP upgrades are how protocols like WebSocket get bootstrapped over HTTP. The client sends a normal HTTP request with Connection: Upgrade and Upgrade: websocket headers. The server responds with 101 Switching Protocols, and from that point forward the connection carries WebSocket frames instead of HTTP messages. The key detail is the sequencing: the protocol switch happens after the server confirms it with 101. Until that confirmation arrives, the connection is still HTTP.
Pingora did not respect this sequencing. When it saw an upgrade request from the client, it entered passthrough mode immediately, before the backend had responded. In passthrough mode, Pingora stops parsing HTTP and just shuffles bytes between the client and the backend. This is correct behavior after a successful upgrade, but premature before one.
The attack works like this. An attacker sends an HTTP upgrade request followed immediately by a second, separate HTTP request on the same connection. This is called pipelining. Because Pingora has already entered passthrough mode, it does not parse the second request as HTTP. It treats it as raw upgrade data and forwards it to the backend. The backend, which may not have even accepted the upgrade yet, receives these bytes and interprets them as a new HTTP request. The attacker has now smuggled a request past the proxy.
This is particularly concerning when the smuggled request can reach a different virtual host or a different route than the one the upgrade was requested for. The proxy’s routing, authentication, rate limiting, and agent chain are all bypassed for the smuggled request because the proxy never saw it as a request at all.
The fix ensures that Pingora stays in HTTP parsing mode until it receives a 101 response from the backend. Only after the backend confirms the upgrade does Pingora switch to passthrough. If the backend rejects the upgrade, the connection continues as normal HTTP, and any pipelined data is parsed as HTTP requests and routed through the normal security pipeline.
CVE-2026-2836: Cache key host collision
This one is different from the other two. It is not a smuggling bug in the traditional sense, but a cache poisoning vulnerability that arises from an incomplete cache key.
Pingora’s default CacheKey implementation constructed the key using only the URI path. It did not include the Host header. This means that if two different virtual hosts both served a resource at /api/config, their responses would map to the same cache entry. An attacker who controls one virtual host, or who can send requests with an arbitrary Host header, could populate the cache with a response that would then be served to requests intended for a completely different host.
Cache poisoning has a multiplicative effect. A single poisoned response gets served to every subsequent request that matches the cache key until the entry expires. In a multi-tenant environment where Zentinel serves traffic for multiple domains, this could mean one tenant’s response being served to another tenant’s users.
The fix in Pingora 0.8.0 removes the default CacheKey constructor entirely. Users must now implement cache_key_callback and construct their own keys. This forces you to think about what should go into the key rather than relying on a default that was never safe for multi-host deployments.
How each CVE affected Zentinel
The three vulnerabilities did not affect Zentinel equally. One was never exploitable, one depended on your configuration, and one applied broadly. Understanding the differences matters for assessing your actual risk rather than treating all three as a single event.
CVE-2026-2836 (cache key poisoning): Zentinel was not affected
Zentinel has never used Pingora’s default cache key. From the first version that supported caching, Zentinel implemented its own cache_key_callback that includes the Host header, the HTTP method, and relevant request headers in the key. Two requests to /api/config on different hosts produce different cache keys, and there is no collision.
This was not a deliberate mitigation against this specific CVE, which had not been discovered yet when we wrote the cache integration. It was just the obvious thing to do. A cache key without the host is only safe if you serve exactly one domain, and a reverse proxy that can only serve one domain is not very useful. We included the host because not including it would have been a bug in our own caching logic.
There is a second layer of protection as well. Caching in Zentinel is disabled by default. You have to explicitly configure it per route. Operators who never enabled caching had no cache to poison regardless of how the key was constructed.
For these reasons, no mitigation was needed for CVE-2026-2836, and no mitigation would have been needed even if we had been slow to upgrade. The vulnerable code path was never reachable in Zentinel.
CVE-2026-2833 (HTTP/1.0 desync): Zentinel was affected
This vulnerability affected Zentinel on Pingora 0.7 and earlier. There was no way to fully prevent exploitation through Zentinel configuration because the bug is in Pingora’s HTTP parser. By the time Zentinel’s request filters, agent chain, or routing logic run, Pingora has already decided how to frame the request body. If that framing decision is wrong, everything downstream inherits the error.
The practical risk depended on your deployment topology. The attack requires sending an HTTP/1.0 request with Transfer-Encoding, which means the attacker needs to reach Zentinel with HTTP/1.0 traffic. In many production deployments, Zentinel sits behind a load balancer or CDN that normalizes traffic to HTTP/1.1 or HTTP/2 before it reaches the proxy. In those environments, the attack surface was limited because the crafted HTTP/1.0 request would be rejected or rewritten before it reached Pingora’s parser.
Deployments where Zentinel was directly exposed to the internet, accepting connections without an upstream load balancer, had the most exposure. Any client could send HTTP/1.0 requests, and Pingora would accept them.
The severity also depended on the backend. CL.TE desync requires the backend to interpret the body differently than the proxy. If the backend also mishandled Transfer-Encoding on HTTP/1.0 in the same way Pingora did, there would be no disagreement and no smuggling. The attack is most effective when the backend strictly follows the spec and ignores Transfer-Encoding on HTTP/1.0, which is the correct behavior and what most modern HTTP servers do.
What operators could have done before upgrading:
The most effective mitigation was to prevent HTTP/1.0 requests from reaching Zentinel. If you had a load balancer in front of Zentinel, most modern load balancers can be configured to reject HTTP/1.0 or upgrade it to HTTP/1.1. AWS Application Load Balancer, GCP Cloud Load Balancing, and HAProxy all support this. Enforcing HTTP/1.1 as a minimum version at the load balancer eliminates the HTTP/1.0 specific attack vector entirely because the crafted request never reaches Pingora in a form that triggers the bug.
If no load balancer was available, a Zentinel WAF agent could have been configured to inspect incoming request headers for the problematic combination: an HTTP/1.0 request carrying a Transfer-Encoding header. An agent that blocked such requests would have caught the attack at Zentinel’s application layer. This is not a perfect mitigation because the agent sees the headers as Pingora has already parsed them, and some of the body framing decisions have already been made by that point. But it catches the obvious case and provides a useful signal in logs.
Reducing connection reuse also helped limit the blast radius, though it did not prevent the attack itself. Smuggling attacks depend on the smuggled request being processed on the same connection as the original request. If you shortened the idle-timeout on your listeners, the window during which a poisoned connection could be reused for a smuggled request was smaller. This does not stop an attacker from smuggling a request, but it reduces the chance that the smuggled request hits a different user’s session.
Finally, using HTTP/2 for upstream connections between Zentinel and backends eliminated the backend side of the desync. HTTP/2 uses binary framing with explicit stream multiplexing, and there is no ambiguity about message boundaries. If the backend only speaks HTTP/2, the smuggled bytes cannot be misinterpreted as a separate HTTP/1 request because the backend is not parsing HTTP/1 at all. This required backend support for HTTP/2 and configuring the upstream with h2 transport, but it closed the gap completely on the backend side.
None of these workarounds were substitutes for the actual fix. They reduced the attack surface or limited the damage, but the underlying parser bug remained. The only real fix was Pingora 0.8.0.
CVE-2026-2835 (premature upgrade): Zentinel was conditionally affected
This vulnerability only affected Zentinel deployments that proxy WebSocket traffic. If your configuration does not include routes that handle WebSocket connections, the vulnerable code path was never reached. Pingora only enters upgrade handling when it sees an Upgrade header, and if your routes reject or ignore such headers, there is no upgrade to be premature about.
For deployments that do proxy WebSocket traffic, the vulnerability was real and concerning. The premature passthrough meant that any client connecting to a WebSocket endpoint could pipeline a second HTTP request that would bypass Zentinel’s entire security pipeline. The smuggled request would arrive at the backend as if it came directly from the network, without passing through routing, authentication, rate limiting, or the agent chain.
The impact was worst in deployments where WebSocket endpoints shared backend infrastructure with regular HTTP endpoints. If the smuggled request could reach an HTTP endpoint on the same backend, the attacker could access that endpoint without any of the security controls that Zentinel would normally enforce. In deployments where WebSocket backends were isolated, with dedicated servers that only handled WebSocket traffic, the blast radius was contained to the WebSocket service itself.
What operators could have done before upgrading:
The simplest mitigation was to disable WebSocket proxying on routes that did not need it. If a route was not intended to serve WebSocket traffic, configuring a filter agent to reject requests with Upgrade headers on that route would have prevented the premature upgrade from being triggered. This does not help for routes that legitimately need WebSocket, but it eliminates accidental exposure on routes that were not designed for it.
For routes that did need WebSocket, isolating them on a separate listener with a dedicated upstream pool would have contained the damage. If the WebSocket listener connects to backends that only handle WebSocket traffic, a smuggled HTTP request arriving at those backends would be rejected because the backend does not serve HTTP. The attacker might break the WebSocket connection, but they cannot reach HTTP endpoints behind Zentinel’s security layer.
A more involved approach was to deploy a WAF agent that inspected request sequences on upgrade connections. An agent that detected HTTP request data arriving after an upgrade handshake was initiated, but before a 101 response was received, could flag the connection as suspicious and terminate it. This requires custom agent logic and careful timing, but it directly targets the attack pattern that the CVE describes.
As a last resort, placing a stricter HTTP reverse proxy in front of Zentinel, something like HAProxy or Envoy that correctly handles upgrade sequencing, would have normalized the connection before traffic reached Pingora. The upstream proxy would hold the connection in HTTP mode until the backend confirmed the upgrade, preventing the premature passthrough from being triggered. This adds latency and operational complexity, so it was best treated as a temporary measure while waiting for the upgrade.
How the upgrade happened
Pingora 0.8.0 was released on March 2. The CVE advisories were published on March 4. Zentinel’s upgrade commit landed on March 5, less than 24 hours after the public disclosure.
The speed was not a heroic effort. It was the result of a practice we have followed since Zentinel started depending on Pingora: upgrade within days of every release, regardless of whether we know about specific security issues. We upgraded to 0.7.0 within three days of its release in January. We did the same for 0.8.0 in March. The pattern is the same each time. We read the release notes, evaluate the breaking changes, adapt our code, run the test suite, and ship.
The 0.8.0 upgrade touched 14 files with 121 insertions and 50 deletions. Most of the changes were API adaptations: Pingora replaced http_proxy_service() with a new ProxyServiceBuilder, removed CacheKey::default() in favor of explicit construction, and marked HttpServerOptions as #[non_exhaustive]. These are the kinds of changes that take an afternoon to work through if you are familiar with the codebase and have good test coverage. The full details are in our Pingora 0.8 upgrade post.
There are two reasons this works reliably. The first is that we stay close to upstream. When you upgrade every release, the diff between versions is small. There are a few API changes, some new features, and a set of bug fixes. Each upgrade is a manageable amount of work. If you skip releases and try to jump from 0.5 to 0.8, you are dealing with every breaking change from three releases at once, and the upgrade becomes a project rather than a task.
The second reason is that Zentinel keeps its Pingora integration surface small. Zentinel uses Pingora for I/O, connection pooling, HTTP parsing, and TLS. Security policy, routing logic, agent communication, and configuration management are all implemented in Zentinel’s own code. The fewer Pingora APIs we touch, the fewer places we need to adapt when those APIs change. This is not about distrust of Pingora. It is about making upgrades predictable.
What Zentinel operators should do now
If you are running Zentinel 26.03_2 (cargo v0.6.1) or later, you are already patched. All three CVEs are resolved. There is nothing to do.
If you are running an older version of Zentinel, you should upgrade. The HTTP smuggling vulnerabilities, CVE-2026-2833 and CVE-2026-2835, cannot be fully mitigated through configuration. The bugs are in the HTTP parser, and the only way to fix them is to run the version of Pingora that has the corrections.
# From source
cargo install zentinel-proxy
# Container
docker pull ghcr.io/zentinelproxy/zentinel:26.03_2
# Binary
curl -fsSL https://get.zentinelproxy.io | sh
If you cannot upgrade immediately, apply these mitigations in priority order to reduce your exposure while you plan the upgrade:
Enforce HTTP/1.1 minimum at your load balancer. This addresses CVE-2026-2833 by preventing the crafted HTTP/1.0 requests from reaching Pingora. Most load balancers support this and it is a configuration change, not a code change.
Block or isolate WebSocket upgrade requests on routes that do not need them. This addresses CVE-2026-2835 by removing the attack surface on non-WebSocket routes. A simple filter agent that rejects requests with
Upgradeheaders is sufficient.Use HTTP/2 for upstream connections where your backends support it. This reduces the smuggling surface by eliminating HTTP/1 body framing ambiguity on the backend side.
Shorten listener
idle-timeoutto limit connection reuse windows. This does not prevent smuggling but reduces the window during which a poisoned connection can be exploited.
These measures buy time. They are not permanent solutions, and they do not fully close the vulnerabilities. Upgrade when you can.
Dependency management at the edge
A reverse proxy occupies a particular position in your infrastructure. It is the first thing that parses traffic from the internet, and it is the last thing that can enforce policy before that traffic reaches your backends. When the parser is wrong, the policy enforcement is meaningless because the smuggled request never goes through it.
This is why we treat Pingora upgrades the way we do. Not because we expect every release to contain security fixes, but because when one does, we want the upgrade to be routine rather than an emergency. If the last time you upgraded Pingora was six months ago, an urgent security release means you are dealing with six months of API changes under pressure. If you upgraded last month, you are dealing with one month of changes, and you have recent experience with the upgrade process.
Our dependency policy comes down to a few practices:
We track upstream releases within days. Not because every release is urgent, but because staying close makes each upgrade small and predictable.
We maintain a fork only when upstream is behind on a security patch, and we drop it the moment upstream catches up. Right now we carry a single-commit fork that bumps a transitive prometheus dependency to a safe version. The moment Cloudflare releases 0.8.1 or 0.9.0 with that fix included, the fork goes away. We have been through this cycle twice now.
We run cargo audit in CI on every push and every pull request. Advisory databases are imperfect, and there is always a window between a vulnerability being discovered and an advisory being published. But catching known issues automatically is the minimum.
We keep the integration surface with Pingora small. Zentinel’s security logic, routing engine, agent protocol, and configuration system are all our own code. Pingora handles the things it is good at: I/O, connection management, HTTP parsing, TLS. The less we depend on, the less we need to change when we upgrade, and the faster we can move when it matters.
The Pingora team at Cloudflare handled this disclosure well. They worked with the researcher over several months, validated fixes carefully, shipped a release, and published clear advisories with enough technical detail for downstream consumers to assess their own exposure. Our job was to stay close enough to pick up the fix quickly, and to have enough understanding of our own Pingora integration to assess impact accurately.
For the full technical breakdown of every change in Pingora 0.8.0, including the non-security features like keepalive-max-requests, upload write-pending diagnostics, and the new ProxyServiceBuilder API, see our detailed upgrade post.