Hi.

I'm planning a new filter to bring HAProxy's own DNA to "cloud" or HTTP(S)
object storages, such as Azure Blob Storage, Google Cloud Storage and S3
compatible storages. The problem is that most of these storages require
authentication and/or a request signature before they deliver the data.

The filter API already covers this to a certain degree. What was missing is
a callback called once the server is known, just before the request is sent
to it. A signature depends on the target server, and the server may change
on a redispatch or a L7 retry, so the request must be finalized at that
point. This patch series adds such an "http_pre_send" callback, along with
a first shot of the cloudauth filter.

The cloudauth filter is opt-in at build time (USE_CLOUDAUTH) and marked as
experimental. For now it only stamps the request with its target server, to
show that the callback follows redispatches and L7 retries. The request
signers will be plugged in at the same place.

While working on this, I also noticed that http_reset is documented as being
called on L7 retries, but it never is. The first patch fixes the
documentation.

Of course I used AI (Claude) to help with the patches, which is noted with
a Co-Authored-By trailer in each commit.

What do you think about the idea? Any feedback is welcome.

Regards,
Aleks
From ad2f2662e6c40858a3bcdfb61b5f4c4ef1d40fc4 Mon Sep 17 00:00:00 2001
From: Aleksandar Lazic <[email protected]>
Date: Fri, 16 Oct 2026 21:17:17 +0200
Subject: [PATCH 8/8] REGTESTS: filters: add tests for the cloudauth filter

The first test checks that the cloudauth filter stamps the request with
the server it is sent to, on a plain request with a spoofed header,
on a L7 retry to another server and on a redispatch after a connection
failure. It verifies at the same time that the http_pre_send callback
is called again on retries.

The second one checks that the filter is rejected when the experimental
directives are not allowed.

Both require a build with USE_CLOUDAUTH.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 reg-tests/filters/cloudauth-experimental.vtc | 13 +++
 reg-tests/filters/cloudauth-pre-send.vtc     | 95 ++++++++++++++++++++
 2 files changed, 108 insertions(+)
 create mode 100644 reg-tests/filters/cloudauth-experimental.vtc
 create mode 100644 reg-tests/filters/cloudauth-pre-send.vtc

diff --git a/reg-tests/filters/cloudauth-experimental.vtc b/reg-tests/filters/cloudauth-experimental.vtc
new file mode 100644
index 000000000..7cba5e601
--- /dev/null
+++ b/reg-tests/filters/cloudauth-experimental.vtc
@@ -0,0 +1,13 @@
+varnishtest "The cloudauth filter must be allowed via expose-experimental-directives"
+
+feature cmd "$HAPROXY_PROGRAM -cc 'feature(CLOUDAUTH)'"
+feature ignore_unknown_macro
+
+haproxy h1 -conf-BAD {'filter-config cloudauth' is experimental, must be allowed via a global 'expose-experimental-directives'} {
+    defaults
+        mode http
+
+    backend be
+        filter-config cloudauth enabled
+        server srv 127.0.0.1:80
+}
diff --git a/reg-tests/filters/cloudauth-pre-send.vtc b/reg-tests/filters/cloudauth-pre-send.vtc
new file mode 100644
index 000000000..ea00d7de3
--- /dev/null
+++ b/reg-tests/filters/cloudauth-pre-send.vtc
@@ -0,0 +1,95 @@
+varnishtest "The http_pre_send filter callback runs once the server is known, and again on retries"
+
+feature cmd "$HAPROXY_PROGRAM -cc 'feature(CLOUDAUTH)'"
+feature ignore_unknown_macro
+
+barrier b_retry cond 2
+
+# plain request, the client header must be replaced
+server s0 {
+    rxreq
+    expect req.http.x-cloudauth-target == "be_plain/srv"
+    txresp -body "plain"
+} -start
+
+# L7 retry: the request must be stamped again for the second server
+server s1 {
+    rxreq
+    expect req.http.x-cloudauth-target == "be_l7/bad"
+    txresp -status 503 -body "retry me"
+    barrier b_retry sync
+} -start
+
+server s2 {
+    rxreq
+    expect req.http.x-cloudauth-target == "be_l7/good"
+    barrier b_retry sync
+    txresp -body "l7 retry"
+} -start
+
+# connection failure then redispatch
+server s3 {
+    rxreq
+    expect req.http.x-cloudauth-target == "be_conn/alive"
+    txresp -body "redispatch"
+} -start
+
+haproxy h1 -conf {
+    global
+        nbthread 1
+        expose-experimental-directives
+    defaults
+        mode http
+        timeout connect "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout client "${HAPROXY_TEST_TIMEOUT-5s}"
+        timeout server "${HAPROXY_TEST_TIMEOUT-5s}"
+    frontend fe
+        bind "fd@${fe}"
+        http-after-response set-header x-retries "%[txn.conn_retries]"
+        http-after-response set-header x-redispatched "%[txn.redispatched]"
+        use_backend be_l7 if { path /l7 }
+        use_backend be_conn if { path /conn }
+        default_backend be_plain
+    backend be_plain
+        filter-config cloudauth enabled
+        server srv ${s0_addr}:${s0_port}
+    backend be_l7
+        balance first
+        retries 1
+        retry-on 503
+        option redispatch
+        filter-config cloudauth enabled
+        server bad ${s1_addr}:${s1_port}
+        server good ${s2_addr}:${s2_port}
+    backend be_conn
+        balance first
+        retries 1
+        option redispatch
+        filter-config cloudauth enabled
+        server dead 127.0.0.1:1
+        server alive ${s3_addr}:${s3_port}
+} -start
+
+client c1 -connect ${h1_fe_sock} {
+    txreq -url "/" -hdr "x-cloudauth-target: spoofed"
+    rxresp
+    expect resp.status == 200
+    expect resp.body == "plain"
+} -run
+
+client c2 -connect ${h1_fe_sock} {
+    txreq -url "/l7"
+    rxresp
+    expect resp.status == 200
+    expect resp.http.x-retries == "1"
+    expect resp.http.x-redispatched == "1"
+    expect resp.body == "l7 retry"
+} -run
+
+client c3 -connect ${h1_fe_sock} {
+    txreq -url "/conn"
+    rxresp
+    expect resp.status == 200
+    expect resp.http.x-redispatched == "1"
+    expect resp.body == "redispatch"
+} -run
-- 
2.56.0

