patch: cgit_sendfile_timeout - <https://jausoft.com/cgit/cgit.git/log/?h=cgit_sendfile_timeout> - and attached
cgit: print_slot: Avoid Slow-Attack via `client-io-idle-timeout` and `client-io-min-rate` default `cgitrc` configurable values - `client-io-idle-timeout`: 20s - `client-io-min-rate`: 500 Bps - Note GSM 2G -- 9.6Kbps or ~1200 Bps maximum return ETIMEDOUT if either - idle time from last successfull sendfile/write > `client-io-idle-timeout` - total time spend exceeds max(`client-io-idle-timeout`, size/`client-io-min-rate`) seconds In case of timeout, the event will be logged with REMOTE_ADDR, a potential bad actor if repetitive. Further adds `cgitrc` config value `log-level`, enabling verbose logging if set above zero. ~Sven
From 0e4062841fa929737aad751d279df8ded493cd6b Mon Sep 17 00:00:00 2001 From: Sven Göthel <[email protected]> Date: Tue, 2 Jun 2026 04:15:57 +0200 Subject: cgit: print_slot: Avoid Slow-Attack via `client-io-idle-timeout` and `client-io-min-rate` default `cgitrc` configurable values - `client-io-idle-timeout`: 20s - `client-io-min-rate`: 500 Bps - Note GSM 2G -- 9.6Kbps or ~1200 Bps maximum return ETIMEDOUT if either - idle time from last successfull sendfile/write > `client-io-idle-timeout` - total time spend exceeds max(`client-io-idle-timeout`, size/`client-io-min-rate`) seconds In case of timeout, the event will be logged with REMOTE_ADDR, a potential bad actor if repetitive. Further adds `cgitrc` config value `log-level`, enabling verbose logging if set above zero. diff --git a/cache.c b/cache.c index e70af13..28e7180 100644 --- a/cache.c +++ b/cache.c @@ -82,32 +82,145 @@ static int close_slot(struct cache_slot *slot) return err; } +#define MY_MIN(X, Y) (((X) < (Y)) ? (X) : (Y)) +#define MY_MAX(X, Y) (((X) > (Y)) ? (X) : (Y)) + +static int sendslot_to_idle(time_t tStart, time_t tLastSend, time_t tNow, + size_t off, size_t size, const char *cache_name) +{ + const time_t td_total = tNow - tStart; + const time_t td_idle = tNow - tLastSend; + const long rate = off / MY_MAX(1, td_total); + cache_log("[cgit] send_slot timeout idle %lds: sending cache " + "%s (%ld/%ld bytes) to client `%s` " + "within [total %lds, idle %lds, rate %ld Bps]\n", + td_idle, cache_name, off, size, ctx.env.remote_addr, + td_total, td_idle, rate); + return ETIMEDOUT; +} + +static int sendslot_to_minrate(time_t tStart, time_t tNow, size_t off, + size_t size, const char *cache_name) +{ + const time_t td_total = tNow - tStart; + const long rate = off / MY_MAX(1, td_total); + cache_log("[cgit] send_slot timeout rate-limit %ld Bps: sending " + "cache %s (%ld/%ld bytes) to client `%s` " + "within [total %lds, rate %ld Bps]\n", + ctx.cfg.client_io_min_rate, cache_name, off, size, ctx.env.remote_addr, + td_total, rate); + return ETIMEDOUT; +} + +static int sendslot_ok(time_t tStart, time_t tNow, size_t size, + const char *cache_name) +{ + if (ctx.cfg.log_level > 90) { + const time_t td_total = tNow - tStart; + const long rate = size / MY_MAX(1, td_total); + cache_log("[cgit] send_slot status: sent cache %s (%ld bytes) to " + "client `%s` " + "within [total %lds, rate %ld Bps]\n", + cache_name, size, ctx.env.remote_addr, td_total, rate); + } + return 0; +} + +static int sendslot_ok2(time_t tStart, size_t size, const char *cache_name) +{ + if (ctx.cfg.log_level > 90) { + return sendslot_ok(tStart, time(NULL), size, cache_name); + } + return 0; +} + +static ssize_t write_in_full_to(int fd, const void *buf, size_t count, off_t *total_out, + time_t tStart, time_t *tLastSend, time_t to_max) +{ + if (!count) { + return 0; + } + const char *p = buf; + ssize_t total = 0; + time_t tNow = *tLastSend; + + do { + if (tNow - *tLastSend >= ctx.cfg.client_io_idle_timeout) { + errno = ETIMEDOUT; + return -2; + } + if (tNow - tStart > to_max) { + errno = ETIMEDOUT; + return -3; + } + + ssize_t written = write(fd, p, MY_MIN(count, MAX_IO_SIZE)); + tNow = time(NULL); + if (written < 0) { + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) { + struct pollfd pfd; + pfd.fd = fd; + pfd.events = POLLOUT; + // no need to check for errors, + // subsequent read/write will detect unrecoverable errors + poll(&pfd, 1, -1); + continue; + } + return -1; + } else if (written > 0) { + *total_out += written; + count -= written; + p += written; + total += written; + *tLastSend = tNow; + if (!count) + return total; + } + } while (1); +} + /* Print the content of the active cache slot (but skip the key). */ static int print_slot(struct cache_slot *slot) { - off_t off; -#ifdef HAVE_LINUX_SENDFILE - off_t size; -#endif + time_t tStart = time(NULL); + time_t tLastSend = tStart; + time_t tNow = tStart; - off = slot->keylen + 1; + off_t off = slot->keylen + 1; + off_t size = slot->cache_st.st_size; -#ifdef HAVE_LINUX_SENDFILE - size = slot->cache_st.st_size; + if (!size) { + return sendslot_ok(tStart, tNow, size, slot->cache_name); + } + const time_t to_min_rate = + MY_MAX(ctx.cfg.client_io_idle_timeout, size / ctx.cfg.client_io_min_rate); +#ifdef HAVE_LINUX_SENDFILE do { - ssize_t ret; - ret = sendfile(STDOUT_FILENO, slot->cache_fd, &off, size - off); - if (ret < 0) { + if (tNow - tLastSend >= ctx.cfg.client_io_idle_timeout) + return sendslot_to_idle(tStart, tLastSend, tNow, + off, size, slot->cache_name); + if (tNow - tStart > to_min_rate) + return sendslot_to_minrate(tStart, tNow, + off, size, slot->cache_name); + + ssize_t count = + sendfile(STDOUT_FILENO, slot->cache_fd, &off, size - off); + tNow = time(NULL); + if (count < 0) { if (errno == EAGAIN || errno == EINTR) continue; /* Fall back to read/write on EINVAL or ENOSYS */ if (errno == EINVAL || errno == ENOSYS) break; return errno; + } else if (count > 0) { + tLastSend = tNow; + if (off == size) + return sendslot_ok(tStart, tNow, size, slot->cache_name); } - if (off == size) - return 0; } while (1); #endif @@ -115,14 +228,26 @@ static int print_slot(struct cache_slot *slot) return errno; do { - ssize_t ret; - ret = xread(slot->cache_fd, slot->buf, sizeof(slot->buf)); - if (ret < 0) + ssize_t count = xread(slot->cache_fd, slot->buf, sizeof(slot->buf)); + if (count < 0) return errno; - if (ret == 0) - return 0; - if (write_in_full(STDOUT_FILENO, slot->buf, ret) < 0) + + ssize_t res; + if ((res = write_in_full_to(STDOUT_FILENO, slot->buf, count, &off, + tStart, &tLastSend, to_min_rate)) < 0) + { + if (ETIMEDOUT == errno) { + if (-2 == res) + return sendslot_to_idle(tStart, tLastSend, time(NULL), + off, size, slot->cache_name); + else if (-3 == res) + return sendslot_to_minrate(tStart, time(NULL), + off, size, slot->cache_name); + } return errno; + } + if (off == size || !count /* should be redundant */) + return sendslot_ok2(tStart, size, slot->cache_name); } while (1); } diff --git a/cgit.c b/cgit.c index ca318e8..26b4045 100644 --- a/cgit.c +++ b/cgit.c @@ -129,7 +129,9 @@ static void config_cb(const char *name, const char *value) { const char *arg; - if (!strcmp(name, "section")) + if (!strcmp(name, "log-level")) + ctx.cfg.log_level = atoi(value); + else if (!strcmp(name, "section")) ctx.cfg.section = strdup_first_line(value); else if (!strcmp(name, "repo.url")) ctx.repo = cgit_add_repo(value); @@ -215,6 +217,10 @@ static void config_cb(const char *name, const char *value) ctx.cfg.cache_scanrc_ttl = atoi(value); else if (!strcmp(name, "cache-static-ttl")) ctx.cfg.cache_static_ttl = atoi(value); + else if (!strcmp(name, "client-io-idle-timeout")) + ctx.cfg.client_io_idle_timeout = atoi(value); + else if (!strcmp(name, "client-io-min-rate")) + ctx.cfg.client_io_min_rate = atol(value); else if (!strcmp(name, "cache-dynamic-ttl")) ctx.cfg.cache_dynamic_ttl = atoi(value); else if (!strcmp(name, "cache-about-ttl")) @@ -251,15 +257,16 @@ static void config_cb(const char *name, const char *value) ctx.cfg.max_commit_count = atoi(value); else if (!strcmp(name, "project-list")) ctx.cfg.project_list = strdup_first_line(expand_macros(value)); - else if (!strcmp(name, "scan-path")) + else if (!strcmp(name, "scan-path")) { + ctx.cfg.scan_path = strdup_first_line(expand_macros(value)); if (ctx.cfg.cache_size) - process_cached_repolist(expand_macros(value)); + process_cached_repolist(ctx.cfg.scan_path); else if (ctx.cfg.project_list) - scan_projects(expand_macros(value), + scan_projects(ctx.cfg.scan_path, ctx.cfg.project_list); else - scan_tree(expand_macros(value)); - else if (!strcmp(name, "scan-hidden-path")) + scan_tree(ctx.cfg.scan_path); + } else if (!strcmp(name, "scan-hidden-path")) ctx.cfg.scan_hidden_path = atoi(value); else if (!strcmp(name, "section-from-path")) ctx.cfg.section_from_path = atoi(value); @@ -381,6 +388,8 @@ static void prepare_context(void) ctx.cfg.cache_scanrc_ttl = 15; ctx.cfg.cache_dynamic_ttl = 5; ctx.cfg.cache_static_ttl = -1; + ctx.cfg.client_io_idle_timeout = 20; + ctx.cfg.client_io_min_rate = 500; ctx.cfg.case_sensitive_sort = 1; ctx.cfg.branch_sort = 0; ctx.cfg.commit_sort = 0; @@ -426,6 +435,7 @@ static void prepare_context(void) ctx.env.server_port = getenv("SERVER_PORT"); ctx.env.http_cookie = getenv("HTTP_COOKIE"); ctx.env.http_referer = getenv("HTTP_REFERER"); + ctx.env.remote_addr = getenv("REMOTE_ADDR"); ctx.env.content_length = getenv("CONTENT_LENGTH") ? strtoul(getenv("CONTENT_LENGTH"), NULL, 10) : 0; ctx.env.authenticated = 0; ctx.page.mimetype = "text/html"; @@ -871,6 +881,15 @@ static void print_repolist(FILE *f, struct cgit_repolist *list, int start) for (i = start; i < list->count; i++) print_repo(f, &list->repos[i]); } +static void print_config(FILE *f, const char *prefix) +{ + // TODO: May need to be completed, if desired to be functional + fprintf(f, "%slog-level=%d\n", prefix, ctx.cfg.log_level); + fprintf(f, "%sproject-list=%s\n", prefix, ctx.cfg.project_list); + fprintf(f, "%sscan-path=%s\n", prefix, ctx.cfg.scan_path); + fprintf(f, "%sclient-io-idle-timeout=%d\n", prefix, ctx.cfg.client_io_idle_timeout); + fprintf(f, "%sclient-io-min-rate=%ld\n", prefix, ctx.cfg.client_io_min_rate); +} /* Scan 'path' for git repositories, save the resulting repolist in 'cached_rc' * and return 0 on success. @@ -1009,12 +1028,14 @@ static void cgit_parse_args(int argc, const char **argv) * NOTE: We assume that there aren't more than 8 * different snapshot formats supported by cgit... */ + ctx.cfg.scan_path = strdup_first_line(arg); ctx.cfg.snapshots = 0xFF; scan++; scan_tree(arg); } } if (scan) { + print_config(stdout, "[cgit] scan: "); qsort(cgit_repolist.repos, cgit_repolist.count, sizeof(struct cgit_repo), cmp_repos); print_repolist(stdout, &cgit_repolist, 0); @@ -1067,6 +1088,8 @@ int cmd_main(int argc, const char **argv) cgit_parse_args(argc, argv); parse_configfile(expand_macros(ctx.env.cgit_config), config_cb); + if (ctx.cfg.log_level) + print_config(stderr, "[cgit] init: "); ctx.repo = NULL; http_parse_querystring(ctx.qry.raw, querystring_cb); diff --git a/cgit.h b/cgit.h index 7d7ece7..f16e501 100644 --- a/cgit.h +++ b/cgit.h @@ -194,6 +194,7 @@ struct cgit_query { }; struct cgit_config { + int log_level; ///< defaults to zero char *agefile; char *cache_root; char *clone_prefix; @@ -207,6 +208,7 @@ struct cgit_config { char *mimetype_file; char *module_link; char *project_list; + char *scan_path; struct string_list readme; struct string_list css; char *robots; @@ -227,6 +229,10 @@ struct cgit_config { int cache_static_ttl; int cache_about_ttl; int cache_snapshot_ttl; + /* idle timeout in seconds between sending/receiving chunks of the cached body to/from the client. Defaults to 20s. */ + int client_io_idle_timeout; + /* minimum transfer rate in Bps for sending/receiving a full cached body to/from the client. Defaults to 500 Bps. */ + long client_io_min_rate; int case_sensitive_sort; int embedded; int enable_filter_overrides; @@ -302,6 +308,7 @@ struct cgit_environment { const char *server_port; const char *http_cookie; const char *http_referer; + const char *remote_addr; unsigned int content_length; int authenticated; }; diff --git a/cgitrc.5.txt b/cgitrc.5.txt index 7c39bf9..a6016ee 100644 --- a/cgitrc.5.txt +++ b/cgitrc.5.txt @@ -100,6 +100,14 @@ cache-static-ttl:: version of repository pages accessed with a fixed SHA1. See also: "CACHE". Default value: -1". +client-io-idle-timeout:: + IDLE timeout in seconds between sending/receiving chunks + of the cached body to/from the client. Default value: "20". + +client-io-min-rate:: + Minimum transfer rate in Bps for sending/receiving a full cached + body to/from the client. Default value "500". + clone-prefix:: Space-separated list of common prefixes which, when combined with a repository url, generates valid clone urls for the repository. This @@ -456,6 +464,9 @@ virtual-root:: NOTE: cgit has recently learned how to use PATH_INFO to achieve the same kind of virtual urls, so this option will probably be deprecated. +log-level:: + Specifies the logging level. Above zero adds verbose logging. + Default value: "0". REPOSITORY SETTINGS -------------------
OpenPGP_signature.asc
Description: OpenPGP digital signature
