GitHub Advisoryadvisory · Go

GHSA-659f-rgp5-w4wf Skipper: opaAuthorizeRequestWithBody filter bypasses OPA policy on Transfer-Encoding — chunked / HTTP/2 requests

HIGH Published 2026-07-08 Modified 2026-07-08 Official page ↗
Aliases
CVE-2026-50197
Ecosystem
Go
Packages
github.com/zalando/skipper
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N/E:U/RL:O
Weaknesses
CWE-444
Reviewed
2026-07-08

Affected packages

• Go github.com/zalando/skipper: < 0.26.10 → fixed in 0.26.10

Description

Summary `zalando/skipper`'s OpenPolicyAgent integration silently bypasses request-body inspection on HTTP/1.1 `Transfer-Encoding: chunked` and HTTP/2 requests that omit the `content-length` pseudo-header. When the `opaAuthorizeRequestWithBody` filter is configured, the `OpenPolicyAgentInstance.ExtractHttpBodyOptionally` helper produces an empty `raw_body` for any request whose `Content-Length` header is missing, while the underlying chunked body still flows through to the upstream service. Rego policies that gate on `input.parsed_body` (e.g. "deny when a forbidden field is present") evaluate against an empty document, treat the forbidden field as absent, and authorize the request. The upstream handler then receives the full attacker payload that the policy intended to block. Affected versions `github.com/zalando/skipper` versions `<= v0.26.8` (the latest release on 2026-05-26, current `master` `4eed47ff`). The vulnerable helper and gate have lived in `filters/openpolicyagent/openpolicyagent.go` since the buffered-body extractor was introduced; no released version contains the fix at the time of filing. Privilege required Unauthenticated network access to the skipper proxy listener. The threat model targets operators who place skipper in front of a private upstream and rely on `opaAuthorizeRequestWithBody` to enforce body-content checks (field allow/deny lists, payload schema gates, content-moderation flags, multi-tenant per-action authorization). Both HTTP/1.1 and HTTP/2 clients are affected; HTTP/2 traffic without a `content-length` pseudo-header is the dominant case because Go's `net/http` sets `http.Request.ContentLength = -1` for chunked HTTP/1.1 AND for HTTP/2 requests whose framing carries the body as DATA frames without an explicit length header. Root cause `filters/openpolicyagent/openpolicyagent.go:1242-1269` (HEAD `4eed47ff`): ```go func bodyUpperBound(contentLength, maxBodyBytes int64) int64 { if contentLength <= 0 { return maxBodyBytes } if contentLength < maxBodyBytes { return contentLength } return maxBodyBytes } func (opa *OpenPolicyAgentInstance) ExtractHttpBodyOptionally(req *http.Request) (io.ReadCloser, []byte, func(), error) { body := req.Body if body != nil && !opa.EnvoyPluginConfig().SkipRequestBodyParse && req.ContentLength <= int64(opa.maxBodyBytes) { wrapper := newBufferedBodyReader(req.Body, opa.maxBodyBytes, opa.bodyReadBufferSize) requestedBodyBytes := bodyUpperBound(req.ContentLength, opa.maxBodyBytes) if !opa.registry.maxMemoryBodyParsingSem.TryAcquire(requestedBodyBytes) { return req.Body, nil, func() {}, ErrTotalBodyBytesExceeded } rawBody, err := wrapper.fillBuffer(req.ContentLength) return wrapper, rawBody, func() { opa.registry.maxMemoryBodyParsingSem.Release(requestedBodyBytes) }, err } return req.Body, nil, func() {}, nil } ``` `filters/openpolicyagent/openpolicyagent.go:1195-1210`: ```go func (m *bufferedBodyReader) fillBuffer(expectedSize int64) ([]byte, error) { var err error for err == nil && int64(m.bodyBuffer.Len()) < m.maxBufferSize && int64(m.bodyBuffer.Len()) < expectedSize { var n int n, err = m.input.Read(m.readBuffer) m.bodyBuffer.Write(m.readBuffer[:n]) } if err == io.EOF { err = nil } return m.bodyBuffer.Bytes(), err } ``` When the client sends `Transfer-Encoding: chunked` (HTTP/1.1) or an HTTP/2 request without `content-length`, Go's `net/http` server sets `req.ContentLength = -1`. The gate at line 1258 (`req.ContentLength <= int64(opa.maxBodyBytes)`) is true (`-1 <= positiveLimit`), so the body gets wrapped in `bufferedBodyReader`. `bodyUpperBound(-1, max)` returns `max`, so the memory semaphore is acquired, but `fillBuffer(-1)` then evaluates `int64(m.bodyBuffer.Len()) < expectedSize` as `0 < -1`, which is false on the first iteration. The loop never enters, the buffer stays empty, and the helper returns `[]byte{}` as `rawBody` to the caller. The caller in `filters/openpolicyagent/opaauthorizerequest/opaauthorizerequest.go:121` hands this empty slice to `envoy.AdaptToExtAuthRequest` which puts it into `AttributeContext.Request.Http.RawBody`. The OPA SDK then exposes the empty buffer as both `input.attributes.request.http.raw_body` and the parsed `input.parsed_body` document (the latter becomes an empty/undefined value). Any Rego rule that asserts the presence of a forbidden field in `input.parsed_body` evaluates to undefined and fails into the rule's default (typically `allow`). Meanwhile, the wrapped `body` returned to the filter (`req.Body = body` at line 127 of `opaauthorizerequest.go`) is a `bufferedBodyReader` whose `Read()` falls through to the underlying `m.input.Read(p)` when the buffer is empty (lines 1212-1228). The upstream handler therefore reads the full attacker payload that OPA was never given a chance to inspect. Reproduction (E2E against pinned `github.com/zalando/[email protected]`) GHSA advisories have no file-attachment mechanism, so the complete `poc_test.go` source and the verbatim `go test` output are inlined below. The PoC is a Go test placed in `filters/openpolicyagent/opaauthorizerequest/poc_test.go` inside a checkout of the `v0.26.8` tag, so it links against the exact released source. It boots a real skipper proxy via `proxytest.New`, configures it with the `opaAuthorizeRequestWithBody` filter pointing at an in-process `opasdktest.MustNewServer` bundle server, installs a Rego policy that DENIES requests whose body contains `admin=true`, and adds a tiny upstream that records the body it actually received. It then drives the proxy over a raw TCP socket (`net.DialTimeout` + `http.ReadResponse`) to control the wire framing precisely, sending three requests. `poc_test.go`: ```go package opaauthorizerequest // PoC: opaAuthorizeRequestWithBody OPA-bypass on chunked / HTTP2 framing. // // The filter's body extractor (filters/openpolicyagent/openpolicyagent.go, // ExtractHttpBodyOptionally) gates on `req.ContentLength <= maxBodyBytes` // and then calls fillBuffer(req.ContentLength). When the client sends the // body with Transfer-Encoding: chunked (HTTP/1.1) or via HTTP/2 without a // declared length, net/http sets req.ContentLength = -1. The gate passes // (-1 <= max) but fillBuffer's loop condition `len(buf) < expectedSize(-1)` // is immediately false, so the buffered body is EMPTY. OPA therefore sees an // empty input.parsed_body, a deny-policy that keys on the body fails open, // and the full attacker body is forwarded upstream. // // This test boots a real skipper proxy (proxytest) with the // opaAuthorizeRequestWithBody filter pointed at an in-process OPA bundle // server (opasdktest) hosting a deny-when-admin=true policy, plus a tiny // upstream that records the body it actually received. It then drives the // proxy over a raw TCP socket to control the wire framing precisely. import ( "bufio" "fmt" "io" "net" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" opasdktest "github.com/open-policy-agent/opa/v1/sdk/test" "github.com/zalando/skipper/eskip" "github.com/zalando/skipper/filters" "github.com/zalando/skipper/filters/builtin" "github.com/zalando/skipper/proxy/proxytest" "github.com/zalando/skipper/tracing/tracingtest" "github.com/zalando/skipper/filters/openpolicyagent" ) // rawRequest opens a fresh TCP connection to addr, writes wire verbatim, and // returns the parsed HTTP response. func rawRequest(t *testing.T, addr, wire string) *http.Response { t.Helper() conn, err := net.DialTimeout("tcp", addr, 5*time.Second) if err != nil { t.Fatalf("dial %s: %v", addr, err) } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) if _, err := io.WriteString(conn, wire); err != nil { t.Fatalf("write wire: %v", err) } resp, err := http.ReadResponse(bufio.NewReader(conn), nil) if err != nil { t.Fatalf("read response: %v", err) …

Related

Alias · CVE (1)

  • CVE-2026-50197 Skipper is an HTTP router and reverse proxy for service composition. vulnerability

Weakness · CWE (1)

  • CWE-444 Inconsistent Interpretation of HTTP Requests weakness

References