From b4a56d6c722534825dcd35dd081a4e21848872fc Mon Sep 17 00:00:00 2001
From: Aleksandar Lazic <[email protected]>
Date: Fri, 16 Oct 2026 21:17:17 +0200
Subject: [PATCH 7/8] MINOR: cloudauth: stamp the request with its target
 server

Implement the http_pre_send callback in the cloudauth filter. For
now it sets the header "x-cloudauth-target", or the one set with the
"header" parameter, to "<backend>/<server>", after the removal of
any existing occurrence, including the ones sent by the client.

It is a placeholder for the request signers, which will be called at
the same place. It shows that the request is finalized for the right
server, including on redispatches and L7 retries.

The filter is documented in a new section of the configuration manual.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 doc/configuration.txt | 48 ++++++++++++++++++++++++++
 src/flt_cloudauth.c   | 80 ++++++++++++++++++++++++++++++++++++++++---
 2 files changed, 124 insertions(+), 4 deletions(-)

diff --git a/doc/configuration.txt b/doc/configuration.txt
index 2f047285d..31673a23a 100644
--- a/doc/configuration.txt
+++ b/doc/configuration.txt
@@ -127,6 +127,7 @@ Summary
 9.6.      fcgi-app
 9.7.      OpenTracing
 9.8.      Bandwidth limitation
+9.9.      Cloud storage authentication
 
 10.   FastCGI applications
 10.1.     Setup
@@ -31935,6 +31936,53 @@ See also : "tcp-request content set-bandwidth-limit",
            "http-request set-bandwidth-limit" and
            "http-response set-bandwidth-limit".
 
+
+9.9. Cloud storage authentication
+---------------------------------
+
+filter-config cloudauth [enabled] [header <name>]
+  Declares the cloud storage authentication filter. It is only available when
+  HAProxy was built with USE_CLOUDAUTH, which also requires an OpenSSL
+  compatible library. It may only be declared with the new mode, and only once
+  per proxy, so the instance id is not needed. This filter is experimental and
+  must be allowed via a global "expose-experimental-directives".
+
+  Arguments :
+
+    <name>      is the name of the header set on the request. The default is
+                "x-cloudauth-target".
+
+  This filter is a work in progress. Its purpose is to authenticate the
+  requests sent to cloud object storages (Azure Blob Storage, Google Cloud
+  Storage, S3 compatible storages), which requires to sign each request for
+  the server it is sent to. For now, it only sets the header <name> to
+  "<backend>/<server>" on each request, any existing occurrence being removed
+  first, including the ones sent by the client.
+
+  The header is set once the server is known, just before the request is sent
+  to it. It is set again for the new server when the request is retried,
+  either after a connection failure followed by a redispatch, or after a L7
+  retry (see "retry-on"). This is the place where the request signatures will
+  be computed.
+
+  Example:
+    global
+        expose-experimental-directives
+
+    backend storage
+        mode http
+        balance first
+        retries 1
+        retry-on conn-failure 503
+        option redispatch
+        filter-config cloudauth enabled header x-target
+        server primary   192.168.0.10:80
+        server secondary 192.168.0.11:80
+
+See also : "filter-config", "retry-on", "option redispatch" and
+           "expose-experimental-directives".
+
+
 10. FastCGI applications
 -------------------------
 
diff --git a/src/flt_cloudauth.c b/src/flt_cloudauth.c
index ef283fe93..7d6359d9d 100644
--- a/src/flt_cloudauth.c
+++ b/src/flt_cloudauth.c
@@ -8,19 +8,40 @@
  * as published by the Free Software Foundation; either version
  * 2 of the License, or (at your option) any later version.
  *
- * For now the filter does nothing.
+ * For now this is only a proof of concept of the 'http_pre_send' filter
+ * callback: it stamps the request with the server it is about to be sent to.
+ * The request signers will be plugged at the same place.
  */
 
 #include <haproxy/api.h>
 #include <haproxy/filters.h>
 #include <haproxy/global.h>
+#include <haproxy/http_ana.h>
+#include <haproxy/http_htx.h>
+#include <haproxy/htx.h>
 #include <haproxy/proxy.h>
+#include <haproxy/server.h>
 #include <haproxy/stream.h>
 #include <haproxy/tools.h>
 
 const char *cloudauth_filter_cls_name = "cloudauth";
 static const char *cloudauth_flt_id = "cloudauth filter";
 
