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