Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities). Co-authored-by: Cursor <cursoragent@cursor.com>
239 lines
11 KiB
Python
239 lines
11 KiB
Python
"""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", "/"))
|
|
check(status(r)[:1] == [200], "health page is served")
|
|
r = raw(PORT, req("GET", "/index.html"))
|
|
check(status(r)[:1] == [200], "embedded index.html is 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", "/"))
|
|
check(status(r)[:1] == [200], "token mode: health page loads without the token (%s)" % status(r))
|
|
r = raw(PORT, req("GET", "/index.html"))
|
|
check(status(r)[:1] == [200], "token mode: index.html 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)
|