A second implementation of cst_agent_hooks_t next to the TUI: the turn runs on a worker thread, the hooks publish events into a ring buffer that SSE clients read, so the engine itself needs no changes. - src/net.c, include/cst_net.h: POSIX/Winsock sockets, non-blocking connect - src/http_srv.c, include/cst_http.h: HTTP/1.1, keep-alive, chunked streaming, body caps (413), Expect: 100-continue, connection cap, exact routing - src/web_md.c, include/cst_web_md.h: streaming markdown -> sanitized HTML (tag/attribute allowlist, javascript: URLs and off-site images dropped) - src/webui.c, include/cst_webui.h: event ring + SSE broadcast, all routes, token (Bearer/Cookie/query) and loopback Host checks, 3 s auto-deny when no client is attached - src/webui_stub.c, CST_WEBUI=0 / CST_BUILD_WEBUI=OFF: build without the WebUI - web/: no-build front end (transcript, approvals, todo, sessions, image upload, status bar) - docs/webui.md: engine API v1 - events, requests, security model - tests/test_webui.c, scripts/e2e_webui.sh: unit tests plus an end-to-end run driven by a recorded SSE stream (no API key needed) - src/main.c: --web --web-port --web-host --web-token --web-dev --web-open and CST_WEB_PORT/HOST/TOKEN/ROOT An empty POST body is now accepted without Content-Length (RFC 9112 6.3) so curl-driven clients can reach cancel/undo/compact/quit; CMakeList's core tests learned about sashidown and test_webui, and CST_WEBUI=0 defines CST_NO_WEBUI.
281 lines
9.4 KiB
C
281 lines
9.4 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);
|
|
|
|
/* 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;
|
|
}
|