+struct cloudauth_flt_conf {
+	struct ist hdr_name; /* header carrying the target server */
+};
+
+static void cloudauth_flt_deinit(struct proxy *px, struct flt_conf *fconf)
+{
+	struct cloudauth_flt_conf *conf = fconf->conf;
+
+	if (conf) {
+		istfree(&conf->hdr_name);
+		free(conf);
+	}
+	fconf->conf = NULL;
+}
+
 static int cloudauth_flt_attach(struct stream *s, struct filter *filter)
 {
 	/* Only HTTP streams are concerned */
@@ -29,16 +50,49 @@ static int cloudauth_flt_attach(struct stream *s, struct filter *filter)
 	return 1;
 }
 
+/* Called just before the request is sent to the server. It may be called
+ * several times for the same request (L7 retries), possibly for different
+ * servers, so the header is always replaced.
+ */
+static int cloudauth_flt_http_pre_send(struct stream *s, struct filter *filter,
+                                       struct http_msg *msg)
+{
+	struct cloudauth_flt_conf *conf = FLT_CONF(filter);
+	struct htx *htx = htxbuf(&msg->chn->buf);
+	struct server *srv = objt_server(s->target);
+	struct http_hdr_ctx ctx;
+	struct buffer *val;
+
+	if (!srv)
+		return 1;
+
+	ctx.blk = NULL;
+	while (http_find_header(htx, conf->hdr_name, &ctx, 1))
+		http_remove_header(htx, &ctx);
+
+	val = get_trash_chunk();
+	chunk_printf(val, "%s/%s", s->be->id, srv->id);
+	if (!http_add_header(htx, conf->hdr_name, ist2(val->area, val->data), 0))
+		return -1;
+	return 1;
+}
+
 static struct flt_ops cloudauth_flt_ops = {
+	.deinit        = cloudauth_flt_deinit,
 	.attach        = cloudauth_flt_attach,
+	.http_pre_send = cloudauth_flt_http_pre_send,
 };
 
 /* Parses the arguments of a "cloudauth" filter instance :
- *    filter-config cloudauth [enabled]
+ *    filter-config cloudauth [enabled] [header <name>]
  * Returns 0 on success, < 0 on error.
  */
 int cloudauth_flt_parse_instance(char **args, struct proxy *px, struct filter_instance *inst, char **err)
 {
+	struct cloudauth_flt_conf *conf;
+	const char *hdr = "x-cloudauth-target";
+	int i;
+
 	if (!experimental_directives_allowed) {
 		memprintf(err, "'filter-config %s' is experimental, must be allowed via a global 'expose-experimental-directives'",
 		          cloudauth_filter_cls_name);
@@ -46,14 +100,32 @@ int cloudauth_flt_parse_instance(char **args, struct proxy *px, struct filter_in
 	}
 	mark_tainted(TAINTED_CONFIG_EXP_KW_DECLARED);
 
-	if (args[0] && *args[0]) {
+	for (i = 0; args[i] && *args[i]; i++) {
+		if (strcmp(args[i], "header") == 0 && args[i + 1] && *args[i + 1]) {
+			hdr = args[++i];
+			continue;
+		}
 		memprintf(err, "'filter-config %s' : unknown keyword '%s'",
-		          cloudauth_filter_cls_name, args[0]);
+		          cloudauth_filter_cls_name, args[i]);
 		return -1;
 	}
 
+	conf = calloc(1, sizeof(*conf));
+	if (!conf)
+		goto oom;
+	conf->hdr_name = istdup(ist(hdr));
+	if (!isttest(conf->hdr_name)) {
+		free(conf);
+		goto oom;
+	}
+
 	inst->fconf->id    = cloudauth_flt_id;
+	inst->fconf->conf  = conf;
 	inst->fconf->ops   = &cloudauth_flt_ops;
 	inst->fconf->flags |= FLT_CFG_FL_HTX;
 	return 0;
+
+  oom:
+	memprintf(err, "out of memory");
+	return -1;
 }
-- 
2.56.0

From e21b90d901312074b7bfbad3890b3cb9b331362e Mon Sep 17 00:00:00 2001
From: Aleksandar Lazic <[email protected]>
Date: Fri, 16 Oct 2026 21:17:17 +0200
Subject: [PATCH 6/8] MINOR: cloudauth: add the filter class and the
 USE_CLOUDAUTH option

The cloudauth filter will authenticate the requests sent to cloud
object storages (Azure Blob Storage, Google Cloud Storage, S3
compatible storages). It is built only with USE_CLOUDAUTH=1, which
requires an OpenSSL compatible library because the request signatures
need it. The build fails with an explicit error otherwise. The option
is not enabled by default.

This first patch only adds the filter class, which may be declared
with "filter-config cloudauth". It is only supported in the new filter
mode and only once per proxy. It is placed last on the request side
because it must see the final request. It is experimental and must
be allowed via a global "expose-experimental-directives".

The filter does nothing yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 Makefile                  | 10 ++++++-
 include/haproxy/filters.h |  7 +++++
 src/filters.c             | 16 +++++++++++
 src/flt_cloudauth.c       | 59 +++++++++++++++++++++++++++++++++++++++
 4 files changed, 91 insertions(+), 1 deletion(-)
 create mode 100644 src/flt_cloudauth.c

diff --git a/Makefile b/Makefile
index 2d3dcee33..81b18af41 100644
--- a/Makefile
+++ b/Makefile
@@ -68,6 +68,7 @@
 #   USE_FCGI                : enable the FCGI subsystem. Always on.
 #   USE_H2                  : enable HTTP/2 subsystem. Always on.
 #   USE_SPOE                : enable the SPOE subsystem. Always on.
+#   USE_CLOUDAUTH           : enable the cloud storage authentication filter (requires USE_OPENSSL).
 #
 # Options can be forced by specifying "USE_xxx=1" or can be disabled by using
 # "USE_xxx=" (empty string). The list of enabled and disabled options for a
@@ -358,7 +359,7 @@ use_opts = USE_EPOLL USE_KQUEUE USE_NETFILTER USE_POLL                        \
            USE_MEMORY_PROFILING USE_SHM_OPEN USE_MINIDEBUG                    \
            USE_STATIC_PCRE USE_STATIC_PCRE2                                   \
            USE_PCRE USE_PCRE_JIT USE_PCRE2 USE_PCRE2_JIT                      \
-           USE_QUIC_OPENSSL_COMPAT USE_KTLS
+           USE_QUIC_OPENSSL_COMPAT USE_KTLS USE_CLOUDAUTH
 
 # preset all variables for all supported build options among use_opts
 $(reset_opts_vars)
@@ -714,6 +715,13 @@ ifneq ($(USE_OPENSSL:0=),)
   endif
 endif
 
+ifneq ($(USE_CLOUDAUTH:0=),)
+  ifeq ($(USE_OPENSSL:0=),)
+    $(error USE_CLOUDAUTH requires an OpenSSL-compatible library, please also set USE_OPENSSL=1)
+  endif
+  OPTIONS_OBJS += src/flt_cloudauth.o
+endif
+
 ifneq ($(USE_ENGINE:0=),)
   # OpenSSL 3.0 emits loud deprecation warnings by default when building with
   # engine support, and this option is made to silence them. Better use it
diff --git a/include/haproxy/filters.h b/include/haproxy/filters.h
index 8a579a32b..4051f340f 100644
--- a/include/haproxy/filters.h
+++ b/include/haproxy/filters.h
@@ -66,6 +66,10 @@ extern const char *fcgi_flt_id;
 extern const char *fcgi_filter_cls_name;
 extern struct filter_class flt_fcgi_cls;
 #endif
+#if defined(USE_CLOUDAUTH)
+extern const char *cloudauth_filter_cls_name;
+extern struct filter_class flt_cloudauth_cls;
+#endif
 
 /* Parsing functions of the internal filter classes, called to finalize a
  * filter instance (see "filter-config" directive)
@@ -87,6 +91,9 @@ int bwlim_out_flt_parse_instance(char **args, struct proxy *px, struct filter_in
 #if defined(USE_FCGI)
 int fcgi_flt_parse_instance(char **args, struct proxy *px, struct filter_instance *inst, char **err);
 #endif
+#if defined(USE_CLOUDAUTH)
+int cloudauth_flt_parse_instance(char **args, struct proxy *px, struct filter_instance *inst, char **err);
+#endif
 
 #define FLT_ID(flt)   (flt)->config->id
 #define FLT_CONF(flt) (flt)->config->conf
diff --git a/src/filters.c b/src/filters.c
index 3ea0c3016..a6521a444 100644
--- a/src/filters.c
+++ b/src/filters.c
@@ -54,6 +54,9 @@ struct filter_class flt_bwlim_out_cls;
 #if defined(USE_FCGI)
 struct filter_class flt_fcgi_cls;
 #endif
+#if defined(USE_CLOUDAUTH)
+struct filter_class flt_cloudauth_cls;
+#endif
 
 /* Global registry of all filter classes, in registration order. Used for the
  * class lookup and by "haproxy -vv".
@@ -3754,6 +3757,9 @@ static void filter_init_classes(void)
 #if defined(USE_FCGI)
 	flt_init_class(&flt_fcgi_cls,          fcgi_filter_cls_name,          0,                fcgi_flt_parse_instance);
 #endif
+#if defined(USE_CLOUDAUTH)
+	flt_init_class(&flt_cloudauth_cls,     cloudauth_filter_cls_name,     0,                cloudauth_flt_parse_instance);
+#endif
 
 	/* Registry: all the known classes, in registration order. It is a flat
 	 * list, and it stays flat whatever the placements are, so that
@@ -3776,6 +3782,9 @@ static void filter_init_classes(void)
 #if defined(USE_FCGI)
 	LIST_APPEND(&filter_classes, &flt_fcgi_cls.list);
 #endif
+#if defined(USE_CLOUDAUTH)
+	LIST_APPEND(&filter_classes, &flt_cloudauth_cls.list);
+#endif
 
 	/* Global order: the same classes, in the global evaluation order. It
 	 * is a placement tree, the classes are re-parented by
@@ -3798,6 +3807,9 @@ static void filter_init_classes(void)
 #if defined(USE_FCGI)
 	LIST_APPEND(&global_filter_classes, &flt_fcgi_cls.global.list);
 #endif
+#if defined(USE_CLOUDAUTH)
+	LIST_APPEND(&global_filter_classes, &flt_cloudauth_cls.global.list);
+#endif
 
 	LIST_APPEND(&req_filter_classes, &flt_trace_cls.req.list);
 	LIST_APPEND(&req_filter_classes, &flt_decomp_req_cls.req.list);
@@ -3814,6 +3826,10 @@ static void filter_init_classes(void)
 #if defined(USE_FCGI)
 	LIST_APPEND(&req_filter_classes, &flt_fcgi_cls.req.list);
 #endif
+#if defined(USE_CLOUDAUTH)
+	/* last one: it finalizes the request just before it is sent */
+	LIST_APPEND(&req_filter_classes, &flt_cloudauth_cls.req.list);
+#endif
 
 #if defined(USE_FCGI)
 	LIST_APPEND(&res_filter_classes, &flt_fcgi_cls.res.list);
diff --git a/src/flt_cloudauth.c b/src/flt_cloudauth.c
new file mode 100644
index 000000000..ef283fe93
--- /dev/null
+++ b/src/flt_cloudauth.c
@@ -0,0 +1,59 @@
+/*
+ * Cloud storage request authentication filter.
+ *
+ * Copyright 2026 Aleksandar Lazic <[email protected]>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version
+ * 2 of the License, or (at your option) any later version.
+ *
+ * For now the filter does nothing.
+ */
+
+#include <haproxy/api.h>
+#include <haproxy/filters.h>
+#include <haproxy/global.h>
+#include <haproxy/proxy.h>
+#include <haproxy/stream.h>
+#include <haproxy/tools.h>
+
+const char *cloudauth_filter_cls_name = "cloudauth";
+static const char *cloudauth_flt_id = "cloudauth filter";
+
+static int cloudauth_flt_attach(struct stream *s, struct filter *filter)
+{
+	/* Only HTTP streams are concerned */
+	if (!IS_HTX_STRM(s))
+		return 0;
+	return 1;
+}
+
+static struct flt_ops cloudauth_flt_ops = {
+	.attach        = cloudauth_flt_attach,
+};
+
+/* Parses the arguments of a "cloudauth" filter instance :
+ *    filter-config cloudauth [enabled]
+ * Returns 0 on success, < 0 on error.
+ */
+int cloudauth_flt_parse_instance(char **args, struct proxy *px, struct filter_instance *inst, char **err)
+{
+	if (!experimental_directives_allowed) {
+		memprintf(err, "'filter-config %s' is experimental, must be allowed via a global 'expose-experimental-directives'",
+		          cloudauth_filter_cls_name);
+		return -1;
+	}
+	mark_tainted(TAINTED_CONFIG_EXP_KW_DECLARED);
+
+	if (args[0] && *args[0]) {
+		memprintf(err, "'filter-config %s' : unknown keyword '%s'",
+		          cloudauth_filter_cls_name, args[0]);
+		return -1;
+	}
+
+	inst->fconf->id    = cloudauth_flt_id;
+	inst->fconf->ops   = &cloudauth_flt_ops;
+	inst->fconf->flags |= FLT_CFG_FL_HTX;
+	return 0;
+}
-- 
2.56.0

From 13d796ee8a405e56faf843f4a70770a819eca860 Mon Sep 17 00:00:00 2001
From: Aleksandar Lazic <[email protected]>
Date: Fri, 16 Oct 2026 21:17:17 +0200
Subject: [PATCH 5/8] DOC: filters: document the http_pre_send callback

Describe when the http_pre_send callback is called, what it may do
and what is expected from it, with an example adding a header with
the server name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 doc/internals/api/filters.txt | 44 ++++++++++++++++++++++++++++++++++-
 1 file changed, 43 insertions(+), 1 deletion(-)

diff --git a/doc/internals/api/filters.txt b/doc/internals/api/filters.txt
index e5e3e3151..23d8edad6 100644
--- a/doc/internals/api/filters.txt
+++ b/doc/internals/api/filters.txt
@@ -347,6 +347,8 @@ existing callbacks. Available callbacks are listed in the following structure :
         void (*http_reply)         (struct stream *s, struct filter *f,
                                     short status,
                                     const struct buffer *msg);
+        int  (*http_pre_send)      (struct stream *s, struct filter *f,
+                                    struct http_msg *msg);
 
         /*
          * TCP callbacks
@@ -1542,7 +1544,7 @@ In addition, there are two others callbacks :
             return 0;
         }
 
-Then, to finish, there are 2 informational callbacks :
+Then, there are 2 informational callbacks :
 
   * 'flt_ops.http_reset' : This callback is called when an HTTP message is
     reset. This happens when a 1xx informational response is received. It
@@ -1554,6 +1556,46 @@ Then, to finish, there are 2 informational callbacks :
     response to the client. This mainly happens when an error or a redirect
     occurs.
 
+Finally, there is a callback to finalize the request once the server is known:
+
+  * 'flt_ops.http_pre_send' : This callback is called on the request channel,
+    after the server was assigned and just before the request headers are sent
+    to it. At this stage, the request analysis is finished and the message
+    headers are already scheduled for forwarding, so all the HTTP rules were
+    evaluated and the target server is available in s->target. It is the right
+    place to add information depending on the server, for instance a request
+    signature. The callback is called once per connection attempt. It is
+    called again when the same request is sent once more, after a connection failure followed by a redispatch or after a L7
+    retry, possibly to another server. So it must be idempotent: everything
+    added for a previous server must be replaced. Only the headers may be
+    rewritten, the payload must not be altered. The amount of output data of
+    the channel is adjusted by the core after the call. It returns a negative
+    value on error, in which case the request is not sent and the stream is
+    aborted, any other value otherwise. Here is an example :
+
+        /* Returns a negative value if an error occurs, any other value
+         * otherwise. */
+        static int
+        my_filter_http_pre_send(struct stream *s, struct filter *filter,
+                                struct http_msg *msg)
+        {
+            struct htx *htx = htxbuf(&msg->chn->buf);
+            struct server *srv = objt_server(s->target);
+            struct http_hdr_ctx ctx;
+
+            if (!srv)
+                return 1;
+
+            /* Remove the value set for a previous server, if any */
+            ctx.blk = NULL;
+            while (http_find_header(htx, ist("x-server"), &ctx, 1))
+                http_remove_header(htx, &ctx);
+
+            if (!http_add_header(htx, ist("x-server"), ist(srv->id), 0))
+                return -1;
+            return 1;
+        }
+
 
 3.6.3. REWRITING DATA
 --------------------
-- 
2.56.0

From 1e893a8030659cef5480c89ef71fde02013f96c2 Mon Sep 17 00:00:00 2001
From: Aleksandar Lazic <[email protected]>
Date: Fri, 16 Oct 2026 21:17:17 +0200
Subject: [PATCH 4/8] MINOR: flt_trace: trace the http_pre_send callback

The trace filter is expected to implement all the filter callbacks to
help debugging the filters activity. Add the http_pre_send callback,
reporting the server the request is about to be sent to.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 src/flt_trace.c | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/src/flt_trace.c b/src/flt_trace.c
index a29cc786b..2c83bf740 100644
--- a/src/flt_trace.c
+++ b/src/flt_trace.c
@@ -21,6 +21,7 @@
 #include <haproxy/http_htx.h>
 #include <haproxy/htx.h>
 #include <haproxy/proxy-t.h>
+#include <haproxy/server-t.h>
 #include <haproxy/stream.h>
 #include <haproxy/time.h>
 #include <haproxy/tools.h>
@@ -523,6 +524,20 @@ trace_http_reply(struct stream *s, struct filter *filter, short status,
 		   __FUNCTION__, "-", proxy_mode(s), stream_pos(s));
 }
 
