Go · github.com/zalando/skipper
Skipper: opaAuthorizeRequestWithBody filter bypasses OPA policy on Transfer-Encoding — chunked / HTTP/2 requests
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.
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.
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.
filters/openpolicyagent/openpolicyagent.go:1242-1269 (HEAD 4eed47ff):
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:
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.
github.com/zalando/skipper@v0.26.8)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:
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
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.