map/ACL pattern files: lines longer than 16383 chars are silently split, tune.bufsize is ignored · Issue #3537 · haproxy/haproxy · GitHub
Skip to content

map/ACL pattern files: lines longer than 16383 chars are silently split, tune.bufsize is ignored #3537

Open

Description

@dwjang97

Detailed Description of the Problem

When a map file or an ACL -f file contains a line longer than 16383 characters, HAProxy silently splits it while loading the configuration:

  • the first 16383 characters are loaded as one entry, so a map value gets truncated;
  • the rest of the line is loaded as one or more extra entries (key only, no value);
  • no warning or error is emitted.

This happens regardless of tune.bufsize. With tune.bufsize 65536, the same long value can be set at runtime with set map; it is then returned in full. As a result the content of a map differs depending on whether an entry was loaded from the file (at startup/reload) or added from the CLI.

For ACLs, the effect is also a matching error: the full pattern no longer matches, while its first 16383 characters do.

The new set-vars-from-map action in master is affected too, since it reads its file through pat_ref_read_from_file_smp(): a 20000-char value gives a 16381-char variable.

I hit this on 3.2.21 with a map holding base64-encoded response bodies returned with http-request return ... lf-string. After each reload, the bodies were served truncated, without any error.

Reproduced with vanilla builds of 3.2.21, 3.4.6 and master (3.5-dev8-e4d1b143b).

Expected Behavior

A line from a pattern file should either be loaded in full, consistent with what the runtime API accepts with the configured tune.bufsize, or be rejected with an explicit error. Silently splitting one line into several entries is never the intended result: it truncates map values and creates unrelated keys. For ACLs, it also yields a prefix match.

Nothing in the documentation mentions a maximum line length for map or ACL files.

Steps to Reproduce the Behavior

  1. Create a map file and an ACL file with one 20000-char line each:
printf 'k %s\n' "$(printf 'a%.0s' $(seq 1 20000))" > long.map
python3 -c "print('a'*16383 + 'b'*3617)" > long.lst
  1. Start HAProxy with the configuration below (haproxy -f haproxy.cfg) from the same directory.

  2. Query it, and inspect the map from the CLI:

curl -H "x-test: $(cat long.lst)" http://127.0.0.1:8080/
curl -H "x-test: $(head -c 16383 long.lst)" http://127.0.0.1:8080/
echo "show map long.map" | socat stdio unix-connect:/tmp/haproxy.sock

Result (identical on 3.2.21, 3.4.6 and master):

full 20000-char header   -> map_len=16381 acl_match=0      (expected: map_len=20000 acl_match=1)
first 16383 chars only   -> map_len=16381 acl_match=1      (expected: map_len=20000 acl_match=0)
show map long.map        -> 2 entries:
                              key "k"                      value: 16381 chars
                              key "aaaa..." (3619 chars)   value: empty
  1. Set the same value from the CLI. The full value is then accepted and returned:
echo "set map long.map k $(printf 'a%.0s' $(seq 1 20000))" | socat stdio unix-connect:/tmp/haproxy.sock
curl http://127.0.0.1:8080/      # -> map_len=20000

Do you have any idea what may have caused this?

Pattern files are read with fgets() into the trash (src/pattern.c:2448 in pat_ref_read_from_file_smp() and src/pattern.c:2535 in pat_ref_read_from_file(), master):