+static int
+trace_http_pre_send(struct stream *s, struct filter *filter,
+		    struct http_msg *msg)
+{
+	struct trace_config *conf = FLT_CONF(filter);
+	struct server *srv = objt_server(s->target);
+
+	FLT_STRM_TRACE(conf, s, "%-25s: channel=%-10s - mode=%-5s (%s) - server=%s",
+		   __FUNCTION__,
+		   channel_label(msg->chn), proxy_mode(s), stream_pos(s),
+		   srv ? srv->id : "<none>");
+	return 1;
+}
+
 /**************************************************************************
  * Hooks to filter TCP data
  *************************************************************************/
@@ -607,6 +622,7 @@ struct flt_ops trace_ops = {
 	.http_end            = trace_http_end,
 	.http_reset          = trace_http_reset,
 	.http_reply          = trace_http_reply,
+	.http_pre_send       = trace_http_pre_send,
 
 	/* Filter TCP data */
 	.tcp_payload        = trace_tcp_payload,
-- 
2.56.0

From 085f3c01b6aee61567336d7edcb0f60f558f4ab8 Mon Sep 17 00:00:00 2001
From: Aleksandar Lazic <[email protected]>
Date: Fri, 16 Oct 2026 21:17:17 +0200
Subject: [PATCH 3/8] MEDIUM: stconn: call http_pre_send before sending a
 request

Call flt_http_pre_send() from sc_conn_send() just before the request
is passed to the mux, when the server is known. It is only performed
on the request channel of HTTP streams with filters, when the start
line is at the head of the output data, meaning the request headers
were not sent yet.

The call is placed after the copy of the request made for the
L7 retries. This way, the copy is not altered by the filters,
and when it is restored by do_l7_retry(), the start line is at the
head of the output data again, so the filters are called again,
possibly for another server. The same is true after a connection
failure followed by a redispatch, because the mux does not accept
data before the connection is established. Both cases are covered by
the conn_retries check performed by flt_http_pre_send().

If a filter reports an error, the request is not sent and an error
is reported on the stream connector, as for a send error.

This changes the send path of all HTTP streams with filters,
hence MEDIUM, but it has no effect until a filter implements the
callback. The http-send-name-header option was handled in a similar
place before it was moved to the muxes by commit 72ba6cd8c ("MINOR:
http: Add server name header from HTTP multiplexers"). Unlike that
code, the HTX representation and the call at the stream connector
level, once per attempt, avoid the rewinding issues it suffered from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 src/stconn.c | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/src/stconn.c b/src/stconn.c
index 3aa5be486..332a79b01 100644
--- a/src/stconn.c
+++ b/src/stconn.c
@@ -1544,6 +1544,24 @@ int sc_conn_send(struct stconn *sc)
 			}
 		}
 
