"""WebUI HTTP hardening checks over raw sockets, against a scripted model. python3 scripts/e2e_webui_http.py (CST_AGENT= picks the binary) Cross-site requests, request parsing (Content-Length, NUL, long lines, keep-alive), auth before the body, changes refused while a turn runs, cancel waking a pending question, token mode and shutdown. """ import json, os, re, shutil, socket, subprocess, sys, tempfile, threading, time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent")) REQS = [] class Api(BaseHTTPRequestHandler): """First call: ask_user. Later calls: a plain answer.""" def log_message(self, *a): pass def do_GET(self): body = json.dumps({"data": [{"id": "deepseek-flash"}]}).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): n = int(self.headers.get("Content-Length", 0)) REQS.append(json.loads(self.rfile.read(n))) self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.end_headers() base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"} def ev(o): self.wfile.write(b"data: " + json.dumps({**base, **o}).encode() + b"\n\n") self.wfile.flush() if len(REQS) == 1: args = json.dumps({"question": "Pick?", "options": "a\nb"}) ev({"choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [ {"index": 0, "id": "call_1", "type": "function", "function": {"name": "ask_user", "arguments": args}}]}}]}) ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]}) else: ev({"choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}}]}) ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}) self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() fails = 0 def check(ok, msg): global fails print(("PASS " if ok else "FAIL ") + msg) fails += 0 if ok else 1 def raw(port, data, timeout=5, read_all=True): """Send bytes, return everything read until EOF/timeout.""" s = socket.create_connection(("127.0.0.1", port), timeout=timeout) s.sendall(data) out = b"" try: while True: b = s.recv(65536) if not b: break out += b if not read_all: break except socket.timeout: pass s.close() return out.decode("latin1") def status(resp): # a pipelined response starts right after the previous body, not on a new line return [int(m) for m in re.findall(r"HTTP/1\.1 (\d{3}) ", resp)] def req(method, path, headers=None, body=b"", host=None): h = {"Host": host or "127.0.0.1:%d" % PORT, "Connection": "close"} h.update(headers or {}) if body or method == "POST": h["Content-Length"] = str(len(body)) head = "%s %s HTTP/1.1\r\n" % (method, path) + "".join("%s: %s\r\n" % kv for kv in h.items()) + "\r\n" return head.encode() + body def start(ws, *extra): log = open(os.path.join(ws, "server.log"), "w+") p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash", "--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1], "--protocol", "native-eos", "--approval", "auto", *extra], stdout=log, stderr=subprocess.STDOUT) end = time.time() + 10 while time.time() < end: log.seek(0) for l in log.read().splitlines(): if l.startswith("webui: http://127.0.0.1:"): return p, int(l.split(":")[3].split("/")[0]), log time.sleep(0.1) raise SystemExit("server did not start") def stop(p, name): p.terminate() try: p.wait(20) except subprocess.TimeoutExpired: p.kill() check(False, name + " hung on SIGTERM") return check(0 <= p.returncode < 128, "%s exits cleanly (%s)" % (name, p.returncode)) api = ThreadingHTTPServer(("127.0.0.1", 0), Api) threading.Thread(target=api.serve_forever, daemon=True).start() root = tempfile.mkdtemp(prefix="cst-http-") try: ws = os.path.join(root, "a") os.makedirs(ws) p, PORT, log = start(ws) me = "http://127.0.0.1:%d" % PORT cfg = b'{"approval":"ask"}' # 1. cross-site requests r = raw(PORT, req("POST", "/api/config", {"Origin": "http://evil.example", "Content-Type": "text/plain"}, cfg)) check(status(r)[:1] == [401], "a cross-site POST is refused (%s)" % status(r)) r = raw(PORT, req("POST", "/api/config", {"Origin": "null"}, cfg)) check(status(r)[:1] == [401], "Origin: null is refused") r = raw(PORT, req("POST", "/api/config", {"Origin": me, "Content-Type": "application/json"}, b'{"approval":"auto"}')) check(status(r)[:1] == [200], "our own page may POST (%s)" % status(r)) r = raw(PORT, req("POST", "/api/config", {}, b'{"approval":"auto"}')) check(status(r)[:1] == [200], "a client without Origin (curl) may POST") r = raw(PORT, req("GET", "/api/attach/img-0000.png", host="evil.example")) check(status(r)[:1] == [401], "session images need the Host check too (%s)" % status(r)) r = raw(PORT, req("GET", "/app.js")) check(status(r)[:1] == [200], "assets are served") # 2. request parsing r = raw(PORT, req("GET", "/api/state%00x")) check(status(r)[:1] == [400], "an encoded NUL in the path is a 400 (%s)" % status(r)) smuggle = b"GET /api/sessions HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n" r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: abc\r\n\r\n" % PORT).encode() + smuggle) check(status(r) == [400], "Content-Length: abc is a 400, nothing smuggled (%s)" % status(r)) r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 0\r\nContent-Length: %d\r\n\r\n" % (PORT, len(smuggle))).encode() + smuggle) check(status(r) == [400], "two different Content-Lengths are a 400 (%s)" % status(r)) r = raw(PORT, ("GET /%s HTTP/1.1\r\nHost: x\r\n\r\n" % ("a" * 9000)).encode()) check(status(r)[:1] == [400], "an over-long request line is a 400 (%s)" % status(r)) r = raw(PORT, req("GET", "/api/state?x=http://a/b")) check(status(r)[:1] == [200], "'://' in the query leaves the path alone (%s)" % status(r)) r = raw(PORT, req("GET", "/api/state", {"Connection": "Close"}), timeout=3) check("Connection: close" in r, "Connection: Close (any case) closes") ka = ("GET /api/state HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode() big = ("POST /api/turn HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 5000000\r\n\r\n" % PORT).encode() r = raw(PORT, ka + big) check(status(r) == [200, 413], "a 413 after a keep-alive request still arrives (%s)" % status(r)) # 3. a pending question: changes are refused, approval may change, cancel wakes it events = [] def sse(): s = socket.create_connection(("127.0.0.1", PORT)) s.sendall(("GET /api/events HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode()) buf = b"" s.settimeout(60) try: while True: b = s.recv(65536) if not b: break buf += b events[:] = [l[7:] for l in buf.decode("latin1").split("\n") if l.startswith("event: ")] except Exception: pass threading.Thread(target=sse, daemon=True).start() time.sleep(0.4) r = raw(PORT, req("POST", "/api/turn", {"Content-Type": "application/json"}, b'{"text":"go"}')) check(status(r)[:1] == [202], "turn accepted") end = time.time() + 15 while time.time() < end and "ask_user" not in events: time.sleep(0.1) check("ask_user" in events, "the question is open") for path, body in (("/api/sessions/new", b""), ("/api/workspace", json.dumps({"path": root}).encode()), ("/api/compact", b""), ("/api/undo", b""), ("/api/config", b'{"model":"x"}')): r = raw(PORT, req("POST", path, {"Content-Type": "application/json"}, body)) check(status(r)[:1] == [409], "%s is refused mid-turn (%s)" % (path, status(r))) r = raw(PORT, req("POST", "/api/config", {"Content-Type": "application/json"}, b'{"approval":"yolo"}')) check(status(r)[:1] == [200], "approval may change mid-turn") r = raw(PORT, req("POST", "/api/cancel", {"Content-Type": "application/json"}, b"{}")) end = time.time() + 10 while time.time() < end and "turn_end" not in events: time.sleep(0.1) check("turn_end" in events, "cancel ends a turn parked on a question") r = raw(PORT, req("POST", "/api/sessions/new", {"Content-Type": "application/json"})) check(status(r)[:1] == [200], "after the turn, a new session is fine (%s)" % status(r)) stop(p, "the server") # 4. token mode: the page loads, the API and images do not without it ws2 = os.path.join(root, "b") os.makedirs(ws2) p, PORT, log = start(ws2, "--web-token", "tok-x") r = raw(PORT, req("GET", "/app.js")) check(status(r)[:1] == [200], "token mode: app.js loads without the token (%s)" % status(r)) r = raw(PORT, req("GET", "/api/state")) check(status(r)[:1] == [401], "token mode: the API needs it") r = raw(PORT, req("GET", "/api/attach/img-0000.png")) check(status(r)[:1] == [401], "token mode: images need it") r = raw(PORT, req("GET", "/api/state", {"Cookie": "cst_token=tok-x"})) check(status(r)[:1] == [200], "token mode: the cookie works") t0 = time.time() r = raw(PORT, ("POST /api/attach HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 33554431\r\n" "Expect: 100-continue\r\n\r\n" % PORT).encode(), timeout=5) check(status(r) == [401] and time.time() - t0 < 3, "no token, no body read: 401 at once (%s, %.1fs)" % (status(r), time.time() - t0)) stop(p, "the token server") # 5. option checks rc = subprocess.run([BIN, "--web", "--web-port", "70000", "--workspace", ws2], capture_output=True, timeout=10).returncode check(rc != 0, "--web-port 70000 is refused") rc = subprocess.run([BIN, "--web", "--web-token", "x" * 80, "--web-port", "0", "--workspace", ws2, "--api-key", "k"], capture_output=True, timeout=10).returncode check(rc != 0, "an over-long --web-token is refused") finally: shutil.rmtree(root, ignore_errors=True) print("FAILED:", fails) sys.exit(1 if fails else 0)