Files
treg/tests/test_body_encoding.py
SToneX ceea47c754 test(catalog): drop per-provider row restatements
AGENTS.md: per-endpoint tests that restate catalog declarations are
deleted; the validator and the adapter round-trip already judge rows.
Keep tests of provider-specific settlement and routing code.
2026-09-25 21:09:53 +08:00

70 lines
3.1 KiB
Python

"""Body-encoding escape hatch.
Some upstream edges (Cloudflare, incl. Render's) 403 a request whose body matches an injection
signature -- a skill recipe or a proxied `call` that legitimately carries SQL/HTML. A client can
base64/gzip-encode the body and mark it with `X-Treg-Body-Encoding`; the edge then sees only opaque
bytes, and the server decodes before any route reads it. This covers both the JSON endpoints
(POST /skills, parsed by Pydantic) and the /call proxy (which relays request.body() upstream).
"""
from __future__ import annotations
import base64
import gzip
import json
import pytest
from httpx import AsyncClient
from treg.bootstrap_http import _decode_request_body
# ---- the pure decoder --------------------------------------------------------------------
@pytest.mark.parametrize("encoding,raw,encode", [
("base64", b"SELECT 1", base64.b64encode),
("gzip", b"DROP TABLE x", gzip.compress),
("base64+gzip", b"UNION SELECT * FROM secrets", lambda raw: base64.b64encode(gzip.compress(raw))),
])
def test_decode_request_body(encoding, raw, encode):
assert _decode_request_body(encode(raw), encoding) == raw
def test_decode_unknown_step_raises():
with pytest.raises(ValueError):
_decode_request_body(b"x", "rot13")
# ---- POST /skills (Pydantic): the exact import path the WAF blocked ----------------------
async def test_skills_accepts_encoded_sql_recipe(clients: AsyncClient):
recipe = "SELECT * FROM users WHERE 1=1; DROP TABLE students; -- WAF bait"
payload = json.dumps({"name": "waf-recipe", "recipe": recipe, "secrets": [], "tools": []}).encode()
r = await clients.post(
"/skills", content=base64.b64encode(payload),
headers={"X-Treg-Body-Encoding": "base64", "Content-Type": "application/json"},
)
assert r.status_code == 200, r.text
bid = next(b["id"] for b in (await clients.get("/bundles")).json() if b["name"] == "waf-recipe")
assert (await clients.get(f"/bundles/{bid}")).json()["recipe"] == recipe # stored decoded, verbatim
# ---- the /call proxy relays the DECODED body upstream ------------------------------------
async def test_proxy_relays_decoded_body_upstream(clients: AsyncClient):
sid = (await clients.post("/secrets", json={"name": "wx-k", "value": "SEK"})).json()["id"]
await clients.post("/tools", json={"name": "wx", "base_url": "https://api.wx.com", "secret_id": sid})
sql = "INSERT INTO t VALUES ('a'); SELECT * FROM t WHERE x=1 OR 1=1"
r = await clients.post(
"/call/https://api.wx.com/echo", content=base64.b64encode(sql.encode()),
headers={"X-Treg-Body-Encoding": "base64", "Content-Type": "text/plain"},
)
assert r.status_code == 200, r.text
assert r.json()["body"] == sql # upstream saw the real body, not the base64 envelope
# ---- a malformed encoded body is a clean 400, not a 500 ----------------------------------
async def test_bad_encoding_is_400(clients: AsyncClient):
r = await clients.post(
"/skills", content=b"this is not gzip",
headers={"X-Treg-Body-Encoding": "gzip", "Content-Type": "application/json"},
)
assert r.status_code == 400, r.text