patch: cgit_sendfile_timeout
Sven Göthel
sgothel at jausoft.com
Thu Jun 4 11:46:41 UTC 2026
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
-------------- next part --------------
From 0e4062841fa929737aad751d279df8ded493cd6b Mon Sep 17 00:00:00 2001
From: Sven Göthel <sgothel at jausoft.com>
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
-------------------
-------------- next part --------------
A non-text attachment was scrubbed...
Name: OpenPGP_signature.asc
Type: application/pgp-signature
Size: 833 bytes
Desc: OpenPGP digital signature
URL: <http://lists.zx2c4.com/pipermail/cgit/attachments/20260604/94c6c4dc/attachment.sig>
More information about the CGit
mailing list