GHSA-659f-rgp5-w4wf Skipper: opaAuthorizeRequestWithBody filter bypasses OPA policy on Transfer-Encoding — chunked / HTTP/2 requests
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 requiredUnauthenticated 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`):`filters/openpolicyagent/openpolicyagent.go:1195-1210`: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/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`:The three requests cover:| # | Wire framing | Body | Expected | Got |
|---|--------------|------|----------|-----|
| 1 | `Content-Length: 15` | `{"admin":false}` | 200 ALLOW | 200 (upstream reached) |
| 2 | `Content-Length: 14` | `{"admin":true}` | 403 DENY  | 403 (OPA blocked) |
| 3 | `Transfer-Encoding: chunked` | `{"admin":true}` | 403 DENY | **200 ALLOW (bypass)** |Test 3 wire bytes: `POST /priv HTTP/1.1\r\nHost: ...\r\nContent-Type:
application/json\r\nConnection: close\r\nTransfer-Encoding:
chunked\r\n\r\ne\r\n{"admin":true}\r\n0\r\n\r\n`.Run command and verbatim output (`go version go1.26.3 darwin/arm64`,
`github.com/open-policy-agent/opa v1.14.1` as pinned by skipper `v0.26.8`):Test 1 (Content-Length, benign body) is allowed and reaches upstream with
`{"admin":false}`. Test 2 is the negative control: the same `{"admin":true}`
payload sent with a `Content-Length` header is correctly DENIED (HTTP 403)
and never reaches the upstream, proving the policy itself is sound. Test 3
sends the identical forbidden body as `Transfer-Encoding: chunked`; OPA
evaluates an empty `parsed_body`, the deny rule fails open, the proxy
returns HTTP 200, and the upstream receives the full `{"admin":true}`
payload that the policy was configured to block.ImpactOperators relying on `opaAuthorizeRequestWithBody` for body-content
authorization are silently downgraded to header/path-only authorization
for any client that emits chunked or HTTP/2 requests. Concrete
production patterns affected:"Deny when body contains admin/privileged field" guardrails for
multi-tenant or role-stratified APIs;"Deny when content-moderation flag is present" filters in front of
user-content endpoints;"Deny when payload schema version is forbidden" gates for
deprecated-API shutdown;"Deny when SQL/command-injection-shaped string is present" generic
body validators.In every case the chunked attack arrives with the forbidden body
intact, OPA evaluates against an empty document, the policy fails open,
and the upstream receives the attacker payload that the deployment was
specifically configured to block. There is no log signal in OPA's
decision log distinguishing "body was empty" from "client did not send
a body".Note that the OPA decision log will record the request as ALLOWED with
`raw_body` length 0, which complicates post-hoc forensic detection. The
upstream's request log will show the full body, producing a confusing
allow/observed asymmetry across systems.CVSS 3.1: `AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N` (8.5 HIGH). Scope is
Changed because the failure crosses the trust boundary between the
authorization filter and the upstream service.Suggested fix`filters/openpolicyagent/openpolicyagent.go`:After the fix, `fillBuffer(maxBodyBytes)` reads chunked bodies up to
the configured cap. If the body exceeds the cap, the existing `Read()`
path already returns the wrapped reader for downstream consumption,
matching the documented behaviour for over-cap requests today; the
operator-configured `maxRequestBodyBytes` continues to be the single
knob governing memory allocation.A regression test `TestOpaAuthorizeRequestWithBody_ChunkedBodyIsParsed`
in `filters/openpolicyagent/opaauthorizerequest/opaauthorizerequest_test.go`
should send a `Transfer-Encoding: chunked` request matching a
blacklist policy and assert HTTP 403, and a same-payload Content-Length
request to confirm parity.CreditReported by tonghuaroot.
CVSS v3.1 score: 10