diff options
Diffstat (limited to 'clients')
| -rw-r--r-- | clients/bokfweb.c | 479 | ||||
| -rw-r--r-- | clients/client.c | 1 | ||||
| -rw-r--r-- | clients/screens_bokslut.c | 2 | ||||
| -rw-r--r-- | clients/screens_reports.c | 4 | ||||
| -rw-r--r-- | clients/ui.c | 27 | ||||
| -rw-r--r-- | clients/ui.h | 6 | ||||
| -rw-r--r-- | clients/web.c | 330 | ||||
| -rw-r--r-- | clients/web.h | 100 |
8 files changed, 949 insertions, 0 deletions
diff --git a/clients/bokfweb.c b/clients/bokfweb.c new file mode 100644 index 0000000..d04e2d6 --- /dev/null +++ b/clients/bokfweb.c @@ -0,0 +1,479 @@ +/* bokfweb: the login gate in front of the browser terminal. + + Caddy terminates TLS and asks this gate (forward_auth, GET /auth) before + it proxies anything to ttyd. A login page checks the credentials with + bokfd's own session.open, so the web has no password store of its own + and bokfd's audit applies. A successful login gets a cookie (never in a + URL) and a terminal handle (in the ttyd URL, ?arg=); /auth accepts a + request only with a live cookie whose session owns the handle in the + URL, and only while the bokfd session is alive. The terminal wrapper + trades the handle for the bokfd session on the internal /redeem, so the + TUI starts logged in. Failed logins are limited per client address + before bokfd's own limiter is reached. + + Listens on BOKFWEB_LISTEN (default 127.0.0.1:7682) and is not exposed + directly: only Caddy and the wrapper in the same container talk to it. */ +#include <arpa/inet.h> +#include <errno.h> +#include <netinet/in.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <sys/time.h> +#include <unistd.h> + +#include "client.h" +#include "log.h" +#include "util.h" +#include "web.h" +#include "yyjson.h" + +#define COOKIE "bokf_web" + +struct gate { + const char *bokfd; /* bokfd target, e.g. /run/bokfd/bokfd.sock */ + const char *base; /* URL prefix, e.g. /web */ + int secure; /* Secure cookie (off only for plain-http tests) */ + struct web_store st; + struct web_rl rl; +}; + +/* One call on a fresh connection; the response line or NULL. */ +static char *bokfd_call(const struct gate *g, const char *cmd, + const char *session, const char *args) +{ + struct client_conn c; + if (client_connect(g->bokfd, &c) != 0) { + log_warn("bokfd unreachable: %s", client_last_error()); + return NULL; + } + char *resp = client_rpc(&c, cmd, session, 0, args); + client_close(&c); + return resp; +} + +static int bokfd_alive(const struct gate *g, const char *session) +{ + char *resp = bokfd_call(g, "session.whoami", session, "{}"); + int ok = resp && client_ok(resp); + free(resp); + return ok; +} + +/* error.code of a response line into code ("" when none). */ +static void error_code(const char *resp, char *code, size_t n) +{ + code[0] = '\0'; + yyjson_doc *d = resp ? yyjson_read(resp, strlen(resp), 0) : NULL; + yyjson_val *root = d ? yyjson_doc_get_root(d) : NULL; + yyjson_val *e = root ? yyjson_obj_get(root, "error") : NULL; + yyjson_val *c = e ? yyjson_obj_get(e, "code") : NULL; + if (c && yyjson_is_str(c)) + snprintf(code, n, "%s", yyjson_get_str(c)); + yyjson_doc_free(d); +} + +/* --- responses ---------------------------------------------------- */ + +static void send_all(int fd, const char *p, size_t n) +{ + while (n > 0) { + ssize_t w = send(fd, p, n, MSG_NOSIGNAL); + if (w <= 0) { + if (w < 0 && errno == EINTR) + continue; + return; + } + p += w; + n -= (size_t)w; + } +} + +static void respond(int fd, const char *status, const char *extra_headers, + const char *ctype, const char *body, size_t blen) +{ + char h[1536]; + int n = snprintf( + h, sizeof h, + "HTTP/1.1 %s\r\n" + "Connection: close\r\n" + "Cache-Control: no-store\r\n" + "X-Content-Type-Options: nosniff\r\n" + "X-Frame-Options: DENY\r\n" + "Referrer-Policy: no-referrer\r\n" + "Content-Security-Policy: default-src 'none'; style-src" + " 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'\r\n" + "%s%s%s%s" + "Content-Length: %zu\r\n\r\n", + status, extra_headers ? extra_headers : "", + ctype ? "Content-Type: " : "", ctype ? ctype : "", + ctype ? "\r\n" : "", blen); + if (n <= 0 || (size_t)n >= sizeof h) + return; + send_all(fd, h, (size_t)n); + if (body && blen) + send_all(fd, body, blen); +} + +static void redirect(int fd, const char *location, const char *extra) +{ + char h[768]; + snprintf(h, sizeof h, "Location: %s\r\n%s", location, extra ? extra : ""); + respond(fd, "303 See Other", h, NULL, NULL, 0); +} + +static void text(int fd, const char *status, const char *body) +{ + respond(fd, status, NULL, "text/plain; charset=utf-8", body, + strlen(body)); +} + +static void buf_puts(struct buf *b, const char *s) +{ + buf_append(b, s, strlen(s)); +} + +static void login_page(int fd, const struct gate *g, const char *error, + const char *user) +{ + struct buf b; + buf_init(&b); + static const char head[] = + "<!doctype html>\n<html lang=\"sv\"><head><meta charset=\"utf-8\">" + "<meta name=\"viewport\" content=\"width=device-width," + "initial-scale=1\"><title>bokf — logga in</title><style>" + "body{font-family:system-ui,sans-serif;background:#1d2327;" + "color:#e8e8e8;display:flex;min-height:100vh;margin:0;" + "align-items:center;justify-content:center}" + "form{background:#2a3136;padding:2rem 2.2rem;border-radius:6px;" + "width:18rem}h1{font-size:1.3rem;margin:0 0 1.2rem}" + "label{display:block;font-size:.85rem;margin:.9rem 0 .3rem}" + "input{width:100%;box-sizing:border-box;padding:.55rem;border:1px " + "solid #56616a;border-radius:4px;background:#1d2327;color:#fff;" + "font-size:1rem}button{margin-top:1.4rem;width:100%;padding:.6rem;" + "border:0;border-radius:4px;background:#3d8fd1;color:#fff;" + "font-size:1rem;cursor:pointer}.err{background:#5c2b2b;" + "padding:.6rem;border-radius:4px;font-size:.9rem}" + "p.note{font-size:.8rem;color:#9aa5ad;margin-top:1.2rem}" + "</style></head><body>"; + buf_puts(&b, head); + buf_puts(&b, "<form method=\"post\" action=\""); + web_html_escape(&b, g->base); + buf_puts(&b, "/login\"><h1>bokf</h1>"); + if (error && *error) { + buf_puts(&b, "<div class=\"err\" role=\"alert\">"); + web_html_escape(&b, error); + buf_puts(&b, "</div>"); + } + static const char fields1[] = + "<label for=\"u\">Användarnamn</label>" + "<input id=\"u\" name=\"username\" autocomplete=\"username\" " + "autocapitalize=\"none\" required autofocus value=\""; + buf_puts(&b, fields1); + web_html_escape(&b, user ? user : ""); + static const char fields2[] = + "\"><label for=\"p\">Lösenord</label>" + "<input id=\"p\" name=\"password\" type=\"password\" " + "autocomplete=\"current-password\" required>" + "<button type=\"submit\">Logga in</button>" + "<p class=\"note\">Samma konto som i bokftui. Efter inloggningen " + "öppnas bokföringen i en terminal i webbläsaren.</p>" + "</form></body></html>\n"; + buf_puts(&b, fields2); + respond(fd, "200 OK", NULL, "text/html; charset=utf-8", + (const char *)b.p, b.len); + buf_free(&b); +} + +/* --- handlers ----------------------------------------------------- */ + +static const char *client_addr(const struct web_req *r, char *buf, size_t n) +{ + /* Caddy sets X-Forwarded-For to the real client; take the first hop */ + if (!r->forwarded_for[0]) + return "direct"; + snprintf(buf, n, "%s", r->forwarded_for); + char *comma = strchr(buf, ','); + if (comma) + *comma = '\0'; + return util_str_trim(buf); +} + +static struct web_session *cookie_session(struct gate *g, + const struct web_req *r) +{ + char tok[128]; + if (web_cookie_get(r->cookie, COOKIE, tok, sizeof tok) != 0) + return NULL; + return web_store_by_token(&g->st, tok, util_now()); +} + +/* The cookie's session when the bokfd session behind it is alive; + a dead one is dropped. */ +static struct web_session *live_session(struct gate *g, + const struct web_req *r) +{ + struct web_session *s = cookie_session(g, r); + if (s && !bokfd_alive(g, s->bokf)) { + log_info("web session of %s ended (bokfd session gone)", s->user); + web_store_del(s); + s = NULL; + } + return s; +} + +static void to_terminal(int fd, const struct gate *g, + const struct web_session *s, const char *extra) +{ + char loc[256]; + snprintf(loc, sizeof loc, "%s/tty/?arg=%s", g->base, s->handle); + redirect(fd, loc, extra); +} + +static void h_login_post(int fd, struct gate *g, const struct web_req *r) +{ + char abuf[64], user[64] = "", pass[256] = ""; + const char *addr = client_addr(r, abuf, sizeof abuf); + int64_t now = util_now(); + int64_t wait = web_rl_blocked(&g->rl, addr, now); + if (wait > 0) { + char msg[128]; + snprintf(msg, sizeof msg, + "För många misslyckade försök. Försök igen om %lld min.", + (long long)(wait + 59) / 60); + login_page(fd, g, msg, NULL); + return; + } + if (web_form_get(r->body, r->body_len, "username", user, sizeof user) || + web_form_get(r->body, r->body_len, "password", pass, sizeof pass) || + !user[0] || !pass[0]) { + login_page(fd, g, "Fyll i användarnamn och lösenord.", user); + return; + } + struct client_conn c; + if (client_connect(g->bokfd, &c) != 0) { + memset(pass, 0, sizeof pass); + log_warn("bokfd unreachable: %s", client_last_error()); + login_page(fd, g, "Servern svarar inte just nu. Försök igen strax.", + user); + return; + } + char *session = NULL, *err = NULL; + int rc = client_login(&c, user, pass, &session, &err); + client_close(&c); + memset(pass, 0, sizeof pass); + if (rc != 0) { + char code[48]; + error_code(err, code, sizeof code); + free(err); + if (strcmp(code, "RATE_LIMITED") == 0) { + login_page(fd, g, + "Inloggningen är tillfälligt spärrad efter för många " + "felaktiga försök. Försök igen om en stund.", + user); + return; + } + if (strcmp(code, "AUTH_FAILED") == 0) { + web_rl_fail(&g->rl, addr, now); + log_info("web login failed for %s from %s", user, addr); + login_page(fd, g, "Fel användarnamn eller lösenord.", user); + return; + } + log_warn("web login error for %s: %s", user, code[0] ? code : "?"); + login_page(fd, g, "Inloggningen misslyckades. Försök igen strax.", + user); + return; + } + web_rl_ok(&g->rl, addr); + struct web_session *s = web_store_add(&g->st, session, user, now); + free(session); + log_info("web login %s from %s", user, addr); + char cookie[256]; + snprintf(cookie, sizeof cookie, + "Set-Cookie: " COOKIE "=%s; Path=%s; HttpOnly;%s SameSite=Strict;" + " Max-Age=%d\r\n", + s->token, g->base, g->secure ? " Secure;" : "", + WEB_SESSION_TTL); + to_terminal(fd, g, s, cookie); +} + +static void h_logout(int fd, struct gate *g, const struct web_req *r) +{ + struct web_session *s = cookie_session(g, r); + if (s) { + free(bokfd_call(g, "session.close", s->bokf, "{}")); + log_info("web logout %s", s->user); + web_store_del(s); + } + char clear[192], loc[160]; + snprintf(clear, sizeof clear, + "Set-Cookie: " COOKIE "=; Path=%s; HttpOnly;%s SameSite=Strict;" + " Max-Age=0\r\n", + g->base, g->secure ? " Secure;" : ""); + snprintf(loc, sizeof loc, "%s/", g->base); + redirect(fd, loc, clear); +} + +/* forward_auth: 200 lets Caddy proxy to ttyd, anything else goes back to + the browser. A handle in the URL must belong to the cookie's session. */ +static void h_auth(int fd, struct gate *g, const struct web_req *r) +{ + struct web_session *s = live_session(g, r); + if (!s) { + char loc[160]; + snprintf(loc, sizeof loc, "%s/", g->base); + redirect(fd, loc, NULL); + return; + } + const char *q = strchr(r->forwarded_uri, '?'); + char arg[128]; + if (q && web_form_get(q + 1, strlen(q + 1), "arg", arg, sizeof arg) == 0 && + web_store_by_handle(&g->st, arg, util_now()) != s) { + text(fd, "403 Forbidden", "fel session\n"); + return; + } + respond(fd, "200 OK", NULL, NULL, NULL, 0); +} + +/* The terminal wrapper's call: handle -> bokfd session. Never routed by + Caddy; a request that came through a proxy is refused anyway. */ +static void h_redeem(int fd, struct gate *g, const struct web_req *r) +{ + char handle[128]; + struct web_session *s = NULL; + if (!r->forwarded_for[0] && + web_form_get(r->body, r->body_len, "handle", handle, + sizeof handle) == 0) + s = web_store_by_handle(&g->st, handle, util_now()); + if (!s || !bokfd_alive(g, s->bokf)) { + text(fd, "404 Not Found", "\n"); + return; + } + text(fd, "200 OK", s->bokf); +} + +static void handle(int fd, struct gate *g, const struct web_req *r) +{ + size_t bl = strlen(g->base); + const char *rest = strncmp(r->path, g->base, bl) == 0 ? r->path + bl + : NULL; + int get = strcmp(r->method, "GET") == 0 || strcmp(r->method, "HEAD") == 0; + int post = strcmp(r->method, "POST") == 0; + if (get && strcmp(r->path, "/healthz") == 0) { + text(fd, "200 OK", "ok\n"); + } else if (get && strcmp(r->path, "/auth") == 0) { + h_auth(fd, g, r); + } else if (post && strcmp(r->path, "/redeem") == 0) { + h_redeem(fd, g, r); + } else if (rest && get && + (!*rest || strcmp(rest, "/") == 0 || + strcmp(rest, "/login") == 0)) { + struct web_session *s = live_session(g, r); + if (s) + to_terminal(fd, g, s, NULL); + else + login_page(fd, g, NULL, NULL); + } else if (rest && post && strcmp(rest, "/login") == 0) { + h_login_post(fd, g, r); + } else if (rest && get && strcmp(rest, "/logout") == 0) { + h_logout(fd, g, r); + } else { + text(fd, "404 Not Found", "not found\n"); + } +} + +/* --- server ------------------------------------------------------- */ + +static int listen_on(const char *spec) +{ + char host[64] = "127.0.0.1"; + int port = 7682; + const char *colon = strrchr(spec, ':'); + if (colon) { + snprintf(host, sizeof host, "%.*s", (int)(colon - spec), spec); + port = atoi(colon + 1); + } + struct sockaddr_in sa; + memset(&sa, 0, sizeof sa); + sa.sin_family = AF_INET; + sa.sin_port = htons((unsigned short)port); + if (port <= 0 || port > 65535 || + inet_pton(AF_INET, host, &sa.sin_addr) != 1) { + log_error("BOKFWEB_LISTEN must be ipv4:port, got %s", spec); + return -1; + } + int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + int one = 1; + if (fd < 0 || + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) != 0 || + bind(fd, (struct sockaddr *)&sa, sizeof sa) != 0 || + listen(fd, 64) != 0) { + log_error("cannot listen on %s: %s", spec, strerror(errno)); + if (fd >= 0) + close(fd); + return -1; + } + return fd; +} + +static void serve(int cfd, struct gate *g) +{ + struct timeval tv = { 5, 0 }; + setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(cfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + char buf[WEB_MAX_REQUEST + 1]; + size_t n = 0; + struct web_req r; + for (;;) { + ssize_t got = recv(cfd, buf + n, sizeof buf - 1 - n, 0); + if (got < 0 && errno == EINTR) + continue; + if (got <= 0) + return; /* closed or timed out before a full request */ + n += (size_t)got; + buf[n] = '\0'; + int pr = web_parse_request(buf, n, &r); + if (pr == 0) + break; + if (pr < 0 || n >= sizeof buf - 1) { + text(cfd, "400 Bad Request", "bad request\n"); + return; + } + } + handle(cfd, g, &r); + memset(buf, 0, n); /* the body may hold a password */ +} + +int main(void) +{ + signal(SIGPIPE, SIG_IGN); + const char *lvl = getenv("BOKFWEB_LOG_LEVEL"); + log_set_level(lvl ? log_level_from_name(lvl) : LOG_INFO); + static struct gate g; + g.bokfd = getenv("BOKFD_SOCKET"); + if (!g.bokfd || !*g.bokfd) + g.bokfd = "/run/bokfd/bokfd.sock"; + g.base = getenv("BOKFWEB_BASE"); + if (!g.base || !*g.base) + g.base = "/web"; + const char *insecure = getenv("BOKFWEB_INSECURE_COOKIE"); + g.secure = !(insecure && strcmp(insecure, "1") == 0); + const char *spec = getenv("BOKFWEB_LISTEN"); + int lfd = listen_on(spec && *spec ? spec : "127.0.0.1:7682"); + if (lfd < 0) + return 1; + log_info("bokfweb listening on %s, base %s, bokfd %s", + spec && *spec ? spec : "127.0.0.1:7682", g.base, g.bokfd); + for (;;) { + int cfd = accept4(lfd, NULL, NULL, SOCK_CLOEXEC); + if (cfd < 0) { + if (errno != EINTR) + log_warn("accept: %s", strerror(errno)); + continue; + } + serve(cfd, &g); + close(cfd); + } +} diff --git a/clients/client.c b/clients/client.c index 30f2a67..549978d 100644 --- a/clients/client.c +++ b/clients/client.c @@ -388,6 +388,7 @@ static int session_open(struct client_conn *c, const char *args, } *session_out = xstrdup(yyjson_get_str(s)); yyjson_doc_free(d); + free(resp); return 0; } diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c index 2328c01..acbb098 100644 --- a/clients/screens_bokslut.c +++ b/clients/screens_bokslut.c @@ -844,6 +844,8 @@ static void strip_markup(const char *in, struct buf *out) static void arsredovisning_save(struct app *a, const char *text) { + if (ui_web_block("Årsredovisning")) + return; const char *home = getenv("HOME"); char def[512]; snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : "."); diff --git a/clients/screens_reports.c b/clients/screens_reports.c index a06e914..1077fe0 100644 --- a/clients/screens_reports.c +++ b/clients/screens_reports.c @@ -701,6 +701,8 @@ static void fmt_verifikationslista(struct buf *t, const struct app *a, static void eskd_save(struct app *a, const char *from, const char *to) { + if (ui_web_block("eSKD")) + return; const char *home = getenv("HOME"); char period[8], def[512], full[512]; snprintf(period, sizeof period, "%.4s%.2s", to, to + 5); @@ -957,6 +959,8 @@ static void fmt_ink2(struct buf *t, const struct app *a, yyjson_val *res) static void sru_save(struct app *a) { + if (ui_web_block("SRU")) + return; const char *home = getenv("HOME"); char def[512]; snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : "."); diff --git a/clients/ui.c b/clients/ui.c index 2eb2dfe..99ad213 100644 --- a/clients/ui.c +++ b/clients/ui.c @@ -338,6 +338,8 @@ static void expand_path(const char *in, char *out, size_t n) Returns a malloc'd path, or NULL on cancel. */ char *file_browser(struct app *a, const char *start_dir) { + if (ui_web_block("Välj fil")) + return NULL; if (g_quit) return NULL; char dir[1024]; @@ -478,6 +480,27 @@ static void safe_fname(const char *in, char *out, size_t n) out[o] = '\0'; } +int ui_web_mode(void) +{ + static int mode = -1; + if (mode < 0) { + const char *v = getenv("BOKF_WEB"); + mode = v && strcmp(v, "1") == 0; + } + return mode; +} + +int ui_web_block(const char *title) +{ + if (!ui_web_mode()) + return 0; + tui_message(title, + "Inte tillgängligt i webbversionen ännu: filer kan inte\n" + "laddas upp, sparas eller öppnas härifrån.\n" + "Använd bokftui på datorn för det."); + return 1; +} + /* Open a saved file with xdg-open when a desktop session is present; otherwise (or on fork failure) show where it was saved. */ static void open_saved(const char *path, const char *title) @@ -507,6 +530,8 @@ static void open_saved(const char *path, const char *title) static void save_cache_and_open(const unsigned char *data, size_t n, const char *filename, const char *title) { + if (ui_web_block(title)) + return; char dir[512], path[700], safe[600]; pdf_cache_dir(dir, sizeof dir); safe_fname(filename, safe, sizeof safe); @@ -614,6 +639,8 @@ void attachment_download(struct app *a, int64_t att_id) tui_message("Underlag", "Kunde inte avkoda innehållet."); goto done; } + if (ui_web_block("Underlag")) + goto done; const char *home = getenv("HOME"); char def[600], dl[512]; snprintf(dl, sizeof dl, "%s/Downloads", home && *home ? home : "."); diff --git a/clients/ui.h b/clients/ui.h index 0fb533b..c221118 100644 --- a/clients/ui.h +++ b/clients/ui.h @@ -72,6 +72,12 @@ void kr_whole(int64_t ore, char *buf, size_t n); int parse_x_double(const char *s, double *out); void replace_x_into(const char *src, double x, char *dst, size_t cap); void app_refresh_context(struct app *a); +/* Web mode (BOKF_WEB=1, set by the browser-terminal wrapper): the TUI runs + on the web frontend, not on the user's machine, so nothing may read or + write files there or start programs. ui_web_block() shows why and + returns 1 in web mode; callers return early. */ +int ui_web_mode(void); +int ui_web_block(const char *title); int select_org(struct app *a); char *rpc_dry(struct app *a, const char *cmd, const char *args); char *read_file_b64(const char *path); 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); +} diff --git a/clients/web.h b/clients/web.h new file mode 100644 index 0000000..a652498 --- /dev/null +++ b/clients/web.h @@ -0,0 +1,100 @@ +#ifndef BOKF_WEB_H +#define BOKF_WEB_H + +#include <stddef.h> +#include <stdint.h> + +#include "util.h" + +/* Pure parts of bokfweb, the login gate in front of the browser terminal: + HTTP request parsing, form and cookie decoding, HTML escaping, the + session store and the per-address login limiter. Unit-tested in + tests/test_web.c; bokfweb.c adds sockets and the bokfd calls. */ + +#define WEB_MAX_REQUEST 8192 + +struct web_req { + char method[8]; + char path[256]; /* without the query */ + char query[256]; + char cookie[512]; + char forwarded_for[64]; + char forwarded_uri[512]; + const char *body; /* points into the parsed buffer */ + size_t body_len; +}; + +/* Parses one HTTP/1.x request in buf[0..n). Returns 0 when complete + (headers and Content-Length bytes of body), 1 when more bytes are + needed, -1 when malformed or too large. */ +int web_parse_request(const char *buf, size_t n, struct web_req *r); + +/* Value of key in an application/x-www-form-urlencoded string (also a + query string), decoded ('+' and %XX). 0 when found, -1 otherwise; a + value that does not fit or decodes to a NUL byte counts as not found. */ +int web_form_get(const char *form, size_t len, const char *key, char *out, + size_t cap); + +/* Value of cookie `name` in a Cookie header. 0 when found. */ +int web_cookie_get(const char *header, const char *name, char *out, + size_t cap); + +/* Appends s to b with & < > " ' escaped. */ +void web_html_escape(struct buf *b, const char *s); + +/* Whether s is a non-empty token of [A-Za-z0-9_-] only (cookie values and + handles are generated that way; anything else is rejected unread). */ +int web_token_ok(const char *s); + +/* --- sessions: cookie token -> bokfd session, plus the terminal handle */ + +#define WEB_MAX_SESSIONS 64 +#define WEB_SESSION_TTL (12 * 3600) /* absolute; bokfd's idle TTL applies too */ + +struct web_session { + char token[64]; /* the cookie value */ + char handle[64]; /* goes into the terminal URL (?arg=) */ + char bokf[128]; /* bokfd session id */ + char user[64]; + int64_t created; +}; + +struct web_store { + struct web_session s[WEB_MAX_SESSIONS]; +}; + +/* Adds a session with fresh random token and handle; the oldest session is + replaced when the store is full. Returns it, or NULL when the random + source fails. */ +struct web_session *web_store_add(struct web_store *st, const char *bokf, + const char *user, int64_t now); +/* The live session with this cookie token / terminal handle, or NULL. */ +struct web_session *web_store_by_token(struct web_store *st, + const char *token, int64_t now); +struct web_session *web_store_by_handle(struct web_store *st, + const char *handle, int64_t now); +void web_store_del(struct web_session *s); + +/* --- failed-login limiter per client address */ + +#define WEB_RL_SLOTS 256 +#define WEB_RL_MAX_FAILS 5 +#define WEB_RL_WINDOW 900 + +struct web_rl_entry { + char addr[64]; + int fails; + int64_t window_end; +}; + +struct web_rl { + struct web_rl_entry e[WEB_RL_SLOTS]; +}; + +/* Seconds until addr may try again, 0 when it may try now. */ +int64_t web_rl_blocked(const struct web_rl *rl, const char *addr, + int64_t now); +void web_rl_fail(struct web_rl *rl, const char *addr, int64_t now); +void web_rl_ok(struct web_rl *rl, const char *addr); + +#endif |
