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 --- src/cmd_auth.c | 67 +++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 27 deletions(-) (limited to 'src') diff --git a/src/cmd_auth.c b/src/cmd_auth.c index 86dab12..502057c 100644 --- a/src/cmd_auth.c +++ b/src/cmd_auth.c @@ -18,38 +18,33 @@ /* login rate limiting (in-memory, per key) */ /* ------------------------------------------------------------------ */ -#define RL_MAX_KEYS 16 +/* Keyed per user name ("u:") and per user for password changes + ("pw:"): wrong guesses lock the guessed account, never everybody. + A full table reuses an expired entry, else the least-failed one, so the + limiter never switches itself off. */ +#define RL_MAX_KEYS 1024 #define RL_MAX_FAILS 5 #define RL_WINDOW 900 struct rl_entry { - char key[64]; + char key[80]; int fails; int64_t window_end; }; static struct rl_entry g_rl[RL_MAX_KEYS]; -static struct rl_entry *rl_get(const char *key, int create) +static struct rl_entry *rl_find(const char *key) { - struct rl_entry *slot = NULL; - for (size_t i = 0; i < RL_MAX_KEYS; i++) { + for (size_t i = 0; i < RL_MAX_KEYS; i++) if (g_rl[i].key[0] && strcmp(g_rl[i].key, key) == 0) return &g_rl[i]; - if (create && !g_rl[i].key[0] && !slot) - slot = &g_rl[i]; - } - if (create && slot) { - snprintf(slot->key, sizeof slot->key, "%s", key); - slot->fails = 0; - slot->window_end = 0; - } - return slot; + return NULL; } static int rl_blocked(const char *key, int64_t *retry_after) { - struct rl_entry *e = rl_get(key, 0); + struct rl_entry *e = rl_find(key); if (!e || e->fails < RL_MAX_FAILS) return 0; int64_t now = util_now(); @@ -62,22 +57,38 @@ static int rl_blocked(const char *key, int64_t *retry_after) static void rl_fail(const char *key) { - struct rl_entry *e = rl_get(key, 1); - if (!e) - return; int64_t now = util_now(); - if (e->fails == 0 || e->window_end <= now) + struct rl_entry *e = rl_find(key); + if (!e) { + /* a free or expired slot, else the one with the fewest failures + (a blocked entry is replaced last, so flooding the table with + names does not lift a block) */ + e = &g_rl[0]; + for (size_t i = 0; i < RL_MAX_KEYS; i++) { + struct rl_entry *c = &g_rl[i]; + if (!c->key[0] || c->window_end <= now) { + e = c; + break; + } + if (c->fails < e->fails || + (c->fails == e->fails && c->window_end < e->window_end)) + e = c; + } + memset(e, 0, sizeof *e); + snprintf(e->key, sizeof e->key, "%s", key); + } + if (e->fails == 0 || e->window_end <= now) { + e->fails = 0; e->window_end = now + RL_WINDOW; + } e->fails++; } static void rl_ok(const char *key) { - struct rl_entry *e = rl_get(key, 0); - if (e) { - e->fails = 0; - e->window_end = 0; - } + struct rl_entry *e = rl_find(key); + if (e) + memset(e, 0, sizeof *e); } /* ------------------------------------------------------------------ */ @@ -188,7 +199,9 @@ static yyjson_mut_val *h_session_open(struct req *r) return fail(r, "INVALID_ARGS", "username and password are required"); } int64_t retry = 0; - if (rl_blocked("local", &retry)) { + char rlkey[80]; + snprintf(rlkey, sizeof rlkey, "u:%s", username); + if (rl_blocked(rlkey, &retry)) { free(reqjson); return failf(r, "RATE_LIMITED", "too many failed logins, retry in %lld seconds", @@ -202,14 +215,14 @@ static yyjson_mut_val *h_session_open(struct req *r) auth_verify_password(pwhash, password) == 0; free(pwhash); if (!ok) { - rl_fail("local"); + rl_fail(rlkey); audit_append(r->db, 0, uid > 0 ? uid : 0, 0, "auth.fail", reqjson, "AUTH_FAILED", NULL); free(display); free(reqjson); return fail(r, "AUTH_FAILED", "invalid credentials"); } - rl_ok("local"); + rl_ok(rlkey); int64_t active = 0; yyjson_mut_val *orgs = orgs_for_user(r->db, r->rdoc, uid, 0, &active); -- cgit v1.3