From 1abb7649b930d35d1f5a76fd72856659b1ee8275 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Wed, 23 Sep 2026 11:36:11 +0200 Subject: web: bokftui in the browser (ttyd + bokfweb login gate); per-user login limit New image bokf-web (Dockerfile target "web", compose service "web" on 127.0.0.1:8790): Caddy routing with forward_auth, the bokfweb login gate (C, authenticates with bokfd's session.open, per-address limit, cookie + terminal handle, one login handed to the TUI via /redeem) and ttyd running bokftui in web mode in an isolated throwaway HOME. TLS stays with the host's reverse proxy. BOKF_WEB=1 blocks every local file and viewer path in the TUI. bokfd's login limiter is now per user name instead of one global counter (5 wrong guesses from anyone locked out everybody), and a full counter table no longer disables it. The cross build and deploy.sh build and ship both images. Co-Authored-By: Claude Opus 5.5 --- tests/test_web.c | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 tests/test_web.c (limited to 'tests/test_web.c') diff --git a/tests/test_web.c b/tests/test_web.c new file mode 100644 index 0000000..c5a3bea --- /dev/null +++ b/tests/test_web.c @@ -0,0 +1,175 @@ +/* Unit tests for the pure parts of bokfweb (clients/web.c). */ +#include +#include + +#include "util.h" +#include "web.h" + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \ + #cond); \ + } \ + } while (0) + +static int parse(const char *s, struct web_req *r) +{ + return web_parse_request(s, strlen(s), r); +} + +static void test_parse(void) +{ + struct web_req r; + CHECK(parse("GET /web/ HTTP/1.1\r\nHost: x\r\n\r\n", &r) == 0); + CHECK(strcmp(r.method, "GET") == 0 && strcmp(r.path, "/web/") == 0); + CHECK(r.query[0] == '\0' && r.body_len == 0); + + const char *post = "POST /web/login?next=1 HTTP/1.1\r\n" + "content-length: 27\r\n" + "Cookie: a=1; bokf_web=tok_EN-9\r\n" + "X-Forwarded-For: 203.0.113.7 \r\n\r\n" + "username=anna&password=x%21"; + CHECK(parse(post, &r) == 0); + CHECK(strcmp(r.path, "/web/login") == 0); + CHECK(strcmp(r.query, "next=1") == 0); + CHECK(strcmp(r.forwarded_for, "203.0.113.7") == 0); + CHECK(r.body_len == 27 && strncmp(r.body, "username=", 9) == 0); + + /* incomplete: headers or body still coming */ + CHECK(parse("GET / HTTP/1.1\r\nHost: x\r\n", &r) == 1); + CHECK(parse("POST /l HTTP/1.1\r\nContent-Length: 10\r\n\r\nabc", &r) == 1); + /* malformed */ + CHECK(parse("GET\r\n\r\n", &r) == -1); + CHECK(parse("GET nopath HTTP/1.1\r\n\r\n", &r) == -1); + CHECK(parse("GET / FTP/1.0\r\n\r\n", &r) == -1); + CHECK(parse("POST / HTTP/1.1\r\nContent-Length: -1\r\n\r\n", &r) == -1); + CHECK(parse("POST / HTTP/1.1\r\nContent-Length: 99999\r\n\r\n", &r) == + -1); + CHECK(parse("POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n", + &r) == -1); + char big[WEB_MAX_REQUEST + 16]; + memset(big, 'a', sizeof big); + memcpy(big, "GET /", 5); + CHECK(web_parse_request(big, sizeof big, &r) == -1); + /* a path longer than the field is refused, not truncated */ + char lp[400] = "GET /"; + memset(lp + 5, 'p', 300); + strcpy(lp + 305, " HTTP/1.1\r\n\r\n"); + CHECK(parse(lp, &r) == -1); +} + +static void test_form_cookie(void) +{ + char v[64]; + const char *f = "username=anna+b&password=p%C3%A5ss%26x&empty="; + CHECK(web_form_get(f, strlen(f), "username", v, sizeof v) == 0 && + strcmp(v, "anna b") == 0); + CHECK(web_form_get(f, strlen(f), "password", v, sizeof v) == 0 && + strcmp(v, "p\xc3\xa5ss&x") == 0); + CHECK(web_form_get(f, strlen(f), "empty", v, sizeof v) == 0 && + v[0] == '\0'); + CHECK(web_form_get(f, strlen(f), "user", v, sizeof v) == -1); + CHECK(web_form_get("a=%2", 4, "a", v, sizeof v) == -1); + CHECK(web_form_get("a=%zz", 5, "a", v, sizeof v) == -1); + CHECK(web_form_get("a=%00", 5, "a", v, sizeof v) == -1); + CHECK(web_form_get("a=12345", 7, "a", v, 4) == -1); /* does not fit */ + /* the body is not NUL-terminated: len bounds it */ + CHECK(web_form_get("a=1&b=2XXXX", 7, "b", v, sizeof v) == 0 && + strcmp(v, "2") == 0); + + CHECK(web_cookie_get("a=1; bokf_web=tok; c=3", "bokf_web", v, + sizeof v) == 0 && + strcmp(v, "tok") == 0); + CHECK(web_cookie_get("xbokf_web=tok", "bokf_web", v, sizeof v) == -1); + CHECK(web_cookie_get("", "bokf_web", v, sizeof v) == -1); + + CHECK(web_token_ok("aZ09_-")); + CHECK(!web_token_ok("")); + CHECK(!web_token_ok("a b")); + CHECK(!web_token_ok("a;b")); + + struct buf b; + buf_init(&b); + web_html_escape(&b, "&'"); + buf_append(&b, "", 1); + CHECK(strcmp((char *)b.p, + "<a href="x">&'</a>") == 0); + buf_free(&b); +} + +static void test_store(void) +{ + static struct web_store st; + memset(&st, 0, sizeof st); + struct web_session *s = web_store_add(&st, "s_bokfd1", "anna", 1000); + CHECK(s && web_token_ok(s->token) && web_token_ok(s->handle)); + CHECK(strcmp(s->token, s->handle) != 0); + char tok[64], hdl[64]; + snprintf(tok, sizeof tok, "%s", s->token); + snprintf(hdl, sizeof hdl, "%s", s->handle); + CHECK(web_store_by_token(&st, tok, 1001) == s); + CHECK(web_store_by_handle(&st, hdl, 1001) == s); + /* token and handle are not interchangeable */ + CHECK(web_store_by_token(&st, hdl, 1001) == NULL); + CHECK(web_store_by_handle(&st, tok, 1001) == NULL); + CHECK(web_store_by_token(&st, "nope", 1001) == NULL); + CHECK(web_store_by_token(&st, "bad;value", 1001) == NULL); + /* absolute expiry */ + CHECK(web_store_by_token(&st, tok, 1000 + WEB_SESSION_TTL) == NULL); + web_store_del(s); + CHECK(web_store_by_token(&st, tok, 1001) == NULL); + + /* a full store replaces the oldest session */ + for (int i = 0; i < WEB_MAX_SESSIONS; i++) + web_store_add(&st, "x", "u", 2000 + i); + struct web_session *n = web_store_add(&st, "new", "u", 5000); + CHECK(n && strcmp(n->bokf, "new") == 0); + int oldest_gone = 1; + for (int i = 0; i < WEB_MAX_SESSIONS; i++) + if (st.s[i].created == 2000) + oldest_gone = 0; + CHECK(oldest_gone); +} + +static void test_rl(void) +{ + static struct web_rl rl; + memset(&rl, 0, sizeof rl); + for (int i = 0; i < WEB_RL_MAX_FAILS - 1; i++) + web_rl_fail(&rl, "203.0.113.7", 100); + CHECK(web_rl_blocked(&rl, "203.0.113.7", 100) == 0); + web_rl_fail(&rl, "203.0.113.7", 100); + CHECK(web_rl_blocked(&rl, "203.0.113.7", 100) == WEB_RL_WINDOW); + /* other addresses are not affected */ + CHECK(web_rl_blocked(&rl, "198.51.100.1", 100) == 0); + /* the window ends */ + CHECK(web_rl_blocked(&rl, "203.0.113.7", 100 + WEB_RL_WINDOW) == 0); + web_rl_fail(&rl, "203.0.113.7", 100 + WEB_RL_WINDOW); + CHECK(web_rl_blocked(&rl, "203.0.113.7", 100 + WEB_RL_WINDOW) == 0); + /* success clears */ + web_rl_ok(&rl, "203.0.113.7"); + CHECK(web_rl_blocked(&rl, "203.0.113.7", 101) == 0); + /* many addresses: the table never overflows */ + char a[32]; + for (int i = 0; i < WEB_RL_SLOTS * 2; i++) { + snprintf(a, sizeof a, "10.0.%d.%d", i / 256, i % 256); + web_rl_fail(&rl, a, 200); + } + CHECK(web_rl_blocked(&rl, a, 200) == 0); +} + +int main(void) +{ + test_parse(); + test_form_cookie(); + test_store(); + test_rl(); + printf("test_web: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} -- cgit v1.3