+		/* Now the server is known, let the filters finalize the
+		 * request headers. The start line is at the head of the
+		 * output data only before the first send and after a L7
+		 * retry, the latter being done on the copy above which was
+		 * not yet seen by the filters.
+		 */
+		if (!(oc->flags & CF_ISRESP) && HAS_FILTERS(s) &&
+		    (s->flags & SF_TXN_MASK) == SF_TXN_HTTP &&
+		    htx_get_first_type(htxbuf(&oc->buf)) == HTX_BLK_REQ_SL) {
+			if (flt_http_pre_send(s, &s->txn.http->req) < 0) {
+				if (!(s->flags & SF_ERR_MASK))
+					s->flags |= SF_ERR_INTERNAL;
+				oc->flags |= CF_WRITE_EVENT;
+				sc->flags |= SC_FL_ERROR;
+				return 1;
+			}
+		}
+
 		if ((sc->flags & SC_FL_SHUT_WANTED) && co_data(oc) == c_data(oc))
 			send_flag |= CO_SFL_LAST_DATA;
 
-- 
2.56.0

From 37a9ef1f9181bc74bafa749ee7e63c863188e53f Mon Sep 17 00:00:00 2001
From: Aleksandar Lazic <[email protected]>
Date: Fri, 16 Oct 2026 21:17:17 +0200
Subject: [PATCH 2/8] MINOR: filters: add the http_pre_send callback