while (fgets(trash.area, trash.size, file) != NULL) {

The trash still has its early default size at that point:

  • it is first allocated at STG_POOL with the default BUFSIZE (src/chunk.c:507, alloc_early_trash());
  • maps are loaded from check_config_validity() (src/haproxy.c:2273), and ACL -f files are loaded even earlier, while parsing (src/acl.c:353);
  • the trash is only resized to tune.bufsize afterwards, by init_trash_buffers(0) (src/haproxy.c:2491).

This looks like a regression from commit 3ef2639 ("MEDIUM: chunks: Realloc trash buffers only after the config is parsed and checked", 1.8-dev3). Its message says "Given that the config parser doesn't rely on the trash size, it should be harmless", but the pattern file readers do rely on it. Up to 1.7, tune.bufsize resized the trash as soon as it was parsed, so pattern files referenced after the global section were read with the configured size.

I confirmed it by building 748919a (its parent) and 3ef2639 with an equivalent configuration (tune.bufsize 65536, with the value exposed through a redirect since 1.8-dev has no http-request return). The parent loads the 20000-char line as a single entry, while 3ef2639 splits it.

Do you have an idea how to solve the issue?

Reading pattern file lines into a dedicated heap buffer that grows as needed removes the dependency on the trash size. This is what readcfgfile() does for configuration lines since 64e8451. getline() is avoided, since it was removed for portability in fbc3d46.

The patch below was tested on master, 3.2.21 and 3.4.6:

  • with the reproducer above, the 20000-char value is loaded in full, the ACL matches only the full pattern, and no extra entry is created;
  • regular files (comments, empty lines, leading spaces and tabs, CRLF, a NUL byte, a last line without LF) load exactly as before;
  • values of 2040-2049, 4090-4099 and 16378-16389 chars, around the buffer growth points, load with their exact length;
  • leaks reports no new leak on haproxy -c.

The patch also adds a reg-test (reg-tests/http-rules/map_acl_long_lines.vtc), which fails without the fix and passes with it on all three versions. It was posted on the mailing list: https://www.mail-archive.com/haproxy@formilux.org/msg47594.html

Proposed patch (master)
diff --git a/src/pattern.c b/src/pattern.c
index ff6e4d54a..b9d899169 100644
--- a/src/pattern.c
+++ b/src/pattern.c
@@ -2396,6 +2396,44 @@ struct pattern_expr *pattern_new_expr(struct pattern_head *head, struct pat_ref
 	return expr;
 }
 
+/* Reads the next line of pattern file <file> into <*buf>, whose allocated
+ * size is <*size>, growing the buffer as needed. Lines are not read into the
+ * trash because pattern files are loaded while the configuration is parsed and
+ * checked, when the trash still has its early default size (tune.bufsize is
+ * only applied afterwards), so longer lines used to be silently split into
+ * several entries. <*buf> must either be NULL, in which case it is allocated,
+ * or point to the area of <*size> bytes it was given by a previous call, and
+ * it must be released by the caller with free(). Returns 1 if a line was read,
+ * 0 at the end of the file or on read error, and -1 on memory allocation
+ * failure.
+ */
+static int pat_ref_read_line(FILE *file, char **buf, size_t *size)
+{
+	size_t len = 0;
+	char *new_buf;
+
+	if (!*buf) {
+		*buf = malloc(LINESIZE);
+		if (!*buf)
+			return -1;
+		*size = LINESIZE;
+	}
+
+	while (fgets(*buf + len, *size - len, file) != NULL) {
+		len += strlen(*buf + len);
+		if (len < *size - 1 || (*buf)[len - 1] == '\n')
+			return 1;
+
+		/* the buffer is full and the line is not complete yet */
+		new_buf = realloc(*buf, *size * 2);
+		if (!new_buf)
+			return -1;
+		*buf = new_buf;
+		*size *= 2;
+	}
+	return len ? 1 : 0;
+}
+
 /* Reads patterns from a file. If <err_msg> is non-NULL, an error message will
  * be returned there on errors and the caller will have to free it.
  *
@@ -2424,6 +2462,9 @@ int pat_ref_read_from_file_smp(struct pat_ref *ref, char **err)
 	char *c;
 	int ret = 0;
 	int line = 0;
+	char *buf = NULL;
+	size_t buf_size = 0;
+	int rd;
 	char *key_beg;
 	char *key_end;
 	char *value_beg;
@@ -2445,9 +2486,9 @@ int pat_ref_read_from_file_smp(struct pat_ref *ref, char **err)
 	 * followed by one value per line. The start spaces, separator spaces
 	 * and and spaces are stripped. Each can contain comment started by '#'
 	 */
-	while (fgets(trash.area, trash.size, file) != NULL) {
+	while ((rd = pat_ref_read_line(file, &buf, &buf_size)) > 0) {
 		line++;
-		c = trash.area;
+		c = buf;
 
 		/* ignore lines beginning with a dash */
 		if (*c == '#')
@@ -2493,6 +2534,11 @@ int pat_ref_read_from_file_smp(struct pat_ref *ref, char **err)
 		}
 	}
 
+	if (rd < 0) {
+		memprintf(err, "out of memory");
+		goto out_close;
+	}
+
 	if (ferror(file)) {
 		memprintf(err, "error encountered while reading  <%s> : %s",
 				ref->reference, strerror(errno));
@@ -2502,6 +2548,7 @@ int pat_ref_read_from_file_smp(struct pat_ref *ref, char **err)
 	ret = 1;
 
  out_close:
+	free(buf);
 	fclose(file);
 	return ret;
 }
@@ -2516,6 +2563,9 @@ int pat_ref_read_from_file(struct pat_ref *ref, char **err)
 	char *arg;
 	int ret = 0;
 	int line = 0;
+	char *buf = NULL;
+	size_t buf_size = 0;
+	int rd;
 
 	file = fopen(ref->reference, "r");
 	if (!file) {
@@ -2532,9 +2582,9 @@ int pat_ref_read_from_file(struct pat_ref *ref, char **err)
 	 * line. If the line contains spaces, they will be part of the pattern.
 	 * The pattern stops at the first CR, LF or EOF encountered.
 	 */
-	while (fgets(trash.area, trash.size, file) != NULL) {
+	while ((rd = pat_ref_read_line(file, &buf, &buf_size)) > 0) {
 		line++;
-		c = trash.area;
+		c = buf;
 
 		/* ignore lines beginning with a dash */
 		if (*c == '#')
@@ -2560,6 +2610,11 @@ int pat_ref_read_from_file(struct pat_ref *ref, char **err)
 		}
 	}
 
+	if (rd < 0) {
+		memprintf(err, "out of memory when loading patterns from file <%s>", ref->reference);
+		goto out_close;
+	}
+
 	if (ferror(file)) {
 		memprintf(err, "error encountered while reading  <%s> : %s",
 				ref->reference, strerror(errno));
@@ -2568,6 +2623,7 @@ int pat_ref_read_from_file(struct pat_ref *ref, char **err)
 	ret = 1; /* success */
 
  out_close:
+	free(buf);
 	fclose(file);
 	return ret;
 }

What is your configuration?

global
    tune.bufsize 65536
    stats socket /tmp/haproxy.sock level admin

defaults
    mode http
    timeout client 5s
    timeout connect 5s
    timeout server 5s

frontend fe
    bind 127.0.0.1:8080
    acl long req.hdr(x-test) -f long.lst
    http-request return status 200 content-type text/plain lf-string "map_len=%[str(k),map(long.map),length] acl_match=1\n" if long
    http-request return status 200 content-type text/plain lf-string "map_len=%[str(k),map(long.map),length] acl_match=0\n"

Output of haproxy -vv

Vanilla master build used for the reproduction. The issue was first seen on Linux x86_64 with 3.2.21.


HAProxy version 3.5-dev8-e4d1b143b 2026/10/05 - https://haproxy.org/
Status: development branch - not safe for use in production.
Known bugs: https://github.com/haproxy/haproxy/issues?q=is:issue+is:open
Running on: Darwin 24.6.0 Darwin Kernel Version 24.6.0: Tue Jul 21 20:50:11 PDT 2026; root:xnu-11417.140.69.711.44~1/RELEASE_ARM64_T6041 arm64
Build options : 
  TARGET  = osx
  CC      = cc
  CFLAGS  = -O2 -g -fwrapv
  OPTIONS = 
  DEBUG   = 

Feature list : -51DEGREES -ACCEPT4 -BACKTRACE -CLOSEFROM +CPU_AFFINITY -CRYPT_H -DEVICEATLAS -DL -ECH -ENGINE -EPOLL -EVPORTS +FCGI +GETADDRINFO +H2 +KQUEUE -KTLS -LIBATOMIC +LIBCRYPT -LINUX_CAP -LINUX_SPLICE -LINUX_TPROXY -LUA -MATH -MEMORY_PROFILING -MINIDEBUG -NETFILTER -NS -OBSOLETE_LINKER -OPENSSL -OPENSSL_AWSLC -OPENSSL_WOLFSSL -PCRE -PCRE2 -PCRE2_JIT -PCRE_JIT +POLL -PRCTL -PROCCTL -PROMEX -PTHREAD_EMULATION -QUIC -QUIC_OPENSSL_COMPAT -RT -SHM_OPEN +SLZ +SPOE -SSL -STATIC_PCRE -STATIC_PCRE2 -TFO +THREAD -THREAD_DUMP +TPROXY +TRACE -WURFL -ZLIB
Detected feature list :

Default settings :
  bufsize = 16384, maxrewrite = 1024, maxpollevents = 200

Built with multi-threading support (MAX_TGROUPS=32, MAX_THREADS=1024, default=12).
Built with libslz for stateless compression.
Compression algorithms supported : identity("identity"), deflate("deflate"), raw-deflate("deflate"), gzip("gzip")
Built with transparent proxy support using:
Built without PCRE or PCRE2 support (using libc's regex instead)
Encrypted password support via crypt(3): yes
Built with clang compiler version 17.0.0 (clang-1700.6.4.2)

Available polling systems :
     kqueue : pref=300,  test result OK
       poll : pref=200,  test result OK
     select : pref=150,  test result OK
Total: 3 (3 usable), will use kqueue.

Available multiplexer protocols :
(protocols marked as <default> cannot be specified using 'proto' keyword)
         h2 : mode=HTTP  side=FE|BE  mux=H2    flags=HTX|HOL_RISK|NO_UPG
       fcgi : mode=HTTP  side=BE     mux=FCGI  flags=HTX|HOL_RISK|NO_UPG
       spop : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
  <default> : mode=SPOP  side=BE     mux=SPOP  flags=HOL_RISK|NO_UPG
         h1 : mode=HTTP  side=FE|BE  mux=H1    flags=HTX|NO_UPG
  <default> : mode=HTTP  side=FE|BE  mux=H1    flags=HTX
       none : mode=TCP   side=FE|BE  mux=PASS  flags=NO_UPG
  <default> : mode=TCP   side=FE|BE  mux=PASS  flags=

Available services : none

Available filters :
	[BWLIM] bwlim-in
	[BWLIM] bwlim-out
	[CACHE] cache
	[COMP] comp-req
	[COMP] comp-res
	[COMP] compression
	[COMP] decomp-req
	[COMP] decomp-res
	[FCGI] fcgi-app
	[SPOE] spoe
	[TRACE] trace

Last Outputs and Backtraces

N/A (no crash)

Additional Information

Related observations, which may deserve separate issues:

  • Even with a fixed loader, map values remain bounded by tune.bufsize at runtime: pattern_exec_match() copies the matched sample into a trash chunk and truncates it to size - 1 (src/pattern.c:2749). This is consistent with the CLI, whose command line is limited by the buffer size.
  • show map silently omits an entry that does not fit in the trash (chunk_appendf() in src/map.c:408), without any marker. With the patch above and a 70000-char value, show map lists only the other entries, and lookups return 65535 chars. Tools that rebuild a map file from show map output therefore drop such entries without noticing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: needs-triageThis issue needs to be triaged.type: bugThis issue describes a bug.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions