v0.8.2: build fixes, WebUI security and correctness

Build: <fcntl.h> in tui.c, format-truncation in http_srv.c and /web.

WebUI:
- refuse cross-site requests (Origin must be the page itself) in the
  default no-token mode: any page could POST approval=yolo and a prompt
- session/workspace/undo/compact/attach/model changes are 409 while a
  turn runs (use-after-free, messages landing in the wrong session)
- ask_user returned "dismissed" at once instead of waiting
- cancel and shutdown wake a turn parked on a permission or question;
  SIGTERM mid-turn no longer hangs or crashes
- a page opened while a question is pending still gets it;
  Last-Event-ID resumes the stream; event seq matches the SSE id
- token mode: page assets load without the token, images via cookie
- --web-open works, --web-port/--web-token are validated
- frontend: gate keys ignored while typing, clear-images button,
  reasoning no longer wiped while streaming

HTTP server: auth and size cap before reading the body, strict
Content-Length, NUL in url_decode, 413 on keep-alive, idle timeout,
absolute-form only without a leading '/', case-insensitive Connection,
header deadline, 400 for long request lines, unlink before close,
poll() instead of select(), SO_EXCLUSIVEADDRUSE on Windows.

Markdown sanitizer escapes quotes in attribute values.

/web: passes this session's provider/model/base/effort/approval (key
via the environment), keeps output off the TUI, shows the real URL and
keeps CURRENT on the TUI's session.

Tests: scripts/e2e_webui_ask.py, scripts/e2e_webui_http.py (run from
e2e_webui.sh), sanitizer quote test.
This commit is contained in:
WHC
2026-10-09 13:51:34 +08:00
parent adf3e9f024
commit e238f5f37d
15 changed files with 1001 additions and 144 deletions
+234
View File
@@ -0,0 +1,234 @@
"""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)