Some filters need to finalize the request once the server it will
be sent to is known. The typical case is a request signature that
depends on the target host and on the final set of headers, as required
by the cloud object storages. None of the existing callbacks fits:
the HTTP analysis is finished before the server is assigned, and the
server may change on a redispatch or a L7 retry.

This patch adds the 'http_pre_send' callback to the flt_ops structure,
and flt_http_pre_send() to call it for all the filters attached to the
request channel. Only the headers may be rewritten. As they are already
scheduled for forwarding at this stage, the amount of output data of
the channel is adjusted after the calls, as http_send_name_header()
used to do.

The callback is called once per connection attempt. The request
may be presented several times for the same attempt, for instance
when the mux refuses it until the connection is established, so the
attempt (conn_retries + 1) of the last call is saved in the stream
filters state.

Nothing calls flt_http_pre_send() yet, so there is no functional
change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 include/haproxy/filters-t.h | 11 ++++++++
 include/haproxy/filters.h   |  1 +
 src/filters.c               | 51 +++++++++++++++++++++++++++++++++++++
 3 files changed, 63 insertions(+)

diff --git a/include/haproxy/filters-t.h b/include/haproxy/filters-t.h
index 88a758edf..06b125bdd 100644
--- a/include/haproxy/filters-t.h
+++ b/include/haproxy/filters-t.h
@@ -152,6 +152,15 @@ struct flt_kw_list {
  *                          to the client (mainly, when an error or a redirect
  *                          occur).
  *                          Returns nothing.
+ *  - http_pre_send       : Called on the request channel, after the server
+ *                          was assigned and just before the request headers
+ *                          are sent to it. It is called once per connection
+ *                          attempt, so again if the request is sent once more
+ *                          (redispatch, L7 retry), possibly to another server,
+ *                          so it must be idempotent. Header rewrites
+ *                          are allowed, the body must not be altered.
+ *                          Returns a negative value if an error occurs, any
+ *                          other value otherwise.
  *
  *
  *  - tcp_payload         : Called when some data can be consumed.
@@ -195,6 +204,7 @@ struct flt_ops {
 	void (*http_reset)         (struct stream *s, struct filter *f, struct http_msg *msg);
 	void (*http_reply)         (struct stream *s, struct filter *f, short status,
 				    const struct buffer *msg);
+	int  (*http_pre_send)      (struct stream *s, struct filter *f, struct http_msg *msg);
 
 	/*
 	 * TCP callbacks
@@ -250,6 +260,7 @@ struct filter {
 struct strm_flt {
 	struct list    filters;               /* List of filters attached to a stream */
 	unsigned short flags;                 /* STRM_FL_* */
+	int pre_send_try;                     /* connection attempt (conn_retries + 1) of the last http_pre_send call, 0 if none */
 };
 
 /* structure holding filter state for some members that are channel oriented */
diff --git a/include/haproxy/filters.h b/include/haproxy/filters.h
index 18a66f038..8a579a32b 100644
--- a/include/haproxy/filters.h
+++ b/include/haproxy/filters.h
@@ -180,6 +180,7 @@ int  flt_http_end(struct stream *s, struct http_msg *msg);
 
 void flt_http_reset(struct stream *s, struct http_msg *msg);
 void flt_http_reply(struct stream *s, short status, const struct buffer *msg);
+int  flt_http_pre_send(struct stream *s, struct http_msg *msg);
 
 int  flt_start_analyze(struct stream *s, struct channel *chn, unsigned int an_bit);
 int  flt_pre_analyze(struct stream *s, struct channel *chn, unsigned int an_bit);
diff --git a/src/filters.c b/src/filters.c
index a76a146ff..3ea0c3016 100644
--- a/src/filters.c
+++ b/src/filters.c
@@ -1147,6 +1147,57 @@ flt_http_reply(struct stream *s, short status, const struct buffer *msg)
 	DBG_TRACE_LEAVE(STRM_EV_STRM_ANA|STRM_EV_HTTP_ANA|STRM_EV_FLT_ANA, s);
 }
 
