Files
OpenFUT/scripts/test-utas-observe.py
T
2026-08-18 19:01:33 +00:00

235 lines
9.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Prove the UTAS recorder is byte-transparent and parses what it saw.
Two independent properties, because they fail differently:
TRANSPARENCY what the client receives through the proxy is byte-identical to
what it receives going direct. If this breaks, the tool corrupts
the session it exists to observe -- and the damage would look
like a UTAS bug.
FIDELITY the parsed transactions match what was actually sent. If this
breaks, only the record is wrong, which is why the design
separates the two.
The upstream here is a purpose-built server, not the live oracle: it can be made
to exercise keep-alive, bodies in both directions and chunked encoding on
demand, and no live client is at risk.
"""
import base64
import http.server
import json
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
TOOL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "openfut-utas-observe.py")
SESSION = os.path.join(os.environ.get("TMPDIR", "/tmp"), "openfut-utas-observe-selftest")
fails = []
def check(name, ok, detail=""):
print((" ok " if ok else " FAIL ") + name + ((" " + detail) if detail and not ok else ""))
if not ok:
fails.append(name)
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive, like the real UTAS
def log_message(self, *a):
pass
def _read_body(self):
n = int(self.headers.get("Content-Length", 0) or 0)
return self.rfile.read(n) if n else b""
def do_GET(self):
if self.path.split("?")[0] == "/chunked":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
for part in (b'{"a":1,', b'"b":[2,3],', b'"c":"end"}'):
self.wfile.write(b"%x\r\n" % len(part) + part + b"\r\n")
self.wfile.write(b"0\r\n\r\n")
return
body = json.dumps({"path": self.path}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
got = self._read_body()
body = json.dumps({"echo_len": len(got),
"echo": got.decode("latin1"),
"sid": "SECRET-SESSION-VALUE"}).encode()
self.send_response(201)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
def raw_exchange(port, requests):
"""Send requests on ONE keep-alive connection; return all bytes received."""
s = socket.create_connection(("127.0.0.1", port), timeout=5)
for r in requests:
s.sendall(r)
time.sleep(0.15)
s.settimeout(1.5)
out = b""
try:
while True:
d = s.recv(65536)
if not d:
break
out += d
except socket.timeout:
pass
s.close()
return out
def main():
shutil.rmtree(SESSION, ignore_errors=True)
up_port, proxy_port = free_port(), free_port()
srv = http.server.ThreadingHTTPServer(("127.0.0.1", up_port), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
proxy = subprocess.Popen(
[sys.executable, TOOL, "record",
"--listen", "127.0.0.1:%d" % proxy_port,
"--upstream", "127.0.0.1:%d" % up_port,
"--session", SESSION],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
time.sleep(1.0)
body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode()
reqs = [
b"GET /ut/game/fifa17/sbs/sets HTTP/1.1\r\nHost: t\r\n\r\n",
b"POST /ut/game/fifa17/sbs/challenge/101/squad HTTP/1.1\r\nHost: t\r\n"
b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body,
b"GET /chunked?deviceId=DEADBEEFCAFE&keep=yes HTTP/1.1\r\nHost: t\r\n\r\n",
b"GET /ut/game/fifa17/hub HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n",
]
print("== transparency ==")
direct = raw_exchange(up_port, reqs)
through = raw_exchange(proxy_port, reqs)
# The two exchanges happen seconds apart, so `Date:` legitimately differs.
# Mask it -- but ONLY it, and assert the mask actually fired, so this cannot
# quietly hide a real difference.
import re as _re
def mask(b):
return _re.sub(rb"Date: [^\r\n]+", b"Date: <M>", b)
dm, tm = mask(direct), mask(through)
check("masking Date actually applied", dm != direct and tm != through)
check("bytes through the proxy are identical to bytes direct (Date masked)",
dm == tm,
"direct=%dB through=%dB firstdiff=%s" % (
len(direct), len(through),
next((i for i in range(min(len(dm), len(tm))) if dm[i] != tm[i]), "len")))
check("nothing but Date differed in the unmasked bytes",
len(direct) == len(through))
check("all four responses arrived", through.count(b"HTTP/1.1 ") == 4,
"saw %d" % through.count(b"HTTP/1.1 "))
check("keep-alive was actually used (one connection, four responses)",
through.count(b"HTTP/1.1 ") == 4)
time.sleep(0.6)
proxy.terminate()
try:
proxy.wait(timeout=5)
except subprocess.TimeoutExpired:
proxy.kill()
print("== capture file ==")
raw = os.path.join(SESSION, "raw", "utas.ofcap")
check("raw capture exists", os.path.exists(raw))
check("raw capture is mode 0600",
oct(os.stat(raw).st_mode & 0o777) == "0o600",
oct(os.stat(raw).st_mode & 0o777))
print("== fidelity ==")
r = subprocess.run([sys.executable, TOOL, "parse", "--session", SESSION],
capture_output=True, text=True)
print(" " + r.stdout.strip().replace("\n", "\n "))
if r.stderr.strip():
print(" stderr: " + r.stderr.strip().replace("\n", "\n "))
txs = [json.loads(l) for l in
open(os.path.join(SESSION, "sanitized", "transactions.jsonl"))]
# Two client connections were made (direct + through); only one went via the proxy.
check("four transactions parsed", len(txs) == 4, "got %d" % len(txs))
if len(txs) == 4:
check("methods and paths in order",
[(t["request"]["method"], t["request"]["path"]) for t in txs] ==
[("GET", "/ut/game/fifa17/sbs/sets"),
("POST", "/ut/game/fifa17/sbs/challenge/101/squad"),
("GET", "/chunked"),
("GET", "/ut/game/fifa17/hub")])
check("request body preserved byte-for-byte",
base64.b64decode(txs[1]["request"]["body_b64"]) == body)
check("statuses recorded", [t["response"]["status"] for t in txs] == [200, 201, 200, 200])
chunked = base64.b64decode(txs[2]["response"]["body_b64"])
check("chunked response body dechunked correctly",
chunked == b'{"a":1,"b":[2,3],"c":"end"}', chunked[:60].decode("latin1"))
check("headers kept in received order, as pairs",
txs[0]["response"]["headers"][0][0] == "Server")
check("keep-alive recorded", txs[0]["connection"]["requests_on_this_connection"] == 4)
check("timing recorded", all(t["elapsed_ms"] is not None for t in txs))
# Sanitiser: the secret goes, the rest of the body does not.
echoed = json.loads(base64.b64decode(txs[1]["response"]["body_b64"]))
check("secret JSON value redacted", echoed.get("sid") == "<REDACTED>")
check("non-secret payload preserved exactly", echoed.get("echo_len") == len(body))
check("redaction is recorded, not silent", "redacted" in txs[1])
# Regression: the sanitiser handled headers and JSON bodies but not the
# QUERY STRING, and was one audit away from publishing a device id from
# `?deviceId=...`. Three surfaces carry identifiers, not two.
qtx = next((t for t in txs if t["request"]["query"]), None)
check("a secret query parameter is redacted",
qtx is not None and "deviceId=<REDACTED>" in qtx["request"]["query"],
qtx["request"]["query"] if qtx else "no query captured")
check("a non-secret query parameter is preserved",
qtx is not None and "keep=yes" in qtx["request"]["query"],
qtx["request"]["query"] if qtx else "")
print("== path-scoped fixture ==")
r = subprocess.run(
[sys.executable, TOOL, "parse", "--session", SESSION,
"--path-prefix", "/ut/game/fifa17/sbs/"],
capture_output=True, text=True)
print(" " + r.stdout.strip().replace("\n", "\n "))
filtered = [json.loads(l) for l in
open(os.path.join(SESSION, "sanitized", "transactions.jsonl"))]
check("SBC prefix emits only the two SBC transactions", len(filtered) == 2,
"got %d" % len(filtered))
check("SBC save body remains byte-exact after scoped parse",
len(filtered) == 2 and
base64.b64decode(filtered[1]["request"]["body_b64"]) == body)
srv.shutdown()
print()
print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())