OpenChoreo’s cluster-gateway accepted requests on its internal listener without authenticating the caller, and its “read-only” proxy was not actually read-only. Anyone who could reach that listener could read tenant Secrets, create or delete workloads, and exec into pods across every connected data plane.
OpenChoreo is an open-source developer platform for Kubernetes. A central control plane manages one or more data-plane clusters, and the cluster-gateway tunnels management traffic from the control plane into each data plane’s Kubernetes API.
That makes the gateway a high-value trust boundary: whoever can talk to it effectively talks to every connected cluster.
How I found it
I found this bug while reviewing access control in openchoreo-api. I had already found that its exec and wirelogs endpoints let a user reach another project’s workloads, reported as GHSA-52gf-6rpq-fgmx / CVE-2026-73841.
That finding lived in the API authorization layer, which raised the follow-up question: after the API handles a request, does the cluster-gateway authorize what it receives, or does it trust the API to have done that?
I traced a forwarded request from the internal listener in internal/cluster-gateway/server.go into validator.go. The listener stood up an internal proxy but never authenticated the caller. The validator checked request shape, but did not constrain it to read-only operations, so forwarded requests could carry arbitrary Kubernetes verbs to the data plane.
Root cause
The internal listener used tls.RequestClientCert, which requests a certificate but does not verify it. The handlers never inspected r.TLS.PeerCertificates.
There was no per-tenant authorization. The crNamespace and crName fields were metadata only, so a caller could target any plane.
The “read-only” proxy allowed full CRUD. Its request validator allowed write methods and blocked only one meaningful path: kube-system Secrets.
Together, a network-reachable caller could receive unauthenticated read and write access across connected clusters.
Impact
An attacker who could reach the internal listener, with no credentials, could:
read Secrets in any tenant namespace, including database passwords, API keys, and service-account tokens;
create, modify, or delete workloads; and
exec into pods on every connected data plane.
The published vector is AV:A/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H. The adjacent attack vector reflects that the listener is internal; changed scope reflects that one component compromise reaches separate data planes.
Proof of concept
A unit test against the real validator shows that a tenant Secret read and every write method passed, while the single blocked path was rejected:
go test ./internal/cluster-gateway/ -run TestVuln_GatewayProxy_NotReadOnly_TenantSecretsReachable passed on the vulnerable version. End to end, a client that could reach the internal port could send the same Kubernetes paths through the proxy without a certificate or token.
Scoring the severity
I scored the report conservatively as High because exploitation depends on whether the internal port is network-isolated, which source code does not control. The maintainers published it as Critical 9.0 with Scope: Changed, reflecting that impact lands in separate data-plane clusters. Both readings are defensible.
Fix and timeline
The maintainers added authentication enforcement on the internal listener and shipped it through PRs #4256, #4258, and #4259. Deployments should upgrade to 1.0.3, 1.1.3, or 1.2.0.
Date
Event
Jul 17
Reported through GitHub private vulnerability reporting
Jul 20
Maintainers accepted the report and opened a private fork for the fix
Jul 21
Maintainer posted a technical analysis confirming the root cause
Jul 27
Advisory published and CVE requested
Aug 14
GitHub assigned CVE-2026-73842
It took 10 days from report to published advisory. Thanks to the OpenChoreo maintainers, especially @mevan-karu and @yashodgayashan, for fast triage and clear communication.
Takeaways
“Internal” is not an authentication mechanism. Every hop that can touch a cluster API needs its own caller check.
Enforce restrictions on the server, not in the client. A client library promising read-only behavior cannot protect a server that accepts writes.
Control planes that proxy into many clusters share this risk. In the same review, I also reported CVE-2026-73841 and CVE-2026-73840.
Traefik forwarded a client’s Upgrade: h2c request to the backend. If the backend accepted it and answered 101 Switching Protocols, Traefik turned the connection into a raw byte tunnel and stopped applying its routers and middleware. An attacker could open the tunnel through an unauthenticated route, then send HTTP/2 requests to protected paths on the same backend, bypassing BasicAuth, ForwardAuth, IPAllowList and RateLimit.
Traefik is a widely used open-source reverse proxy. Its security model is per request: each incoming HTTP request is matched to a router, and that router’s middleware (authentication, IP allowlists, rate limits) runs before the request reaches the backend. Two routers can point at the same backend with different protection, for example a public /public and an authenticated /admin.
HTTP/1.1 lets a client ask to switch protocols with Connection: Upgrade and an Upgrade: <token> header. WebSocket is the common case. h2c is another: it asks to continue the same connection as cleartext HTTP/2. Once a server answers 101 Switching Protocols, the connection stops being HTTP/1.1 and a proxy can only pass bytes through.
Traefik already supports h2c backends when an operator configures them explicitly (h2c:// or transportH2C). This bug is different: the client chose the upgrade, and Traefik let it through regardless of configuration.
Root cause
Two things combine. Traefik forwards an arbitrary client-chosen upgrade token, and its routing and middleware run only on the first HTTP/1 request, not on what comes through the tunnel afterward. In the default proxy (pkg/proxy/httputil), the request path is:
pkg/middlewares/forwardedheaders/forwarded_header.go — removeConnectionHeaders preserves and forwards the Upgrade header when Connection: Upgrade is present, with no check that the token is websocket.
pkg/proxy/httputil/proxy.go — isWebSocketUpgrade gives WebSocket special header handling, but this is not an allowlist. Other upgrade protocols still pass through.
pkg/server/service/smart_roundtripper.go — a request with Connection: Upgrade is sent to the backend over HTTP/1, letting the backend perform the upgrade.
net/http/httputil.ReverseProxy — when the backend returns 101, the reverse proxy switches to tunnel mode and just copies bytes.
The security boundary breaks at the last step. The router and middleware were chosen for the initial request only. After 101, Traefik no longer parses the stream as HTTP requests, so it never re-runs routing or middleware for the HTTP/2 streams that follow.
The /admin request never reaches the /admin router: it rides the tunnel that /public opened, so none of /admin’s middleware runs.
Proof of concept
I reproduced it against Traefik built from master (commit 9bb0e55, Go 1.26.4) with default configuration. The setup is a backend exposing /public and /admin, and a Traefik config where two routers point at that one backend but only /admin has basicAuth. The attacker confirms /admin is protected, opens an h2c tunnel through /public with no credentials, then sends /admin over the resulting HTTP/2 connection. The key request is a plain HTTP/1 call carrying the upgrade headers:
GET /public HTTP/1.1
Host: x
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: AAMAAABkAAQAoAAAAAIAAAAA
The backend answers 101, and from there the attacker speaks HTTP/2 to /admin on the same socket. The result:
[1] Direct GET /admin (no creds) -> 401 "401 Unauthorized"
[2] Upgrade: h2c to /public (no auth) -> "HTTP/1.1 101 Switching Protocols"
[3] HTTP/2 GET /admin over tunnel -> 200 "ADMIN SECRET DATA (proto=HTTP/2.0 path=/admin)"
Step 1 proves the control: /admin returns 401 to a direct unauthenticated request. Step 3 returns 200 for the same endpoint once the tunnel is open. The full backend and attacker code is in the GitHub advisory.
Impact and preconditions
An unauthenticated attacker can bypass middleware protecting other paths on the same backend: BasicAuth, ForwardAuth, IPAllowList, RateLimit and per-route security headers. Tunneled requests also skip Traefik’s access logs, metrics and tracing, so the activity does not show up as individual requests.
It is not a blanket “Traefik plus any HTTP/2 backend” issue. Four conditions must hold: the attacker can reach a router without the relevant middleware; that router shares a backend with a protected router; the backend accepts Upgrade: h2c and returns 101; and Traefik lets the upgrade complete. The last is always true on affected versions; the backend’s behavior is the real gate. Recent golang.org/x/net/http2/h2c servers no longer support the HTTP/1.1 upgrade, so they are not affected, but older implementations, non-Go servers, custom h2c handlers and some gRPC stacks may be.
The CVSS v4 vector is AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N (7.0). The AT:P (attack requirements: present) captures that backend precondition, and the subsequent-system confidentiality and integrity are High because the impact lands on the protected backend.
Fix and timeline
The fix stops Traefik forwarding the Upgrade: h2c token and the HTTP2-Settings header. Upgrade: websocket is unaffected. It shipped in 2.11.57 and 3.7.13 via PR #13797. Versions 3.4.2 through 3.6 are end-of-life and also affected; those users must move to 3.7.13.
Date (2026)
Event
Sep 7
Advisory published by the Traefik maintainers
Sep 9
CVE-2026-88008 reserved
Sep 10
Published to NVD and the GitHub Advisory Database
Takeaways
A protocol upgrade is a security boundary. Any proxy that enforces policy per HTTP request loses that enforcement the moment the connection becomes an opaque tunnel. If the client gets to choose the upgrade, the client gets to choose when your middleware stops running.
Forward an allowlist, not a denylist. Traefik handled WebSocket specially but passed every other upgrade token through. The safe shape is the reverse: forward only the protocols the proxy actually supports, reject the rest.
Look for the same pattern elsewhere. Any reverse proxy, API gateway or service mesh that supports WebSocket upgrades is worth checking for whether it also forwards h2c or arbitrary upgrade tokens, and whether policy survives the switch. This is a bug class, not a single bug.