/* 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 #include #include #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)) " "![i](/api/attach/img-1.png)", strlen("**hi** [x](javascript:alert(1)) " "![i](/api/attach/img-1.png)")); CHECK(html != NULL); CHECK(strstr(html, "hi")); 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 = "

ok

" "\"a\"" "l"; html = cst_web_md_sanitize(evil, strlen(evil)); CHECK(html != NULL); CHECK(strstr(html, "ok")); CHECK(strstr(html, "q" "r"; 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, "ok") != 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, "bold")); 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; }