+/*
+ * Calls 'http_pre_send' callback for all filters attached to the request
+ * channel of a stream. It is called from the stream connector just before the
+ * request headers are sent to the server, once the server is known. Filters
+ * may rewrite the headers, so the amount of output data of the channel is
+ * adjusted accordingly. Returns a negative value if an error occurs, any other
+ * value otherwise.
+ */
+int
+flt_http_pre_send(struct stream *s, struct http_msg *msg)
+{
+	struct channel *chn = msg->chn;
+	struct filter *filter;
+	struct htx *htx = htxbuf(&chn->buf);
+	uint32_t data = htx->data;
+	int ret = 1;
+
+	/* The request may be presented several times for the same connection
+	 * attempt, for instance when the mux refuses it until the connection
+	 * is established. Each retry increments conn_retries.
+	 */
+	if (strm_flt(s)->pre_send_try == s->conn_retries + 1)
+		return 1;
+
+	DBG_TRACE_ENTER(STRM_EV_STRM_ANA|STRM_EV_HTTP_ANA|STRM_EV_FLT_ANA, s, s->txn.http, msg);
+	for (filter = flt_list_start(s, chn); filter;
+	     filter = flt_list_next(s, chn, filter)) {
+		if (FLT_OPS(filter)->http_pre_send) {
+			struct thread_exec_ctx exec_ctx = EXEC_CTX_MAKE(TH_EX_CTX_FLT, filter->config);
+
+			DBG_TRACE_DEVEL(FLT_ID(filter), STRM_EV_HTTP_ANA|STRM_EV_FLT_ANA, s);
+			filter->calls++;
+			ret = EXEC_CTX_WITH_RET(exec_ctx, FLT_OPS(filter)->http_pre_send(s, filter, msg));
+			if (ret < 0)
+				break;
+		}
+	}
+
+	/* the headers are part of the output data, account for the rewrites */
+	if (co_data(chn)) {
+		if (data >= htx->data)
+			c_rew(chn, data - htx->data);
+		else
+			c_adv(chn, htx->data - data);
+	}
+	if (ret >= 0)
+		strm_flt(s)->pre_send_try = s->conn_retries + 1;
+	DBG_TRACE_LEAVE(STRM_EV_STRM_ANA|STRM_EV_HTTP_ANA|STRM_EV_FLT_ANA, s);
+	return ret;
+}
+
 /*
  * Calls 'http_payload' callback for all "data" filters attached to a
  * stream. This function is called when some data can be forwarded in the
-- 
2.56.0

From 10d7330a2718e392fc17a847ea1ce810a8bf43b6 Mon Sep 17 00:00:00 2001
From: Aleksandar Lazic <[email protected]>
Date: Fri, 16 Oct 2026 21:17:17 +0200
Subject: [PATCH 1/8] DOC: filters: http_reset is not called on L7 retries

The http_reset callback is documented as being called on L7 retries
since commit a254a37ad ("MEDIUM: streams: Add the ability to retry
a request on L7 failure.", 2.0-dev3), but do_l7_retry() never calls
flt_http_reset(). The only caller is the 1xx informational response
handling in http_wait_for_response(). Filters relying on this callback
to reset their context on L7 retries are never notified.

Fix the API documentation and the comment of the flt_ops structure
to describe the real behavior. Whether the callback should be called
on L7 retries is a separate question.

This may be backported to all stable versions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 doc/internals/api/filters.txt | 8 ++------
 include/haproxy/filters-t.h   | 5 ++---
 2 files changed, 4 insertions(+), 9 deletions(-)

diff --git a/doc/internals/api/filters.txt b/doc/internals/api/filters.txt
index d52f414cf..e5e3e3151 100644
--- a/doc/internals/api/filters.txt
+++ b/doc/internals/api/filters.txt
@@ -1545,13 +1545,9 @@ In addition, there are two others callbacks :
 Then, to finish, there are 2 informational callbacks :
 
   * 'flt_ops.http_reset' : This callback is called when an HTTP message is
-    reset. This happens either when a 1xx informational response is received, or
-    if we're retrying to send the request to the server after it failed. It
+    reset. This happens when a 1xx informational response is received. It
     could be useful to reset the filter context before receiving the true
-    response.
-    By checking s->txn.http->status, it is possible to know why this callback is
-    called. If it's a 1xx, we're called because of an informational
-    message. Otherwise, it is a L7 retry.
+    response. Note that it is not called on L7 retries.
 
   * 'flt_ops.http_reply' : This callback is called when, at any time, HAProxy
     decides to stop the processing on a HTTP message and to send an internal
diff --git a/include/haproxy/filters-t.h b/include/haproxy/filters-t.h
index 3115b2dbf..88a758edf 100644
--- a/include/haproxy/filters-t.h
+++ b/include/haproxy/filters-t.h
@@ -144,9 +144,8 @@ struct flt_kw_list {
  *                          it needs to wait for some reason, any other value
  *                          otherwise.
  *  - http_reset          : Called when the HTTP message is reset. It happens
- *                          either when a 100-continue response is received.
- *                          that can be detected if s->txn.http->status is 10X, or
- *                          if we're attempting a L7 retry.
+ *                          when a 1xx informational response is received. It
+ *                          is not called on L7 retries.
  *                          Returns nothing.
  *  - http_reply          : Called when, at any time, HAProxy decides to stop
  *                          the HTTP message's processing and to send a message
-- 
2.56.0

Reply via email to