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
 -------------------

Attachment: OpenPGP_signature.asc
Description: OpenPGP digital signature

Reply via email to