diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-23 11:36:11 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-23 11:36:11 +0200 |
| commit | 1abb7649b930d35d1f5a76fd72856659b1ee8275 (patch) | |
| tree | 50d1d3dd905056b75749e22a58e7247e4a4bb0e2 /clients/web.c | |
| parent | 71a702f375750829c634b552217c9925d549828b (diff) | |
| download | bokf-1abb7649b930d35d1f5a76fd72856659b1ee8275.tar.gz bokf-1abb7649b930d35d1f5a76fd72856659b1ee8275.zip | |
web: bokftui in the browser (ttyd + bokfweb login gate); per-user login limitv0.1.69
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 <noreply@anthropic.com>
Diffstat (limited to 'clients/web.c')
| -rw-r--r-- | clients/web.c | 330 |
1 files changed, 330 insertions, 0 deletions
diff --git a/clients/web.c b/clients/web.c new file mode 100644 index 0000000..cc70caf --- /dev/null +++ b/clients/web.c @@ -0,0 +1,330 @@ +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> + +#include "web.h" + +/* Copies at most cap-1 bytes of [s, s+n) into out; -1 when it does not fit. */ +static int copy_n(char *out, size_t cap, const char *s, size_t n) +{ + if (!cap || n >= cap) + return -1; + memcpy(out, s, n); + out[n] = '\0'; + return 0; +} + +/* Value of header `name` (case-insensitive) in [h, end), trimmed. */ +static int header_get(const char *h, const char *end, const char *name, + char *out, size_t cap) +{ + size_t nl = strlen(name); + while (h < end) { + const char *eol = memchr(h, '\n', (size_t)(end - h)); + if (!eol) + eol = end; + const char *line_end = eol > h && eol[-1] == '\r' ? eol - 1 : eol; + if ((size_t)(line_end - h) > nl && h[nl] == ':' && + strncasecmp(h, name, nl) == 0) { + const char *v = h + nl + 1; + while (v < line_end && (*v == ' ' || *v == '\t')) + v++; + const char *ve = line_end; + while (ve > v && (ve[-1] == ' ' || ve[-1] == '\t')) + ve--; + return copy_n(out, cap, v, (size_t)(ve - v)); + } + h = eol + 1; + } + if (cap) + out[0] = '\0'; + return -1; +} + +int web_parse_request(const char *buf, size_t n, struct web_req *r) +{ + memset(r, 0, sizeof *r); + if (n > WEB_MAX_REQUEST) + return -1; + const char *hend = NULL; + for (size_t i = 0; i + 3 < n; i++) + if (memcmp(buf + i, "\r\n\r\n", 4) == 0) { + hend = buf + i + 4; + break; + } + if (!hend) + return n >= WEB_MAX_REQUEST ? -1 : 1; + + /* request line: METHOD SP TARGET SP HTTP/x.y */ + const char *sp1 = memchr(buf, ' ', (size_t)(hend - buf)); + if (!sp1 || copy_n(r->method, sizeof r->method, buf, + (size_t)(sp1 - buf)) != 0) + return -1; + const char *tgt = sp1 + 1; + const char *sp2 = memchr(tgt, ' ', (size_t)(hend - tgt)); + if (!sp2 || sp2 == tgt || *tgt != '/') + return -1; + const char *q = memchr(tgt, '?', (size_t)(sp2 - tgt)); + const char *pend = q ? q : sp2; + if (copy_n(r->path, sizeof r->path, tgt, (size_t)(pend - tgt)) != 0) + return -1; + if (q && copy_n(r->query, sizeof r->query, q + 1, + (size_t)(sp2 - q - 1)) != 0) + return -1; + if (strncmp(sp2 + 1, "HTTP/1.", 7) != 0) + return -1; + + const char *hdrs = memchr(sp2, '\n', (size_t)(hend - sp2)); + if (!hdrs) + return -1; + hdrs++; + header_get(hdrs, hend, "Cookie", r->cookie, sizeof r->cookie); + header_get(hdrs, hend, "X-Forwarded-For", r->forwarded_for, + sizeof r->forwarded_for); + header_get(hdrs, hend, "X-Forwarded-Uri", r->forwarded_uri, + sizeof r->forwarded_uri); + char cl[32]; + size_t body_len = 0; + if (header_get(hdrs, hend, "Content-Length", cl, sizeof cl) == 0) { + char *e = NULL; + long long v = strtoll(cl, &e, 10); + if (!*cl || *e || v < 0 || v > WEB_MAX_REQUEST) + return -1; + body_len = (size_t)v; + } + char te[32]; + if (header_get(hdrs, hend, "Transfer-Encoding", te, sizeof te) == 0) + return -1; /* chunked bodies are not needed here */ + size_t have = n - (size_t)(hend - buf); + if (have < body_len) + return (size_t)(hend - buf) + body_len > WEB_MAX_REQUEST ? -1 : 1; + r->body = hend; + r->body_len = body_len; + return 0; +} + +static int hexval(char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +int web_form_get(const char *form, size_t len, const char *key, char *out, + size_t cap) +{ + size_t kl = strlen(key); + const char *p = form, *end = form + len; + while (p < end) { + const char *amp = memchr(p, '&', (size_t)(end - p)); + const char *pe = amp ? amp : end; + const char *eq = memchr(p, '=', (size_t)(pe - p)); + if (eq && (size_t)(eq - p) == kl && memcmp(p, key, kl) == 0) { + size_t o = 0; + for (const char *v = eq + 1; v < pe; v++) { + char c = *v; + if (c == '+') { + c = ' '; + } else if (c == '%') { + if (pe - v < 3) + return -1; + int hi = hexval(v[1]), lo = hexval(v[2]); + if (hi < 0 || lo < 0) + return -1; + c = (char)(hi * 16 + lo); + if (!c) + return -1; + v += 2; + } + if (o + 1 >= cap) + return -1; + out[o++] = c; + } + out[o] = '\0'; + return 0; + } + p = pe + 1; + } + return -1; +} + +int web_cookie_get(const char *header, const char *name, char *out, + size_t cap) +{ + size_t nl = strlen(name); + const char *p = header; + while (p && *p) { + while (*p == ' ' || *p == ';') + p++; + const char *semi = strchr(p, ';'); + const char *pe = semi ? semi : p + strlen(p); + if ((size_t)(pe - p) > nl && p[nl] == '=' && + strncmp(p, name, nl) == 0) + return copy_n(out, cap, p + nl + 1, (size_t)(pe - p - nl - 1)); + p = semi ? semi + 1 : NULL; + } + return -1; +} + +void web_html_escape(struct buf *b, const char *s) +{ + for (; s && *s; s++) { + switch (*s) { + case '&': + buf_append(b, "&", 5); + break; + case '<': + buf_append(b, "<", 4); + break; + case '>': + buf_append(b, ">", 4); + break; + case '"': + buf_append(b, """, 6); + break; + case '\'': + buf_append(b, "'", 5); + break; + default: + buf_append(b, s, 1); + } + } +} + +int web_token_ok(const char *s) +{ + if (!s || !*s) + return 0; + for (; *s; s++) + if (!((*s >= 'A' && *s <= 'Z') || (*s >= 'a' && *s <= 'z') || + (*s >= '0' && *s <= '9') || *s == '_' || *s == '-')) + return 0; + return 1; +} + +/* --- sessions ------------------------------------------------------ */ + +static int session_live(const struct web_session *s, int64_t now) +{ + return s->token[0] && now - s->created < WEB_SESSION_TTL; +} + +struct web_session *web_store_add(struct web_store *st, const char *bokf, + const char *user, int64_t now) +{ + struct web_session *slot = NULL; + for (int i = 0; i < WEB_MAX_SESSIONS && !slot; i++) + if (!session_live(&st->s[i], now)) + slot = &st->s[i]; + if (!slot) { /* full: replace the oldest */ + slot = &st->s[0]; + for (int i = 1; i < WEB_MAX_SESSIONS; i++) + if (st->s[i].created < slot->created) + slot = &st->s[i]; + } + memset(slot, 0, sizeof *slot); + char *tok = util_random_id("", 32); + char *hdl = util_random_id("", 24); + snprintf(slot->token, sizeof slot->token, "%s", tok); + snprintf(slot->handle, sizeof slot->handle, "%s", hdl); + free(tok); + free(hdl); + snprintf(slot->bokf, sizeof slot->bokf, "%s", bokf); + snprintf(slot->user, sizeof slot->user, "%s", user ? user : ""); + slot->created = now; + return slot; +} + +static struct web_session *store_find(struct web_store *st, const char *v, + int by_handle, int64_t now) +{ + if (!web_token_ok(v)) + return NULL; + size_t vl = strlen(v); + for (int i = 0; i < WEB_MAX_SESSIONS; i++) { + struct web_session *s = &st->s[i]; + const char *k = by_handle ? s->handle : s->token; + if (session_live(s, now) && strlen(k) == vl && + util_const_eq(k, v, vl)) + return s; + } + return NULL; +} + +struct web_session *web_store_by_token(struct web_store *st, + const char *token, int64_t now) +{ + return store_find(st, token, 0, now); +} + +struct web_session *web_store_by_handle(struct web_store *st, + const char *handle, int64_t now) +{ + return store_find(st, handle, 1, now); +} + +void web_store_del(struct web_session *s) +{ + if (s) + memset(s, 0, sizeof *s); +} + +/* --- limiter ------------------------------------------------------- */ + +static struct web_rl_entry *rl_find(struct web_rl *rl, const char *addr) +{ + for (int i = 0; i < WEB_RL_SLOTS; i++) + if (rl->e[i].addr[0] && strcmp(rl->e[i].addr, addr) == 0) + return &rl->e[i]; + return NULL; +} + +int64_t web_rl_blocked(const struct web_rl *rl, const char *addr, + int64_t now) +{ + for (int i = 0; i < WEB_RL_SLOTS; i++) { + const struct web_rl_entry *e = &rl->e[i]; + if (e->addr[0] && strcmp(e->addr, addr) == 0) + return e->fails >= WEB_RL_MAX_FAILS && e->window_end > now + ? e->window_end - now + : 0; + } + return 0; +} + +void web_rl_fail(struct web_rl *rl, const char *addr, int64_t now) +{ + struct web_rl_entry *e = rl_find(rl, addr); + if (!e) { + /* a free or expired slot, else the one whose window ends first */ + e = &rl->e[0]; + for (int i = 0; i < WEB_RL_SLOTS; i++) { + struct web_rl_entry *c = &rl->e[i]; + if (!c->addr[0] || c->window_end <= now) { + e = c; + break; + } + if (c->window_end < e->window_end) + e = c; + } + memset(e, 0, sizeof *e); + snprintf(e->addr, sizeof e->addr, "%s", addr); + } + if (e->fails == 0 || e->window_end <= now) { + e->fails = 0; + e->window_end = now + WEB_RL_WINDOW; + } + e->fails++; +} + +void web_rl_ok(struct web_rl *rl, const char *addr) +{ + struct web_rl_entry *e = rl_find(rl, addr); + if (e) + memset(e, 0, sizeof *e); +} |
