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.
291 lines
9.9 KiB
C
291 lines
9.9 KiB
C
/* WebUI: the socket/HTTP layer, the SSE framing, auth and the HTML
|
|
* sanitizer. The full turn path is covered by scripts/e2e_webui.sh, which
|
|
* runs a recorded stream through a real server. */
|
|
#ifndef _DEFAULT_SOURCE
|
|
#define _DEFAULT_SOURCE
|
|
#endif
|
|
|
|
#include "cst_http.h"
|
|
#include "cst_net.h"
|
|
#include "cst_util.h"
|
|
#include "cst_web_md.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include "cst_test_util.h"
|
|
|
|
#define CHECK(c) \
|
|
do { \
|
|
if (!(c)) { \
|
|
fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #c); \
|
|
exit(1); \
|
|
} \
|
|
} while (0)
|
|
|
|
/* --- a tiny HTTP client --------------------------------------------------- */
|
|
|
|
static os_socket_t connect_to(int port) {
|
|
char host[32];
|
|
snprintf(host, sizeof(host), "127.0.0.1");
|
|
os_socket_t s = os_net_connect(host, port, 3000);
|
|
CHECK(s != OS_SOCKET_BAD);
|
|
return s;
|
|
}
|
|
|
|
/* Sends data, reads until the peer closes (or 5 s pass). malloc'd. */
|
|
static char *roundtrip(int port, const char *req, size_t *out_len) {
|
|
os_socket_t s = connect_to(port);
|
|
CHECK(os_net_send_all(s, req, strlen(req), 3000) == 0);
|
|
cst_buf_t b = {0};
|
|
char buf[4096];
|
|
for (;;) {
|
|
long n = os_net_recv(s, buf, sizeof(buf), 5000);
|
|
if (n <= 0)
|
|
break;
|
|
CHECK(cst_buf_append(&b, buf, (size_t)n) == 0);
|
|
}
|
|
os_net_close(s);
|
|
*out_len = b.len;
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
/* Same, but stops as soon as `needle` shows up. */
|
|
|
|
/* --- handlers ------------------------------------------------------------- */
|
|
|
|
static int h_hello(cst_http_conn_t *c, void *ud) {
|
|
(void)ud;
|
|
return cst_http_send_str(c, 200, "text/plain", "world");
|
|
}
|
|
|
|
static int h_secret(cst_http_conn_t *c, void *ud) {
|
|
(void)ud;
|
|
return cst_http_send_str(c, 200, "text/plain", "secret");
|
|
}
|
|
|
|
static int h_echo(cst_http_conn_t *c, void *ud) {
|
|
(void)ud;
|
|
const cst_http_req_t *r = cst_http_request(c);
|
|
char head[64];
|
|
snprintf(head, sizeof(head), "%zu:", r->body_len);
|
|
cst_buf_t b = {0};
|
|
cst_buf_puts(&b, head);
|
|
cst_buf_append(&b, r->body ? r->body : "", r->body_len);
|
|
int rc = cst_http_send(c, 200, "text/plain", b.buf, b.len);
|
|
cst_buf_free(&b);
|
|
return rc;
|
|
}
|
|
|
|
static int h_events(cst_http_conn_t *c, void *ud) {
|
|
(void)ud;
|
|
CHECK(cst_http_stream_begin(c, "text/event-stream") == 0);
|
|
CHECK(cst_http_stream_write(c, "event: one\ndata: {\"n\":1}\n\n", 26) == 0);
|
|
CHECK(cst_http_stream_write(c, "event: two\ndata: {\"n\":2}\n\n", 26) == 0);
|
|
CHECK(cst_http_stream_end(c) == 0);
|
|
return 0;
|
|
}
|
|
|
|
static int auth_bearer(const cst_http_req_t *req, void *ud) {
|
|
const char *want = ud;
|
|
if (!req->authorization)
|
|
return -1;
|
|
if (strncmp(req->authorization, "Bearer ", 7) != 0)
|
|
return -1;
|
|
return strcmp(req->authorization + 7, want) == 0 ? 0 : -1;
|
|
}
|
|
|
|
static void test_http(void) {
|
|
static const cst_http_route_t routes[] = {
|
|
{"GET", "/hello", h_hello, NULL, 0, 0},
|
|
{"GET", "/secret", h_secret, NULL, 1, 0},
|
|
{"GET", "/events", h_events, NULL, 0, 0},
|
|
{"POST", "/echo", h_echo, NULL, 0, 4096},
|
|
};
|
|
cst_http_opts_t o;
|
|
memset(&o, 0, sizeof(o));
|
|
o.host = "127.0.0.1";
|
|
o.port = 0;
|
|
o.max_body = 1024 * 1024;
|
|
o.routes = routes;
|
|
o.nroutes = (int)(sizeof(routes) / sizeof(routes[0]));
|
|
o.auth = auth_bearer;
|
|
o.auth_ud = (void *)"s3cret";
|
|
char err[256] = "";
|
|
cst_http_server_t *s = cst_http_start(&o, err, sizeof(err));
|
|
CHECK(s != NULL);
|
|
int port = cst_http_port(s);
|
|
CHECK(port > 0);
|
|
|
|
size_t n = 0;
|
|
char *resp = roundtrip(port, "GET /hello HTTP/1.1\r\nHost: x\r\n"
|
|
"Connection: close\r\n\r\n", &n);
|
|
CHECK(strstr(resp, "200 OK") && strstr(resp, "world"));
|
|
free(resp);
|
|
|
|
/* keep-alive: two requests, one connection */
|
|
resp = roundtrip(port, "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"
|
|
"GET /hello HTTP/1.1\r\nHost: x\r\n"
|
|
"Connection: close\r\n\r\n", &n);
|
|
CHECK(strstr(resp, "200 OK"));
|
|
CHECK(strstr(resp, "200 OK") != strrchr(resp, '2')); /* twice */
|
|
{
|
|
int hits = 0;
|
|
for (const char *p = resp; (p = strstr(p, "200 OK")); p++)
|
|
hits++;
|
|
CHECK(hits == 2);
|
|
}
|
|
free(resp);
|
|
|
|
/* auth: without, then with the token */
|
|
resp = roundtrip(port, "GET /secret HTTP/1.1\r\nHost: x\r\n"
|
|
"Connection: close\r\n\r\n", &n);
|
|
CHECK(strstr(resp, "401"));
|
|
free(resp);
|
|
resp = roundtrip(port, "GET /secret HTTP/1.1\r\nHost: x\r\n"
|
|
"Authorization: Bearer s3cret\r\n"
|
|
"Connection: close\r\n\r\n", &n);
|
|
CHECK(strstr(resp, "200") && strstr(resp, "secret"));
|
|
free(resp);
|
|
|
|
/* unknown route */
|
|
resp = roundtrip(port, "GET /nope HTTP/1.1\r\nHost: x\r\n"
|
|
"Connection: close\r\n\r\n", &n);
|
|
CHECK(strstr(resp, "404"));
|
|
free(resp);
|
|
/* traversal is rejected before any handler runs */
|
|
resp = roundtrip(port, "GET /../etc/passwd HTTP/1.1\r\nHost: x\r\n"
|
|
"Connection: close\r\n\r\n", &n);
|
|
CHECK(strstr(resp, "400") || strstr(resp, "404"));
|
|
free(resp);
|
|
|
|
/* a body round trip, and the per-route cap */
|
|
resp = roundtrip(port, "POST /echo HTTP/1.1\r\nHost: x\r\n"
|
|
"Content-Length: 3\r\nConnection: close\r\n\r\nabc", &n);
|
|
CHECK(strstr(resp, "200") && strstr(resp, "3:abc"));
|
|
free(resp);
|
|
{
|
|
cst_buf_t b = {0};
|
|
cst_buf_puts(&b, "POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 9999\r\n"
|
|
"Connection: close\r\n\r\n");
|
|
for (int i = 0; i < 9999; i++)
|
|
cst_buf_puts(&b, "a");
|
|
resp = roundtrip(port, b.buf, &n);
|
|
CHECK(strstr(resp, "413"));
|
|
free(resp);
|
|
cst_buf_free(&b);
|
|
}
|
|
|
|
/* chunked streaming: the client must see the decoded frames */
|
|
resp = roundtrip(port, "GET /events HTTP/1.1\r\nHost: x\r\n"
|
|
"Connection: close\r\n\r\n", &n);
|
|
CHECK(strstr(resp, "Transfer-Encoding: chunked"));
|
|
CHECK(strstr(resp, "event: one") && strstr(resp, "event: two"));
|
|
CHECK(strstr(resp, "\r\n0\r\n\r\n")); /* terminator */
|
|
free(resp);
|
|
|
|
cst_http_stop(s);
|
|
cst_http_free(s);
|
|
}
|
|
|
|
/* --- query parsing -------------------------------------------------------- */
|
|
|
|
static void test_query(void) {
|
|
cst_http_req_t r;
|
|
memset(&r, 0, sizeof(r));
|
|
snprintf(r.query, sizeof(r.query), "token=a%%20b&since=17&x=");
|
|
char buf[64];
|
|
CHECK(cst_http_query(&r, "token", buf, sizeof(buf)) == 0);
|
|
CHECK(strcmp(buf, "a b") == 0);
|
|
CHECK(cst_http_query(&r, "since", buf, sizeof(buf)) == 0);
|
|
CHECK(strcmp(buf, "17") == 0);
|
|
CHECK(cst_http_query(&r, "nope", buf, sizeof(buf)) != 0);
|
|
}
|
|
|
|
/* --- markdown + sanitizer ------------------------------------------------- */
|
|
|
|
static cst_buf_t g_md_out;
|
|
|
|
static int collect_md(const char *html, size_t len, void *ud) {
|
|
(void)ud;
|
|
return cst_buf_append(&g_md_out, html, len) == 0 ? 0 : 1;
|
|
}
|
|
|
|
static void test_sanitize(void) {
|
|
char *html = cst_web_md_html("**hi** [x](javascript:alert(1)) "
|
|
"",
|
|
strlen("**hi** [x](javascript:alert(1)) "
|
|
""));
|
|
CHECK(html != NULL);
|
|
CHECK(strstr(html, "<strong>hi</strong>"));
|
|
CHECK(strstr(html, "javascript:") == NULL);
|
|
CHECK(strstr(html, "/api/attach/img-1.png"));
|
|
free(html);
|
|
|
|
/* raw markup that sashidown would pass through gets stripped */
|
|
const char *evil = "<p>ok</p><script>alert(1)</script>"
|
|
"<img src=\"http://evil.example/x.png\" alt=\"a\">"
|
|
"<a href=\"//evil.example\">l</a>";
|
|
html = cst_web_md_sanitize(evil, strlen(evil));
|
|
CHECK(html != NULL);
|
|
CHECK(strstr(html, "ok"));
|
|
CHECK(strstr(html, "<script") == NULL);
|
|
CHECK(strstr(html, "evil.example/x.png") == NULL);
|
|
CHECK(strstr(html, "//evil.example") == NULL);
|
|
CHECK(strstr(html, "<a") == NULL); /* an unusable href drops the tag */
|
|
free(html);
|
|
|
|
/* a quote inside a single-quoted or bare value cannot open a new attribute */
|
|
const char *quoted = "<a href='http://x\" onmouseover=\"alert(1)'>q</a>"
|
|
"<a href=http://y\"onmouseover=alert(2)//>r</a>";
|
|
html = cst_web_md_sanitize(quoted, strlen(quoted));
|
|
CHECK(html != NULL);
|
|
CHECK(strstr(html, "\" onmouseover") == NULL);
|
|
CHECK(strstr(html, "\"onmouseover") == NULL);
|
|
CHECK(strstr(html, """) != NULL);
|
|
free(html);
|
|
|
|
/* streaming: a javascript: link is dropped, a split tag never leaks */
|
|
{
|
|
cst_buf_free(&g_md_out);
|
|
const char *src = "see [x](javascript:alert(1)) and **ok**";
|
|
cst_web_md_t *md = cst_web_md_new(collect_md, NULL);
|
|
CHECK(md != NULL);
|
|
CHECK(cst_web_md_feed(md, src, 4) == 0); /* a deliberately split tag */
|
|
CHECK(cst_web_md_feed(md, src + 4, strlen(src) - 4) == 0);
|
|
CHECK(cst_web_md_finish(md) == 0);
|
|
cst_web_md_free(md);
|
|
const char *out = g_md_out.buf ? g_md_out.buf : "";
|
|
CHECK(strstr(out, "javascript:") == NULL);
|
|
CHECK(strstr(out, "href") == NULL);
|
|
CHECK(strstr(out, "see"));
|
|
CHECK(strstr(out, "<strong>ok</strong>") != NULL);
|
|
cst_buf_free(&g_md_out);
|
|
}
|
|
{
|
|
/* and the same text fed one byte at a time still comes out whole */
|
|
cst_buf_free(&g_md_out);
|
|
const char *md_src = "**bold** and [ok](https://example.com)";
|
|
cst_web_md_t *md = cst_web_md_new(collect_md, NULL);
|
|
CHECK(md != NULL);
|
|
for (size_t i = 0; i < strlen(md_src); i++)
|
|
CHECK(cst_web_md_feed(md, md_src + i, 1) == 0);
|
|
CHECK(cst_web_md_finish(md) == 0);
|
|
cst_web_md_free(md);
|
|
CHECK(strstr(g_md_out.buf, "<strong>bold</strong>"));
|
|
CHECK(strstr(g_md_out.buf, "https://example.com"));
|
|
CHECK(strstr(g_md_out.buf, "noopener") != NULL);
|
|
cst_buf_free(&g_md_out);
|
|
}
|
|
}
|
|
|
|
int main(void) {
|
|
CHECK(os_net_init() == 0);
|
|
test_query();
|
|
test_sanitize();
|
|
test_http();
|
|
printf("test_webui: OK\n");
|
|
return 0;
|
|
}
|