Martin Mirchev [Fri, 30 Oct 2026 05:32:30 +0000 (13:32 +0800)]
BUG/MINOR: tcpcheck: check the number of "hdr" in http-check send
parse_tcpcheck_send_http() collects the "hdr <name> <value>" arguments
of an "http-check send" rule into a stack array sized by
tune.http.maxhdr, followed by an IST_NULL terminator, but never checks
the number of headers against that size. When tune.http.maxhdr is
lowered and a rule sets as many headers as the limit, the terminator is
written past the end of the array, and each extra header writes one
more entry past it.
This happens while parsing the configuration. E.g. with:
global
tune.http.maxhdr 5
backend b
option httpchk
http-check send meth GET uri /health hdr X-H1 v1 hdr X-H2 v2 \
hdr X-H3 v3 hdr X-H4 v4 hdr X-H5 v5
server s1 127.0.0.1:1 check
"haproxy -c" crashes with a segfault on a release build, and ASan
reports a dynamic-stack-buffer-overflow WRITE in
parse_tcpcheck_send_http(). With the default value (101) it cannot be
reached because a config line is limited to 64 words.
Let's reject the rule with an error when there are too many headers,
keeping room for the terminator.
This should be backported to all stable versions. The code dates back to
2.2 and commit e5870d872b ("MAJOR: checks: Implement HTTP check using
tcp-check rules").
BUG/MEDIUM: spoe: stop waiting on a dead producer channel
spoe_process_event() interrupts a pending event processing when the frontend
connector reports an error or an early close: there is no point waiting for
an agent reply if the stream is being torn down from the client side.
The check was only made on the frontend connector. With an event bound to
the response channel (on-http-response), a server error reported while the
processing is pending leaves the SPOE context in SENDING_MSGS/ WAITING_ACK
with the processing timeout armed on res->analyse_exp, and nothing can wake
it up anymore.
Test the error on the producer of the channel the event is bound to: scb
in addition to scf for response-side events, the scf checks remaining
valid whatever the direction. For the response side, only SC_FL_ERROR is
relevant: an early server close with "option abortonclose" is a
request-side semantic which is already handled on the request path. The
context then concludes with the INTERRUPT error, which lets the response
analyser proceed and deliver whatever response was already received.
This is the SPOE part of github issue #3559: with a filter engine bound to
on-http-response, an H2 backend answering early and resetting its stream
while the request body is still being uploaded used to leave the stream
spinning in process_stream() until the watchdog killed the worker. The
send-spoe-group action is covered by the http-ana fix, as it is called from
the http-response rule engine.
BUG/MINOR: http-ana: also set FINAL on http-response actions on server error
http_res_get_intercept_rule() stops http-response actions from yielding once
the client side is dead: when the frontend connector reports an error or an
early close, the action is called with ACT_OPT_FINAL, so a yield is refused
and the caller receives an HTTP_RULE_RES_ERROR instead of being left waiting
forever.
The server side was missing: when the backend connector reports an error
while an http-response action (e.g. send-spoe-group) is yielding, the action
kept waiting for a wakeup that would only ever come from the server it just
lost, and the response was stuck in the channel until the client timed out.
Add the missing server-side condition: an SC_FL_ERROR on the backend
connector also sets ACT_OPT_FINAL, so http-response actions get a final
chance to conclude when the server dies. Combined with the previous commit,
the "server answered early and reset its stream while the action was
pending" case from github issue #3559 now lets the action conclude with an
error instead of hanging the response.
This patch should be backported to all supported versions.
BUG/MEDIUM: stream: reset analyse_exp when analysers are stripped on error
When the client or the server connector reports an error or a timeout that
no analyser handled, process_stream() strips all analysers from the
corresponding channel to let the error propagate, except the flt_end one.
When this happens while an analyser was yielding with the channel's
analyse_exp timer armed (e.g. an SPOE event or a Lua action waiting on an
external party), the stripped analysers can no longer manage nor reset this
timer. If it already expired, update_exp_and_leave() refuses to clear it as
long as the AN_*_FLT_END bit is kept for the two-channel filter
synchronisation, so t->expire is set to a past date, the task is requeued in
the past and process_stream() spins in a tight loop until the watchdog kills
the worker.
The request-side retry path (http_ana.c, http_req_retry) already resets
res->analyse_exp right after the same strip; do the same on both error
propagation paths of process_stream() so that an armed analyse timer can
never survive the analysers it was armed for.
This is the first commit of the series fixing github issue #3559. It must be
backported to all stable versions
Willy Tarreau [Thu, 5 Nov 2026 07:54:06 +0000 (08:54 +0100)]
MINOR: http/hardening: turn it to a macro and count matches with COUNT_IF()
Thanks to COUNT_IF() it is quite easy to count the number of times the
condition for a given hardening flag was met, and the number of times it
was enforced. Let's replace the function by a macro for this. In order
to avoid counting multiple times the same match (e.g. many CTL chars in
a header value), the macro takes care of checking both the found and the
rules flags. This way we have at least one found per enforcement, and at
least one per distinct match but not necessarily more if no enforcement
is configured.
Willy Tarreau [Thu, 5 Nov 2026 07:51:27 +0000 (08:51 +0100)]
BUG/MINOR: http/hardening: don't count unregistered hdr/tlr too early
In 3.5-dev11, the ability to remove unregistered headers and trailers
(X-*) was added with commits 4b9b6044b2 ("MINOR: http/hardening: add
"delete-unregistered-header-fields"") and b5fd464f0a ("MINOR:
http/hardening: add "delete-unregistered-trailer-fields""). There is
a small mistake there, which is the the http_harden() call is made
before checking if the situation was met, resulting in the flags being
set for each header/trailer. For now this doesn't have any impact but
it will when reporting is implemented. Let's fix that by only calling
http_harden() after the header syntax was checked. No backport is
needed.
BUG/MINOR: fd: allow fd_write_frag_line() to run before fdtab allocation
With traces enabled on the command line using "-dt ssl:developer:clean",
a trace event is emitted during the configuration check if an SSL bind
line uses "generate-certificates".
check_config_validity() calls ->prepare_bind_conf() for every bind line.
For SSL, this is ssl_sock_prepare_bind_conf(). When "generate-certificates"
is set, that function checks that a default certificate exists, and calls
ssl_sock_choose_sni_ctx() for this. This happens long before
init_pollers() is called, so fdtab is still NULL. fd_write_frag_line()
reads fdtab[fd].state to take the per-fd lock and to test FD_INITIALIZED,
and the process segfaults as soon as the trace is written to stderr.
fd_write_frag_line() has read fdtab since 931d8b79a (v2.1), but no trace
could be emitted that early at that time. The crash became possible with 105c1ca13 (v3.2-dev13), which added traces to ssl_sock_choose_sni_ctx();
ssl_sock_prepare_bind_conf() has called this function from the
configuration check since b80635a7e (v3.0).
At this stage the process is still single-threaded, so the per-fd lock is
not needed. Skip the fdtab accesses when fdtab is not allocated yet, and
call writev() directly.
Owais Lone [Tue, 3 Nov 2026 10:29:01 +0000 (10:29 +0000)]
REGTESTS: protobuf: cover the ungrpc message realignment
Regression test for 3a319e14c ("BUG/MINOR: sample: realign the ungrpc
converter on message boundaries").
grpc.vtc already walks a body of two gRPC messages, but the lookup
succeeds in its first message, so the cursor is left at the message end
and the bug does not show: that file passes both before and after the
fix. Reaching the defect needs a lookup that fails part-way through the
first message, which a length-delimited field whose wire type does not
match the requested one produces.
The body is 23 bytes and holds field 1 twice: length-delimited in the
first message, with a payload that reads as a gRPC header once the
cursor is misaligned, and as a varint 9999 in the second one.
ungrpc(1,int64) must return 9999. Before the fix it returned 1337, a
field of no message of the body.
Checked against 3a319e14c (passes) and its parent 1f114e4c4 (fails,
returning 1337).
CLEANUP: quic: remove the duplicate qc_prep_hdshk_fast_retrans() prototype
This function is declared in quic_retransmit.h, which is included both by
quic_tx.c and quic_rx.c, its only two callers. The second declaration in
quic_tx.h is therefore useless.
CLEANUP: quic: drop the intermediate packet tree pointer in the fast retransmissions
In qc_prep_fast_retrans() and qc_prep_hdshk_fast_retrans(), the local
<pkts> pointer is assigned once and then only used once, as argument of
eb64_first(). Dereference the packet number space tree directly instead,
which removes five lines without changing the behaviour.
CLEANUP: quic: remove the dead encryption level variable in qc_prep_hdshk_fast_retrans()
<qel> was assigned once to qc->iel and never modified afterwards, so
every "qel == iqel" test was always true. Use <iqel> directly for the
packet number space and for the pto_probe increment, test the
anti-amplification condition positively, and unwrap the requeue
condition.
The <start> label has had no goto target since this function stopped
looping over encryption levels, remove it.
CLEANUP: quic: use QUIC_PACKET_PN_MAXLEN instead of the magic 4
Both fast retransmission functions account for the packet number increase
when comparing the size of the packets to be retransmitted with the
anti-amplification limit. They use a plain 4 for this, while
quic_conn-t.h defines QUIC_PACKET_PN_MAXLEN for this exact purpose, as
already used in quic_tx.c for the header protection sample.
Replace the magic value in qc_prep_fast_retrans() and
qc_prep_hdshk_fast_retrans().
CLEANUP: quic: remove a redundant test in the fast retransmission lookups
qc_pkt_with_only_acked_frms() returns 1 for a packet without any frame,
since its loop is not entered and it falls back on the final return 1.
The LIST_ISEMPTY(&pkt->frms) test combined with it is therefore
redundant, both in qc_prep_fast_retrans() and in
qc_prep_hdshk_fast_retrans().
CLEANUP: quic: fix and complete the comments in quic_retransmit.c
Three comments are inaccurate:
- qc_prep_fast_retrans() is documented as taking a <qel> argument, but
it has been replaced by <pktns> for a while;
- the packet lookup loop of qc_prep_fast_retrans() is said to skip the
coalesced packets, while QUIC_FL_TX_PACKET_COALESCED is not tested
there at all. Only the Handshake lookup loop skips them;
- qc_pkt_with_only_acked_frms() is documented as returning 1 for a
packet made only of acknowledged frames, but it also returns 1 for a
packet without any frame. qc_prep_fast_retrans() relies on this to
skip the empty packets.
Three comment blocks are added where the intent is not obvious: the
<tmp> list used by qc_dup_pkt_frms() to prepend the duplicated frames
ahead of the pending ones, the <pkt> variable which is deliberately not
reset before the <start> label so that a second probe datagram is built
from the same frames when no other packet may be retransmitted, and the
Initial packet number space which may still be probed with a PING frame
when the anti-amplification limit allows to send a full-size datagram.
CLEANUP: quic: compute the STREAM frame end offset once in qc_stream_frm_is_acked()
The end offset of a STREAM frame, frm->offset + frm->len, was recomputed
four times in qc_stream_frm_is_acked(): once in the BUG_ON() and three
times in the offset comparisons. Store it in a local frm_end variable, as
already done for the FIN bit flag.
Willy Tarreau [Wed, 4 Nov 2026 22:22:17 +0000 (23:22 +0100)]
[RELEASE] Released version 3.5-dev11
Released version 3.5-dev11 with the following main changes :
- BUILD: sym: Fix build on recent glibc
- BUG/MINOR: checks/log: force implicit UDP checks as TCP for log backend
- CLEANUP: sym: factor out the short object name extraction
- MINOR: sym: use the symbol table to resolve a DSO name
- BUG/MINOR: errors: reset diagnostic buffers after thread cleanup
- BUG/MINOR: sample: fix NULL dereference in the ltime/utime converters
- BUILD: Makefile: extend the capabilities of EXTRA_MAKE
- MINOR: thread: add EXTRAS_LOCK so addons can name their locks
- BUG/MINOR: debug: fix "debug dev fd" crash on unknown address families
- BUILD: Makefile: allow external makefiles to provide build targets
- BUG/MEDIUM: ssl: skip TLS 1.3 suites in the FIPS TLS 1.2 cipher check
- REGTESTS: ssl: skip AWS-LC FIPS rejected crypto in ciphersuites/curves tests
- CI: github: add AWS-LC FIPS testing workflow
- BUILD: github: run FreeBSD CI on every branch
- CI: github: cache VTest binary in FreeBSD workflow
- BUG/MEDIUM: proxy: fix stick table expired entries cleanup on stopping
- BUG/MINOR: http-ana: fix response compression for applets
- DOC: configuration: Clarify wait-for-body doc for the response side
- MINOR: http-act: Add 'no-fail' option on 'wait-for-body' action
- BUG/MEDIUM: leastconn: reset the smallest key when backups take over
- CLEANUP: quic: remove duplicated key phase pointer assignments
- MINOR: quic: use the right trace event in quic_tls_key_update()
- CLEANUP: quic: remove duplicated rx offset initialization in quic_cstream_new()
- CLEANUP: quic: remove the unused tx part of struct quic_cstream
- CLEANUP: quic: remove unused tx crypto size and offset counters
- BUG/MINOR: quic: fix the header protection key length dump with AWS-LC
- CLEANUP: quic: remove the unused key parameter from quic_tls_decrypt()
- CLEANUP: quic: remove the unused count and flags from struct quic_tls_kp
- CLEANUP: quic: remove the stale initial_salt declaration
- CLEANUP: quic: remove duplicated quic_cstream_new() declaration
- CLEANUP: quic: remove the unneeded quic_rx.h include from quic_tls.c
- CLEANUP: quic: update the outdated comment of quic_tls_derive_keys()
- BUG/MINOR: sample: realign the ungrpc converter on message boundaries
- BUG/MEDIUM: compression: fix incompatibility between compression and large-buffers
- CLEANUP: tools: make inetaddr_host_lim*() usable on untrusted inputs
- BUG/MINOR: flt-http-comp: check the block returned by htx_replace_blk_value()
- BUG/MINOR: compression: pass the configured level to slz
- OPTIM: flt-http-comp: only flush the compressor after the last DATA block
- IMPORT: slz: update slz to version 1.4-dev4
- MINOR: compression: pass the content type to the algorithms' init function
- MINOR: flt-http-comp: size the compression reserve using SLZ_MAX_OVERHEAD()
- REGTESTS: compression: check hard to compress contents and nearly full buffers
- BUG/MEDIUM: mux-h1: don't drop pending output on a short content-length
- BUG/MINOR: filters: Reject id-less implicit instances of multi-instance classes
- BUG/MINOR: ssl/ckch: free the new instance when ckch_inst_rebuild() fails
- BUG/MEDIUM: ssl/cli: reset the ongoing transaction when "commit ssl cert" is interrupted
- BUG/MEDIUM: ssl/cli: don't unlock the certificate lock after a failed trylock
- BUG/MINOR: httpclient/lua: fix stack overread when copying the headers
- BUG/MINOR: httpclient/lua: raise an error on too many request headers
- BUG/MINOR: resolvers: validate TXT RDATA before reading
- MINOR: stream: add per-stream memory slots
- BUG/MINOR: haterm: fix the response body phase on short writes
- BUG/MEDIUM: haload: restore request accounting after user timeouts
- BUG/MEDIUM: ssl: disable kTLS on OpenSSL versions affected by a known bug
- DOC: config: add missing empty line before tune.ssl.ktls-unsafe
- BUG/MINOR: ist: make ist_find_range() accept non-zero min boundaries
- MINOR: ist: add ist_find_http_ctl() to match http CTL chars
- MINOR: ist: add ist_find_outside() to find offending characters
- MINOR: http: use ist_find_http_ctl() instead of ist_find_ctl()
- MINOR: h1: indicate the trailers state to h1_parse_msg_tlrs()
- MINOR: http: introduce the notion of http-hardening flags and sections
- MINOR: cfgparse/http: add a simple parser for new section "http-fingerprint"
- MINOR: http: support default hardening names "untrusted" and "trusted"
- MINOR: cfgparse: dump the "http-hardening" keywords with -dKcfg
- MINOR: cli/http: add the "show http-hardening" CLI command
- MINOR: listener: add the "http-hardening" bind keyword
- MINOR: server: add the "http-hardening" server keyword
- MINOR: tools: add is_ip_untrusted() to check for non-local addresses
- DOC: config: document the "http-hardening" section and server/bind options
- MINOR: http: define http_hdng_chk for ambient hardening checks to be run
- MINOR: mux-h1: pass the profile's HTTP hardening checks into the h1c and h1s
- MINOR: mux-h2: pass the profile's HTTP hardening checks into the h2c and h2s
- MINOR: h3: pass the HTTP hardening checks to the h3s and h3c
- MINOR: http: add the "set http-hardening" CLI command
- MINOR: http/hardening: implement hardening "reject-duplicate-host-header"
- MINOR: http/hardening: implement hardening "reject-lowercase-method"
- MINOR: http/hardening: implement hardening "reject-underscore-in-header-name"
- MINOR: http/hardening: implement hardening "reject-underscore-in-trailer-name"
- MINOR: http/harderning: implement "delete-underscore-in-header-name"
- MINOR: http/harderning: implement "delete-underscore-in-trailer-name"
- MINOR: http/hardening: add "delete-unregistered-header-fields"
- MINOR: http/hardening: add "delete-unregistered-trailer-fields"
- MINOR: http/hardening: add "delete-all-trailer-fields"
- MINOR: http/hardening: implement hardening "reject-header-trailer-folding"
- MINOR: h1: rearrange the header value parser to better deal with CTL chars
- MINOR: http: speed up http_header_has_forbidden_char() to make it work alone
- MINOR: http: don't call ist_find_http_ctl() before up http_header_has_forbidden_char()
- MEDIUM: http/hardening: implement hardening "reject-ctl-in-header-trailer"
- MINOR: http/hardening: implement hardening "reject-content-length-with-te"
- MINOR: http/hardening: implement hardening "reject-content-length-leading-zero"
- MINOR: http/hardening: add aliases "all-reject" and "all-delete"
- MEDIUM: http: enable a few important hardening rules on untrusted connections
- REGTESTS: http-messaging: test the http-hardening checks on HTTP/1 headers
- REGTESTS: http-messaging: test the http-hardening checks on HTTP/2 headers
- REGTESTS: http-messaging: test the http-hardening checks on trailers
- REGTESTS: http-messaging: test http-hardening profiles and their CLI
Willy Tarreau [Wed, 4 Nov 2026 17:11:10 +0000 (18:11 +0100)]
REGTESTS: http-messaging: test http-hardening profiles and their CLI
This is an LLM-generated reg-test covering hardening config and CLI.
This covers the declaration and selection of http-hardening profiles, using
a few checks that are easy to observe on HTTP/1 requests:
- the implicit "trusted" profile is the one used over the loopback when
no profile or "auto" is set on the bind line, and both implicit
profiles may be redefined;
- "from" inherits the settings of another profile, and keywords are
applied in order ("all on" followed by an exception);
- "all-reject" and "all-delete" only cover their own family;
- a server inherits its profile from default-server, and "auto" resets
it to "trusted";
- "show http-hardening" lists the profiles (implicit ones being marked
as such) and dumps one profile's options, and "set http-hardening"
changes an option by name or number, with immediate effect on both
bind and server lines, and its error messages;
- the configuration errors: undeclared or forward-referenced profile on
bind and server lines, reserved name "auto", duplicate profile, unknown
or self parent, unknown keyword with a suggestion, invalid value.
Since the "untrusted" profile now enables a few rejections by default, a
redefined "untrusted" profile starts with reject-lowercase-method, so the
test must expect a lower case method to be rejected there. This is now
checked explicitly, as well as the default contents of the implicit
"untrusted" and "trusted" profiles as reported by "show http-hardening",
and their inheritance by "from".
Willy Tarreau [Wed, 4 Nov 2026 17:09:18 +0000 (18:09 +0100)]
REGTESTS: http-messaging: test the http-hardening checks on trailers
This is an LLM-generated reg-test covering hardening checks on trailers.
These are tricky because we don't have anything beyond TCP mode with
content inspection to check them.
Each trailer-related check gets its own profile, applied to a bind line for
request trailers and to a server line for response trailers, with both
HTTP/1 and HTTP/2 on the receiving side. A profile enabling header checks
verifies that these ones do not affect trailers.
vtest cannot parse HTTP/1 trailers, so the request trailers are forwarded in
HTTP/1 to a TCP frontend of the same instance, which waits for the whole
chunked message, then switches to HTTP and replies with the list of fields
it received. Response trailers are delivered to HTTP/2 clients, and the
response body is waited for so that a rejected trailer results in a 502.
Covered: underscore in trailer name (reject and delete), unregistered
trailer fields, deletion of all trailers, folding, CTL and DEL in values.
Willy Tarreau [Wed, 4 Nov 2026 17:08:51 +0000 (18:08 +0100)]
REGTESTS: http-messaging: test the http-hardening checks on HTTP/2 headers
This is an LLM-generated reg-test covering HTTP/2 hardening checks.
This is the HTTP/2 counterpart of http_hardening_h1.vtc for the checks that
apply to HTTP/2 (folding and Transfer-Encoding cannot appear there). A
rejected request must cause a stream error, except for an invalid method
which results in a 400 like other method errors, and a rejected response a
502. It also checks that a Host header accompanying an identical :authority
is not considered as a duplicate Host header.
Willy Tarreau [Wed, 4 Nov 2026 17:07:55 +0000 (18:07 +0100)]
REGTESTS: http-messaging: test the http-hardening checks on HTTP/1 headers
This is an LLM-generated reg-test covering HTTP/1 hardening checks.
Each header-related check gets its own profile, applied to a bind line for
requests and to a server line for responses, and a profile with everything
disabled shows that each offending message is accepted by default. The
frontend replies itself with the method, the header names and the value
of a header it saw, so that deletions are observed after the H1 parser.
Covered: duplicate Host, lower case method, underscore in header name (reject,
delete, and reject having precedence over delete), unregistered header
fields, folding (after the colon, and after a value with SP or HT), CTL and
DEL in values (with HT, SP and obs-text still accepted, and CTL found past
the first words), Content-Length with Transfer-Encoding, and Content-Length
with leading zeroes. Rejected requests must return a 400 and rejected
responses a 502.
Willy Tarreau [Wed, 4 Nov 2026 17:17:48 +0000 (18:17 +0100)]
MEDIUM: http: enable a few important hardening rules on untrusted connections
For connections coming from non-local IP addresses, we now enable these
hardening rules by default, which should not cause any trouble to
existing applications and which are essentially used to build attacks
when seen in public:
This will cause error 400 or 502 depending on the side they are triggered.
In HTTP/1 an error will be captured. We still don't have a capture of what
matched but that's a start.
Willy Tarreau [Wed, 4 Nov 2026 15:21:34 +0000 (16:21 +0100)]
MINOR: http/hardening: add aliases "all-reject" and "all-delete"
These aliases group all reject-* and all delete-* respectively into one
alias, allowing to simplify general cleanups on certain boundaries, or
to fix issues in emergency.
Rejects requests and responses having a Content-Length header field
whose value contains a leading zero before a digit. Some client and
server applications tend to parse this as an octal value giving a
different body size (e.g. "0300" = 192). This can be used in attacks
trying to desynchronize a client or server from the rest of the
communication, and for example for cache poisionning attacks. These may
seldom be seen on legacy applications but not on standard compoennts
found on the internet, so it is generally safe to reject them on public
facing listeners.
Rejects requests and responses having both a Content-Length header
field and a Transfer-Encoding header field. While these are permitted
by HTTP/1.1 (Transfer-Encoding wins), it can cause problems when the
message first passed through an HTTP/1.0 gateway that does not
recognize Transfer-Encoding, and can desynchronize this gateway from
the rest of the communication. These are still seldom seen in legacy
client or server applications, sometimes in relation with compression
and/or caching. While HAProxy properly normalizes them, it is generally
safe to reject them on public facing listeners.
Rejects requests and responses which contain control characters in
their header or trailer field values (codes 0-8,10-31,127). These ones
are forbidden but have long experienced a certain tolerance among
implementations, which has encouraged some rare applications to use
them as record delimiters for example. No standard component produces
them on the public internet, except attacks. By default, HAProxy only
blocks the strictly required ones (0,10,13) and lets other ones pass,
which can make certain backend applications trigger connection errors.
As such, it is both safe and recommended to block them on public facing
listeners. See also the has_ctl() converter used for detecting them.
Willy Tarreau [Wed, 4 Nov 2026 10:59:00 +0000 (11:59 +0100)]
MINOR: http: don't call ist_find_http_ctl() before up http_header_has_forbidden_char()
Now that the function was sped up to skip non-offending bytes, we don't
need ist_find_ctl() anymore. And since it would block us from finding
certain chars, let's now get rid of it.
Willy Tarreau [Wed, 4 Nov 2026 09:58:53 +0000 (10:58 +0100)]
MINOR: http: speed up http_header_has_forbidden_char() to make it work alone
The function reads one char at a time and is systematically hidden behind
a first call to ist_find_http_ctl() to speed up the lookup of control chars.
Unfortunately, this construct doesn't permit to find other control chars
such as 0x7F. Better make http_header_has_forbidden_char() self-sustaining,
but for this we need to implement the speedup there. For this, we start
with a loop using is_char8_outside(0x20,0x7e) that allows to fly over most
non-offending bytes (SP, VCHAR). Only HT (9) and obs-text (0x80..0xFF) will
exit early, which is not a problem given that they are extremely rare in
header values. After that we continue the loop to search for offending
bytes starting from the block of 4 or 8 that triggered.
Willy Tarreau [Wed, 4 Nov 2026 07:52:44 +0000 (08:52 +0100)]
MINOR: h1: rearrange the header value parser to better deal with CTL chars
The header value parser only made a special case of NUL, CR and LF in
its core loop, which doesn't ease detection of other CTL chars. Let's
first slightly rearrange the loop to make the special case of chars below
0x20 first. The checks were only moved inside a new "if" block but nothing
was changed. Use diff -b to better see.
Rejects requests and responses which contain folded header fields or
trailer fields. Folding consists in having a single field span multiple
lines. This practice is obsoleted by modern HTTP standards, absolutely
not seen anymore in valid messages over the internet but might still be
present in a few antique internal services dealing with large cookies.
On untrusted networks, they are always associated with attacks trying
to inject headers into non-compliant components, and should be rejected
as a sign of break-in attempt. Note that HAProxy does not have problems
with them and systematically unfolds these messages before forwarding
them.
Deletes from requests and responses any trailer field, regardless of
its content or syntax. This is done after syntactic checks though. The
vast majority of public facing applications do not use trailers because
they are not reliable (intermediaries are allowed to drop or fold them)
and their presence on public networks is generally associated with
attack attempts. It is generally safe to drop them by default on public
facing listeners, and even on servers meant to deliver public contents,
in order to avoid any information leakage. Note that gRPC services use
them.
This is the same as for the headers, but this time for the trailers. Better
keep them separated as trailers are used even worse than headers these days.
Similarly as for the underscore, unwanted trailers are dropped early for
h1.
Deletes from requests and responses any header field whose name starts
with 'x-' (upper or lower case). These header fields are not registered
with IANA and are not used across the internet by standard components.
They may appear in untrusted requests in attacks trying to exploit some
backend framework weaknesses however, as well as in old browsers that
were trying to work around known server bugs (e.G. "X-Pad"). While it
would be risky to block such requests, it is safe and even recommended
to delete such headers from inbound internet requests. It is preferable
not to do it on responses however when applications uses such header
fields to report information to HAProxy.
This is the same as for the headers, but this time for the trailers. Better
keep them separated as trailers are used even worse than headers these days.
Note that in h1 we delete them when we detect them since we already have
the code for this, no need to index them and delete them later.
Deletes from requests and responses any header field whose name
contains an underscore character ('_' or \x5C). While this character is
perfectly permitted, some application frameworks which create automatic
variables already turn the dash ('-') into an underscore in variable
names, leading to an ambiguity with the underscore which can result in
the same variable name. This could be abused to bypass some
access-control policy for example. No standard HTTP header uses this,
so it is recommended to enable on untrusted listeners. Note that this
applies only after "reject-underscore-in-header-name" was accepted.
This is the same as for the headers, but this time for the trailers. Better
keep them separated as trailers are used even worse than headers these days.
Rejects requests and responses in which one header field name contains
an underscore character ('_' or \x5C). This character is perfectly
permitted, but some application frameworks which create automatic
variables tend to already turn the dash ('-') into an underscore in
variable names, leading to an ambiguity with the underscore which can
result in the same variable name. This could be abused to bypass some
access-control policy for example. No standard HTTP header uses this,
so it is safe to enable on untrusted listeners.
Rejects requests whose method contains a lower-case character. No
standard HTTP method uses lower-case characters and these ones are
essentially used in desynchronization attack scenarios, by trying to
exploit different case matching schemes along the chain. This is
recommended on untrusted listeners. This is implemented directly in the
h1 parser, as well as in http_method_has_forbidden_char() for h2 and
h3. H2 now uses this function instead of rolling its own loop.
Rejects requests containing more than one "Host" header field, even if
valid and identical. This is recommended on untrusted listeners. Note that
even if not rejected, duplicate host header fields are folded into a
single one. This was done for h1/h2/h3 which all had the explicit
check to perform deduplication.
In h2 and h3 we also had to make sure not to count host+authority as a
duplicate, so that requires a host counter as a local var, but that's
cheap.
Note that h3 currently doesn't check that duplicate headers are strictly
equal, only the first one is kept.
Willy Tarreau [Tue, 27 Oct 2026 16:43:44 +0000 (17:43 +0100)]
MINOR: http: add the "set http-hardening" CLI command
"set http-hardening <name> <option> {on|off}" enables or disables an option
of a profile at runtime, including the "all" keyword to change everything
at once, e.g. in an emergency. Options may also be specified by the bit
number that appears in -dKcfg or in "show http-hardening". The option is
looked up like in the section parser so that a suggestion is made on typos.
The profile is only read by the other threads, so it is updated while the
threads are isolated, which avoids any locking on the fast path. The
command is restricted to the admin level.
Willy Tarreau [Fri, 30 Oct 2026 10:29:45 +0000 (11:29 +0100)]
MINOR: h3: pass the HTTP hardening checks to the h3s and h3c
This adds an http_hdng_chk into h3s. In h3_attach() we preset
it to the h3c's profile's mask, which is found during h3c initialization
from the connection side's target profile (from server or bind_conf). If
no hardening profile is found ("auto" or not set), then we check the
source address of the connection. If it's a non-local IPv4/v6, then
we use the untrusted profile, otherwise we fall back to the trusted one.
Contrary to h2, h3s is allocated before parsing (like h1s), so we can
place all pre-initialization in h3_attach() without having to preset any
fields on h3s before parsing: in h3_rcv_buf(), we just have to preset
and collect http_hdng_chk, like for h1 again.
Willy Tarreau [Thu, 29 Oct 2026 16:14:35 +0000 (17:14 +0100)]
MINOR: mux-h2: pass the profile's HTTP hardening checks into the h2c and h2s
This adds an http_hardening profile pointer to h2c and an
http_hdng_chk into h2s. In h2_init() we preset the global hardening
profile depending on the side (note that the side might be reversed with
rhttp, but the pointer still points to a bind_conf or server so it remains
valid), and if no profile is found ("auto" or anything else) then we check
the source address to see if it's a non-local IPv4/v6, in which case we
use the untrusted profile, otherwise we fall back to the trusted one.
In h2s_new() we preset the h2s's hardening rules to the mask defined by
the profile pointed to by the h2c. This will be used as-is by the backend,
but in h2c_frt_handle_headers(), we have to preset the global hardening
profile to the h2c's mask before proceeding. If the stream is accepted
and created, we then copy what we collected to h2s for later consumption
(not implemented yet).
Willy Tarreau [Thu, 29 Oct 2026 14:25:50 +0000 (15:25 +0100)]
MINOR: mux-h1: pass the profile's HTTP hardening checks into the h1c and h1s
This adds the pointer to the chosen http_hardening profile to h1c, and
sets an http_hdng_chk on h1s. In h1c_init() we simply use the
connection's target to get the hardening profile. It is either a server,
or a listener, in which case we access its bind_conf's profile. If none
is found, we check if we have an untruted source address and set it to
untrusted, otherwise we fall back to trusted. Then we can preset the
http_hdng_chk rules in h1s from the mask of the profile pointed
to by h1c.
For now the returned information, if set, are not exploited.
Willy Tarreau [Thu, 29 Oct 2026 14:14:45 +0000 (15:14 +0100)]
MINOR: http: define http_hdng_chk for ambient hardening checks to be run
In order to enforce HTTP hardening checks, we have to instrument functions
at many different layers of the HTTP parsing code. First tests show that
we might need to add extra parameters to numerous functions, just to
forward requirements and results.
A simpler, less elegant but much more efficient approach consists sharing
the parsing checks globally (local to the thread) so that anywhere in the
HTTP stack it is possible to know what checks are enforced. It is not
different from the global acceptable violations after all, except that
this has to be preset, consumed and cleared by the caller. The benefit
is that it will then be possible to define the checks before parsing,
and let any function anywhere in the chain participate to the operation,
including for reporting possible anomalies.
The http_harden(flag) function does the repetitive part of the check and
returning what final action was performed and should be easy to deploy.
Willy Tarreau [Mon, 26 Oct 2026 18:18:19 +0000 (19:18 +0100)]
DOC: config: document the "http-hardening" section and server/bind options
Add section 12.10 describing the "http-hardening" section, the implicit
"untrusted" and "trusted" profiles and the "all" keyword, and document the
"http-hardening" option of "bind" and "server" lines. For now we do'nt
have other examples aside "all" in the section but the goal is that its
usage now starts with a few options.
Also take this opportunity for defining the meaning of "trusted" and
"untrusted" in this context.
Willy Tarreau [Tue, 3 Nov 2026 13:57:53 +0000 (14:57 +0100)]
MINOR: tools: add is_ip_untrusted() to check for non-local addresses
The function will return true for non-local ipv4/ipv6 addresses only.
Some are between the two (benchmark, doc, examples, cgn etc) and are
intentionally reported as non-local hence untrusted as well, the
rationale being that they're normally not deployed for production so
they shouldn't be taken for trusted local addresses. This will be
used in future commits.
Willy Tarreau [Mon, 26 Oct 2026 17:47:28 +0000 (18:47 +0100)]
MINOR: server: add the "http-hardening" server keyword
The new "http-hardening" server keyword sets the HTTP hardening profile
that applies to the responses coming from the server and to the requests
sent to it. The server now holds a pointer to the profile, which may be
NULL if unset or explicitly set to "auto". It is inherited from
default-server, server templates and "from" like any other setting, and
may be set at runtime with "add server". The profile must have been
declared before.
Willy Tarreau [Mon, 26 Oct 2026 17:39:23 +0000 (18:39 +0100)]
MINOR: listener: add the "http-hardening" bind keyword
The new "http-hardening" bind keyword sets the HTTP hardening profile that
applies to the connections accepted by the bind line. The bind_conf now
keeps a pointer to the profile set by the keyword, or NULL if not set or
if explicitly set to "auto".
Willy Tarreau [Mon, 26 Oct 2026 17:21:33 +0000 (18:21 +0100)]
MINOR: cli/http: add the "show http-hardening" CLI command
Without argument, "show http-hardening" lists all known profiles, including
the implicit "untrusted" and "trusted" ones when they were not declared (they
are then marked "(implicit)"). With a profile name, it shows each known
single-bit hardening option of this profile, along with its number (bit
position plus 1), direction among "req", "res", "-" (for bidir) and the
status ("on" or "off"), so that it is easy to know what is enabled.
Keywords covering a set of options such as "all" are not shown.
Willy Tarreau [Mon, 26 Oct 2026 17:08:40 +0000 (18:08 +0100)]
MINOR: cfgparse: dump the "http-hardening" keywords with -dKcfg
The keywords of the "http-hardening" section are not registered via the
cfg_kw_list mechanism but through their own table in http.c, so they were
missing from the output of "-dKcfg". Let's have cfg_dump_registered_keywords()
report them by calling dump_http_hardening_kws() once the other sections
were dumped. The function also reports the option numbers for pure options
(those which are not composite).
Willy Tarreau [Mon, 26 Oct 2026 19:41:09 +0000 (20:41 +0100)]
MINOR: http: support default hardening names "untrusted" and "trusted"
And also reserve "auto" for future use. Both "untrusted" and "trusted"
have a global storage and are available all the time. They're just not
registered in the tree by default so that we'll find them once missed
in the tree lookup. If a section with these names is created, it
inherits their settings. This will allow new default settings to
change over time, with minimal maintenance effort for the users.
Willy Tarreau [Mon, 26 Oct 2026 16:43:22 +0000 (17:43 +0100)]
MINOR: cfgparse/http: add a simple parser for new section "http-fingerprint"
Just like the resolvers parser, this is a whole section parser. It handles
both the http-fingerprint keyword that creates the section, and all
registered hardening options supported there. The format is:
A section may only be declared once, and the origin must already exist if
mentioned (like with defaults). Like with http-violations, keywords are
resolved using fuzzy matching so that an approaching name will suggest
the correct one.
For now, no keywords beyond "all" are declared, and nothing is done with
these sections.
Willy Tarreau [Mon, 26 Oct 2026 16:07:13 +0000 (17:07 +0100)]
MINOR: http: introduce the notion of http-hardening flags and sections
The goal here will be to declare http-hardening sections which enumerate
controls that can be relaxed or enforced. The control names will be stored
in a table just like the http-violations, associated with a mask. They
will possibly permit to have names for sets that cover multiple bits. For
now only 32 bits are planned, covering both front/back and req/resp. This
should be sufficient for a start and we'll see once the limit is reached
what's the best way to proceed.
One difficulty here is to reason in terms of what is not permitted.
So the way to think about it is to associate each verb to an action
to be performed, such as reject-xxx, delete-xxx, adjust-xxx, block-xxx.
Willy Tarreau [Tue, 3 Nov 2026 17:49:11 +0000 (18:49 +0100)]
MINOR: h1: indicate the trailers state to h1_parse_msg_tlrs()
In order to parse H1 trailers, h1_parse_msg_tlrs() builds a local dummy
h1m that sets H1_MF_HDRS_ONLY and calls h1_headers_to_hdr_list() which
directly jumps to plain headers parsing. However, this latter function
has no way to adjust its error processing on header or trailer because
the information regarding the fact that headers or trailers are being
parsed isn't present. Currently this is not a problem but it will become
in order to refine error processing to have separate rules for headers
and trailers.
This patch sets the dummy h1m's state to H1_MSG_TRAILERS so that the
called function can safely rely on it. All other callers pass a real
message with a valid state, so it will be possible to check h1m->state
during error processing to figure if the function is in the process of
parsing trailers or headers.
Willy Tarreau [Wed, 4 Nov 2026 16:37:11 +0000 (17:37 +0100)]
MINOR: http: use ist_find_http_ctl() instead of ist_find_ctl()
At a few places we were using ist_find_ctl() as an accelerator to locate
possibly forbidden CTL chars, but that one doesn't detect the DEL char,
so now that we have the new ist_find_http_ctl(), let's use it instead.
This is not tagged as a bug because nothing at this point is made with
this char so this doesn't make a difference at this point.
Willy Tarreau [Wed, 4 Nov 2026 10:28:25 +0000 (11:28 +0100)]
MINOR: ist: add ist_find_outside() to find offending characters
For some syntax checks it's interesting to find offending characters
that are outside an interval. Unfortunately for now we only limit
outselves to searching control chars < 0x20 but that's not always
sufficient. Let's just implement a fast scan for characters outside a
range (min and max may not be more than 0x7f apart though). Just like
ist_find_range(), it reads one word at a time and finishes per byte
when found or on false positives. It's optimized for non-matching
inputs.
Willy Tarreau [Wed, 4 Nov 2026 16:29:02 +0000 (17:29 +0100)]
MINOR: ist: add ist_find_http_ctl() to match http CTL chars
Some tests in the HTTP code involve ist_find_ctl() hoping to find any
HTTP CTL char, but CTL for ist is 0x00..0x1F only (and documented as
such so we won't change it). For HTTP, this also includes 0x7F which
these tests are missing.
It's not realistic to do a second full scan on those data for something
that never happens. Better introduce a new function which scans for
0x00..0x1F,0x7F, that's much more efficient. It's constructed exactly
like the existing one with a extra check for 0x7F by adding 1 to
generate a carry in this case as well. It's expected to cost roughly
2 extra cycles per word, which is OK.
Willy Tarreau [Wed, 4 Nov 2026 10:09:24 +0000 (11:09 +0100)]
BUG/MINOR: ist: make ist_find_range() accept non-zero min boundaries
The way ist_find_range() is currently constructed can make it miss the
lower bound. For example, checking "abcdefgh1jklmnop" against min='0'
and max='9' misses the '1' in the middle because the check only sees
bytes that are below (max-min), not those between min and max (the
min value is not subtracted before the comparison). It's OK in the
byte-at-a-time check however.
Let's just simplify the operation to make it work more like the
is_char8_below_opt() and is_char8_above_opt() functions. The idea
is to check (x-min) and (max-x) and combine them to check for the
carry. However there's still the case of testing that all bytes are
outside the interval (we don't want to miss one). The trick here
is to or the left term with the carry so that we keep the carry for
bytes outside and it only disappears when all bytes are ouside the
range. That way the loop stops when there's one carry, indicating
at least one byte within range, or one which had bit 7 previously
turned on (which was already mentioned as the exception), in which
case we finish one byte at a time. The only restriction is that
min <= max <= 0x7f, but that's already how the function was meant
to be used.
This can be backported, though it will not fix any bug because at
the moment, all callers pass 0 to the min value and are not affected.
BUG/MEDIUM: ssl: disable kTLS on OpenSSL versions affected by a known bug
With vanilla OpenSSL, a blocked kTLS record keeps referencing the caller's
buffer and the library retries from it, even though
SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER is set; the H2 fast-forward reuses that
buffer, so streams get corrupted. Upstream fixed this in 3.4.6 and 3.5.7, so
disable kTLS on older versions and report it, with "tune.ssl.ktls-unsafe" to
opt in anyway.
BUG/MEDIUM: haload: restore request accounting after user timeouts
The user path drops expired streams without releasing their active-stream
count or their tot_rconns slot, unlike the stream-error path. Once -r is
reached with fewer completions, running_tasks never falls back to zero and
haload hangs until it is killed.
Release both there, matching the error path; the timeout is still counted
as an error. This was introduced with haload in 3.5-dev, no backport needed.
BUG/MINOR: haterm: fix the response body phase on short writes
hstream_add_ff_data() computed the offset of the next body byte as
(req_size - len) % modulo, where <len> is the length offered by the mux
for that call. A short write or a partially filled output buffer makes it
smaller than the number of bytes still to send, so the offset no longer
matched what had already been emitted: the repeating pattern jumped and
the peer received a corrupted body, which happened as soon as a proxy read
the response with its own buffer boundaries. Use hs->to_write instead.
Introduced with ecf36f2ca ("MEDIUM: haterm: Add support for 0-copy data
forwading and option to disable it"), present only in the development
branch, so no backport is needed.
Add stream_reserve_memslot() to reserve pointer-aligned per-stream storage
during single-threaded initialization, before streams exist. Access the
reserved slots with stream_get_memslot().
Reserved storage is zeroed at stream creation, preserved for the stream's
lifetime and released on destruction.
maxml [Wed, 4 Nov 2026 10:16:46 +0000 (10:16 +0000)]
BUG/MINOR: resolvers: validate TXT RDATA before reading
maxml reported on the public HAProxy mailing list that the DNS response
validator reads the first TXT character-string length byte before checking
that the record contains any RDATA.
When a TXT answer has RDLENGTH zero and ends exactly at bufend, the generic
RDATA bounds check accepts the empty range. The TXT parser then reads
reader[0] with reader == bufend, before rejecting the record. Short-circuit
evaluation of the subsequent condition does not protect this earlier read.
The report includes a minimized 31-byte DNS response which triggers an
AddressSanitizer one-byte heap-buffer-overflow with an exactly sized
libFuzzer input. All 13 original AFL++ crash inputs reach the same read.
The production caller uses a DNS_MAX_MSG_SIZE + 1 stack buffer and parses
at most DNS_MAX_MSG_SIZE bytes, so this read remains within the allocation.
The loaded value is discarded when the zero-length check fails. No
production crash or information disclosure was demonstrated.
Check that RDATA contains at least one byte before reading its first
character-string length. A valid empty TXT string remains accepted: its
RDLENGTH is one and its character-string length byte is zero.
This was introduced by e418e828aa ("MINOR: resolvers: basic TXT record
implementation"). It should be backported to 3.4. Older stable branches
are not affected.
Martin Mirchev [Fri, 30 Oct 2026 05:49:13 +0000 (13:49 +0800)]
BUG/MINOR: httpclient/lua: raise an error on too many request headers
When a Lua httpclient request has more header values than
tune.http.maxhdr, hlua_httpclient_table_to_hdrs() pops the inner
lua_next() key and value and returns NULL. But the key and value of the
outer lua_next() loop on the headers table stay on the stack, so the
caller's lua_next() on the argument table gets the wrong key and the
request fails with a confusing error:
runtime error: invalid key to 'next' from [C]: in method 'get'
The header values already duplicated with istdup() are also leaked.
Let's free them and raise an explicit Lua error instead, as the caller
does for the other invalid arguments:
runtime error: too many headers (tune.http.maxhdr is 101)
The function is now marked __LJMP and its call wrapped in MAY_LJMP().
This should be backported where 2db801c63 ("BUG/MINOR: hlua: fix stack
overflow in httpclient headers conversion") was backported. Before 3.5
the function is in src/hlua.c.
Martin Mirchev [Fri, 30 Oct 2026 05:45:12 +0000 (13:45 +0800)]
BUG/MINOR: httpclient/lua: fix stack overread when copying the headers
hlua_httpclient_table_to_hdrs() collects the header values of a Lua
httpclient request into a stack array of tune.http.maxhdr entries, then
copies hdr_num + 1 entries into the allocated result, although the
array never contains a terminator: the result is allocated with calloc()
and its last entry is set to IST_NULL right after the copy.
Since commit 2db801c63 ("BUG/MINOR: hlua: fix stack overflow in
httpclient headers conversion") hdr_num may reach tune.http.maxhdr, and
the copy then reads one entry past the end of the array. E.g. with the
default value:
local v = {}
for i = 1, 101 do v[i] = "v" .. i end
hc:get{ url = "http://127.0.0.1:8080/", headers = { ["x-many"] = v } }
ASan reports a dynamic-stack-buffer-overflow READ of size 3264 (102
entries) in hlua_httpclient_table_to_hdrs(). With fewer values, the
extra entry is uninitialized stack, which is then overwritten.
Let's only copy the hdr_num collected entries.
This should be backported to all stable versions with the Lua
httpclient (2.5 and above), on top of 2db801c63 where it was
backported. Before 3.5 the function is in src/hlua.c.
BUG/MEDIUM: ssl/cli: don't unlock the certificate lock after a failed trylock
When ckch_store_load_payload() fails to acquire ckch_lock, it jumps to
the common cleanup path, which unconditionally releases the lock. This
unlocks another operation's lock and returns success because errcode is
still zero. When the actual owner subsequently unlocks, the lock state
underflows and later certificate commands remain blocked.
Return ERR_ALERT | ERR_FATAL immediately on failed lock acquisition. No
temporary resources have been allocated at this point, so cleanup is
unnecessary.
This regression was introduced in 3.4-dev11 by commit efe6c97488, which
extracted ckch_store_load_payload() from the CLI parser. This fix should
be backported to 3.4 only. Versions 3.3 and older return directly on
failed lock acquisition and are not affected.
Chris Staite [Mon, 2 Nov 2026 10:00:44 +0000 (10:00 +0000)]
BUG/MEDIUM: ssl/cli: reset the ongoing transaction when "commit ssl cert" is interrupted
"commit ssl cert" builds the new instances in several calls of its I/O
handler: it yields every 10 instances, and whenever the output buffer is
full. If the applet is released in between, for example because the CLI
client closed the connection, cli_release_commit_cert() frees the new
ckch_store of the transaction, but ckchs_transaction.new_ckchs,
ckchs_transaction.old_ckchs and ckchs_transaction.path still reference
it. The next "set ssl cert", "commit ssl cert", "abort ssl cert" or
"show ssl cert" on this certificate then uses the freed store, which may
crash the process or corrupt memory.
Only an admin of the CLI can trigger it, but it doesn't take much: a
certificate used by more than 10 instances (e.g. in a crt-list loaded by
several bind lines) and a client which doesn't wait for the end of the
commit are enough.
The release function is only called with a new store still attached when
the transaction is being abandoned, the success path having consumed it
in CERT_ST_INSERT, so the transaction is reset there. On the error path,
CERT_ST_FIN already reset it and doing it again is harmless.
"commit ssl ca-file" and "commit ssl crl-file" seem to follow the same
pattern in cli_release_commit_cafile() and cli_release_commit_crlfile(),
this will be addressed separately.
This should be backported to all stable branches. Before 3.4, the release
function is the same but frees ctx->new_ckchs itself instead of calling
ckch_store_update_cleanup().
Chris Staite [Mon, 2 Nov 2026 10:00:56 +0000 (10:00 +0000)]
BUG/MINOR: ssl/ckch: free the new instance when ckch_inst_rebuild() fails
ckch_inst_rebuild() creates the new ckch_inst of a "commit ssl cert" with
its sni_ctx and SSL_CTX, then prepares the SSL_CTX of the server or of
the bind line. When this preparation fails it returns an error to
ckch_store_rebuild_instances() which only returns -1, so the new instance,
which is not linked anywhere yet, is leaked with its SSL_CTXs, and with
everything they reference (the certificate, the chain, the key, the CA
file links).
The impact is a memory leak for each failed commit, which requires an
admin to commit a certificate that doesn't fit its bind or server lines.
Free the instance with ckch_inst_free() and reset the pointer on these
two error paths. The instance is not in any tree nor list at this point,
which ckch_inst_free() handles. The first error path, after
ckch_inst_new_load_store() or ckch_inst_new_load_srv_store(), is already
fine since these functions never leave an instance behind on failure.
This should be backported to all stable branches, the function exists
since 2.5.
BUG/MINOR: filters: Reject id-less implicit instances of multi-instance classes
An id is mandatory when a filter class allows several instances per proxy,
and this was verified when a filter-config directive was parsed. But it was
not when an implicit instance was created by flt_add_implicit_instance()
called from another directive: for such a class, an instance without id
could not be distinguished from a second one.
Like for an explicit declaration, the implicit declaration is now reported
and ignored if no id is provided for such a class. All the in-tree
directives creating implicit instances of a multi-instance class (the
cache-store rules) already pass the cache name as id, so nothing changes for
existing configurations.
The filters documentation was updated accordingly.
Olivier Houchard [Fri, 30 Oct 2026 15:04:33 +0000 (16:04 +0100)]
BUG/MEDIUM: mux-h1: don't drop pending output on a short content-length
When a fixed-length message is truncated and output data is still pending
in the H1 output buffer, h1s_destroy() flags the connection in error
because we got less data than expected, and so h1_send() will refuse to
send the remaining data, and not everything will be transmitted.
Now, when a truncated message is detected while output data is still
pending, H1C_F_ERROR is not set immediately, we just avoid adding it to
the idle pool, and the connection will be closed later when everything
has been sent.
To release the connection once the output buffer is drained, the
condition in h1_process() that only matched delayed silent shutdowns is
generalized to any connection in CLOSING state with an empty output
buffer. This covers both silent shutdowns and the short content-length
case. The connection is still not released while a stream remains
attached, since h1_close() keeps it in CLOSING until the stream is
detached.
Willy Tarreau [Sat, 31 Oct 2026 08:21:13 +0000 (09:21 +0100)]
REGTESTS: compression: check hard to compress contents and nearly full buffers
Add reg-tests/compression/output_margin_*.vtc, with a Lua service in
output_margin.lua, which sends bodies that are incompressible or barely
compressible (random bytes, bytes that cost 9 bits each with the fixed
tree, random data mixed with text, random data with short matches, runs
of 9-bit literals separated by 4-byte matches) or repetitive. The service
serves the cases of the set named in the request in order, so that the
vtest clients just loop over the same request. Each response must be
compressed with gzip, and is decompressed by the client, which checks the
gzip trailer's CRC and length, then compares the body length to the one
announced in x-len.
The tests are split into several files so that vtest runs them in
parallel, and tagged slow:
- output_margin_{whole,buf,small,odd}.vtc send all patterns through 4 kB
buffers, whole or by calls of 4000, 1 to 64 or 1 to 20000 bytes;
- output_margin_full_{random,run51}.vtc use 16 kB buffers and
tune.maxrewrite 32, so that the buffer may be almost full when the
filter compresses it, with bodies of 16088 to 16116 bytes sent in one
call. With the default maxrewrite, the channel always keeps far more
room than the deflater could ever need, which is why this does not
happen with default settings. These sizes still leave enough room for
the deflater, so they must be compressed as well (responses of 16144
bytes or more are not compressed at all with this setup).
Before the fixes, the last two crashed haproxy in comp_http_payload()
(htx_get_blk_type() on the NULL block returned by htx_replace_blk_value())
for all sizes from 16086 to 16118 bytes, which is the crash reported since
3.5-dev10. Each file takes about as long as a trivial test (0.25 to 0.3s
without ASAN here, 0.33 to 0.42s with ASAN).
Willy Tarreau [Sun, 1 Nov 2026 15:36:20 +0000 (16:36 +0100)]
MINOR: flt-http-comp: size the compression reserve using SLZ_MAX_OVERHEAD()
htx_compression_buffer_init() reserves room for the deflater's output based
on what it would emit as stored blocks. libslz now publishes in slz.h the
room each call to slz_encode() needs beyond its input length,
SLZ_MAX_OVERHEAD(len), which includes the stored blocks, the end of the
stream, the bits queued by previous calls and the up to 40 bytes its fixed
huffman encoding may lag behind stored blocks. Let's use it so that the
reserve follows slz's own settings instead of a local estimate.
All the DATA blocks of a pass are queued by the slz backend and compressed
in a single call when the last one is flushed, so the call covers at most
the whole buffer. The reserve is thus based on the buffer size, which keeps
it constant (52 bytes for buffers up to 64 kB), plus 14 bytes for the finish
(queue, last block and gzip trailer, a flush needs 10) and 10 bytes for the
gzip header: 76 bytes for 16 kB buffers.
The previous computation is kept for zlib, which publishes no such bound,
and when linking with an older external libslz which doesn't define the
macro. In all cases the result of htx_replace_blk_value() remains checked
since commit 5ab337485 ("BUG/MEDIUM: flt-http-comp: check the block
returned by htx_replace_blk_value()"). Crafted bodies made of runs of
literals >= 144 separated by short references, as well as random data,
sent in chunks of 256 bytes to 64 kB, all decompress correctly.
This is not a bug fix, only a way to better tie haproxy to slz's settings,
and it must not be backported.
Willy Tarreau [Sun, 1 Nov 2026 21:33:14 +0000 (22:33 +0100)]
MINOR: compression: pass the content type to the algorithms' init function
Now that we have slz 1.4-dev4 with support for text-aware compression
using an alternate huffman tree, let's implement support for enabling it.
In order to permit this, the algorithms' init() function now takes a new
argument, <ctype>, which is either COMP_CT_GENERIC (0) for unknown or
binary contents, or COMP_CT_TEXT (1) for text contents.
comp_prepare_compress_request() and select_compression_response_header()
set it to COMP_CT_TEXT when the Content-Type header starts with "text/",
regardless of the "compression type" lists. The config-time checks of the
algorithms use COMP_CT_GENERIC.
The slz algorithms select SLZ_CT_TEXT after their init for text contents.
libslz has no dedicated macro for the content types, but they were
introduced right after SLZ_MAX_OVERHEAD(), which is thus used to detect an
external libslz that supports them. zlib and identity ignore the argument.
On an 81 kB HTML file, the response is 22.0 kB when served as text/html
instead of 25.4-27.1 kB when served as application/octet-stream at level 1
(the size depends on how the server sends the data).
Willy Tarreau [Tue, 3 Nov 2026 08:14:08 +0000 (09:14 +0100)]
IMPORT: slz: update slz to version 1.4-dev4
This refreshes the slz library to 1.4-dev4, fixing a crash bug and
bringing a small number of improvements:
- faster (vectorizable) adler32 checksum calculation for zlib
("deflate" token in accept-encoding)
- small performance improvements on compression (size and speed)
- support for a special compression scheme for text (~-15...-20%)
Willy Tarreau [Sun, 1 Nov 2026 13:39:14 +0000 (14:39 +0100)]
OPTIM: flt-http-comp: only flush the compressor after the last DATA block
comp_http_payload() used to call the compressor's flush() after each DATA
block, which emits a synchronization point (an EOB and an empty stored
block, up to 5 bytes) and closes the current huffman block each time.
Worse, with slz, and before recent commit a0b37fb470 ("BUG/MEDIUM:
compression: fix incompatibility between compression and large-buffers"),
add_data() only queued the data and flush() is where they were compressed,
so each block was compressed on its own, without being able to reference
the previous ones, since slz keeps no context between calls.
Now flush() is only called when no other DATA block follows in this
pass, so that the client still gets everything while we're waiting for
more data. With zlib, which compresses in add_data(), the last bits are
simply kept for the next block. With slz, all DATA blocks of the pass
are queued (the first one by reference, the following ones copied into
a contiguous buffer, as add_data() already does) and compressed at once
with their whole context. The first block's data are still referenced
after the block is removed or emptied, which is fine since this does not
touch the data area, and they are copied when the second block is added.
The difference is only visible when several DATA blocks are processed
in the same pass, e.g. a 1 MB HTML response made of 256-byte chunks
shrinks from 405057 to 402030 bytes (-0.7%) at level 1.
Willy Tarreau [Sun, 1 Nov 2026 20:23:40 +0000 (21:23 +0100)]
BUG/MINOR: compression: pass the configured level to slz
Since its integration in 1.6 with commit 418b8c0c4 ("MAJOR: compression:
integrate support for libslz"), the slz backend only knew levels 0 and 1,
so the init functions forced the level to 0 or 1 using !!level, and the
rate limiting code only raised the level back up to 1. Since slz 1.4-dev3
was imported in commit 32582812a ("IMPORT: slz: update slz to version
1.4-dev3"), slz supports levels 1 to 3 (higher ones behave as 3), but
tune.comp.maxlevel was still capped to 1.
Let's pass the configured level to slz's init functions, and let the rate
limiting code raise the level back up to tune.comp.maxlevel.
Note that configurations with tune.comp.maxlevel above 1 will now use the
higher slz levels, which compress better but consume more CPU.
This must be backported wherever commit 32582812a is backported.
Willy Tarreau [Sat, 31 Oct 2026 07:57:19 +0000 (08:57 +0100)]
BUG/MINOR: flt-http-comp: check the block returned by htx_replace_blk_value()
comp_http_payload() replaces the value of a DATA block with the deflater's
output and directly passes the result to htx_get_next_blk(), without checking
it. htx_replace_blk_value() returns NULL when htx_prepare_blk_expansion()
finds not enough room for the new value (delta > htx_free_space()). Normally
this cannot happen, but it seldom does during development or improvements
on the compression libraries, and causes crashes that could just be turned
into errors. The decompression code already performs the test, so let's do
the same and stay safe in front of such occasional early bugs. It's not
necessary to backport this since it doesn't trigger with stable code.
Willy Tarreau [Tue, 3 Nov 2026 07:49:52 +0000 (08:49 +0100)]
CLEANUP: tools: make inetaddr_host_lim*() usable on untrusted inputs
inetaddr_host_lim() and inetaddr_host_lim_ret() were introduced in 1.4.4
by commit d54bbdce87 ("[MINOR] add very fast IP parsing functions") as
fast parsers for some specific use cases. They expect a delimiter after
the IP address (cr/lf/nul/whatever, in fact, anything not a digit), and
this is how they're currently used. As noted by Md. Raisul Islam Mobin,
they could also be misused by external code not aware of this limitation
which by then saved a few CPU cycles per address at an era where they
were still precious, so let's test the end pointer before reading the
digit to make it more future-proof. If this speed still matters, these
ones could probably be rewritten to benefit from automatic vectorization
on SIMD-capable machines.
Willy Tarreau [Mon, 2 Nov 2026 17:42:14 +0000 (18:42 +0100)]
BUG/MEDIUM: compression: fix incompatibility between compression and large-buffers
The SLZ compression being stateless, it doesn't keep data between calls
and must be called with the largest blocks at once to be efficient. When
it was added, muxes were not there yet and chunks were parsed inside the
HTTP forwarding analyzer, which would call comp->add_data() for each chunk
of data. In case of small chunks, that would just not compress, so a local
buffer was added and data were temporarily stored there and effectively
compressed when calling ->flush().
But with the introduction of large buffers, it became a problem because
the locally allocated buffer can be too small if the data to be compressed
comes from a large one. One solution to this is to also pre-allocate a
large buffer, but since there needs to be one per thread and that some
users might use extremely large buffers on large systems, that could be
extremely expensive in terms of memory.
A better solution is to just simplify all of this as this complexity is
no longer needed at the era of muxes which extract chunks and make a
large HTX data block with them. That's what this patch does, and the
functions become as simple as their zlib counterparts, no longer require
a local per-thread buffer, and will be even faster thanks to the removal
of a long-unneeded extra copy.
This fix will need to be backported to 3.4 where large buffers were
introduced, maybe after a period of observation. An alternate fix in
the mean time could be to reject configs having both compression and
large buffers.
BUG/MINOR: sample: realign the ungrpc converter on message boundaries
sample_conv_ungrpc() walks the GRPC messages of the body with two
variables: <pos>, the read cursor, and <grpc_left>, the bytes left.
They are supposed to describe the same position.
When protobuf_field_lookup() fails, it does not always leave <pos> at the
end of the message. For a length-delimited field it stops on the value,
after the key and the length. But the loop still subtracts the whole
message length from <grpc_left>, without moving <pos>. The cursor and the
counter then describe different parts of the input, and the next
iteration reads a "gRPC header" from the previous message's payload.
ungrpc(1,int64) returns 1337, which is the value of the first message's
field. The second message holds the real field 1 = 9999 but is never
reached. The counter can also over-count, and skip real messages.
Save the end of the message rigght after the "grpc_left < left"
check, then set <pos> to it when the lookup fails, so the next iteration
starts on a real message boundary.
Nothing changes on the success path, nor for fully parseable bodies,
where the lookup already stops at the message end. The multi-message walk
is intended for streaming bodies and is kept.
This is not a memory-safety issue: <pos> and the counter advance by the
header together, and the lookup moves forward by at most the message
length, which is the same amount removed from the counter.
This is also not a policy bypass like a4f5eea841 ("BUG/MEDIUM: protobuf:
fix nested path bypass in field lookup"). There, a flat sibling field
could satisfy a nested path that the configuration explicitly
restricted, so the client gained a capability it did not have. Here the
client writes the whole body and can already obtain any value with a
single well-formed message. The bug only makes the proxy read a value
that is the field of no message of the body, while the backend reads
another one. Hence MINOR and not MEDIUM.
The loop was written this way when the converter was added in 2.0
(1fceee8316) and did not change since 5f33f85ce8, apart from the
unaligned access fix. Not a regression. Backport to 2.0 and above.
Thank you to Owais Lone for having reported this bug and provided this
patch.
CLEANUP: quic: update the outdated comment of quic_tls_derive_keys()
The comment still refers to a <ctx> parameter which no longer exists and
claims that the derived key, IV and header protection key have the same
size, which the code below contradicts. Describe the actual derivation
from RFC 9001 5.1 instead.
CLEANUP: quic: remove the stale initial_salt declaration
No symbol named initial_salt exists anymore: the Initial salts are the
per-version initial_salt_draft_29, initial_salt_v1 and initial_salt_v2,
reached through the initial_salt member of struct quic_version. Let's
remove the leftover declaration.
CLEANUP: quic: remove the unused count and flags from struct quic_tls_kp
The count and flags members of struct quic_tls_kp are only initialized by
quic_tls_kp_init() and read nowhere: the key phase state is kept in the pn
member of the TLS secrets and in the flags member of the TLS context.
Let's remove them.
CLEANUP: quic: remove the unused key parameter from quic_tls_decrypt()
The key parameter of quic_tls_decrypt() and quic_tls_decrypt2() is never
read: the key is installed in the cipher context by
quic_tls_rx_ctx_init(), only the IV differs per packet. Dead since the
cipher contexts became reusable (commit f4605748f).
BUG/MINOR: quic: fix the header protection key length dump with AWS-LC
Under QUIC_AEAD_API, tls_hp() returns EVP_CIPHER_CHACHA20, which is an
EVP_AEAD cast to EVP_CIPHER. Applied to it, EVP_CIPHER_key_length() reads
an unrelated field and returns 1 instead of 32, so
quic_tls_keys_hexdump() only dumps one byte of the header protection key.
quic_tls_derive_keys() has the same computation but is guarded by
#ifdef QUIC_AEAD_API. Move that special case to a helper in quic_tls.h
and use it for both.
This only affects the trace output. Measured on AWS-LC 5.9.0: 1 instead
of 32 for ChaCha20, AES-128 and AES-256 unaffected.
Should be backported to all stable versions even if only the QUIC traces
are impacted.
CLEANUP: quic: remove unused tx crypto size and offset counters
The sz and offset members of the tx crypto substructure of
quic_enc_level are written but never read: the buffered CRYPTO data are
located with the bufs array and the sz member of each quic_crypto_buf.
Let's remove them.
CLEANUP: quic: remove the unused tx part of struct quic_cstream
struct quic_cstream carries a tx substructure with offset, sent_offset
and buf members, inherited from the qcs object it was derived from to
handle CRYPTO streams. Nothing ever reads them: the TX side of the
CRYPTO stream is handled by the tx crypto buffers of its encryption
level for the data, and by the stream descriptor attached to the
cstream. Only quic_cstream_new() sets the three members and no reader
exists anywhere in src/ or include/.
They were already unread when they were introduced (commit 7e3f7c47e
"MINOR: quic: New quic_cstream object implementation"), so let's remove
them along with their initialization.
MINOR: quic: use the right trace event in quic_tls_key_update()
The next RX/TX secret lengths are reported with QUIC_EV_CONN_SPPKTS,
whose handler expects a <struct quic_tx_packet *>, while the rest of the
function uses QUIC_EV_CONN_KP event. TRACE_PRINTF() bypasses the source
callback through <&trace_no_cb>, so nothing crashed: the message was
just filtered under the wrong event.
<nxt_rx> and <nxt_tx> are already initialized from <qc->ku> at
declaration time in <quic_tls_key_update()>, and <qc->ku> is not
modified in between, so the two assignments placed after TRACE_ENTER()
are dead duplicates.
BUG/MEDIUM: leastconn: reset the smallest key when backups take over
Since commit 9fe72bba3 ("MAJOR: leastconn; Revamp the way servers are
ordered."), the lookup starts at the smallest key recorded in
lbprm.lb_seq, and this key is shared by the active and the backup
trees. Under load it follows the keys of the active servers.
When the last active server goes down or into maintenance, the key is
only reset if it equals the key of that server, which is often not the
case under load. With "option allbackups" the backup tree is then
looked up from that key, and the idle backup servers, whose key is 0,
are not found. fwlc_get_next_server() returns NULL, the streams are
queued in the backend, and without maxconn nothing dequeues them: every
request ends with a 503 after "timeout queue", although a backup server
is up. It only recovers when a server is queued at a lower key again.
Without "option allbackups" the first backup server is used directly
and the problem does not happen.
Fix it by resetting the smallest key when the last active server leaves
the tree.
This fixes GitHub issue #3555. It can be reproduced with an active and
a backup server, "balance leastconn" and "option allbackups", by
setting the active server to maintenance under load.
This should be backported as far as 3.2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Aleksandar Lazic <al-haproxy@none.at>
MINOR: http-act: Add 'no-fail' option on 'wait-for-body' action
With this option, the "wait-for-body" action does not return an error at the
end of the timeout but continues the message analysis with the body received
so far, instead of responding with a 408 "Request Timeout" error to the
client on the request side or a 504 "Gateway Timeout" error on the response
side. The remaining body bytes, if any, are streamed to the server or to the
client once received.
DOC: configuration: Clarify wait-for-body doc for the response side
The documentation was too request-oriented and did not mention the 504
"Gateway Timeout" error returned to the client when the timeout occurs while
waiting for a response body. It is now side-neutral and explicitly mentions
the 408 and the 504 errors returned on timeout, depending on the side.
Willy Tarreau [Mon, 2 Nov 2026 16:01:34 +0000 (17:01 +0100)]
BUG/MINOR: http-ana: fix response compression for applets
In 3.5-dev6, commit 1a99a7c0f4 ("MINOR: flt-http-comp: Check request
headers for response compression earlier") was needed to make sure
that processing of the accept-encoding request header happened in the
suitable order when both compression offload and decompression are
used at the same time. While theoretically it's no longer needed now
with the updated filter sequences merged into -dev9, it still uncovered
an interesting vestigal construct that we could carbon-14 date to 2014
in 1.5-dev24, precisely with this commit: 70730dddbd ("MEDIUM: http:
enable analysers to have keep-alive on stats").
By then, applets were not yet autonomous objects outside of the stream,
but were entirely part of the stream and were not much compatible with
analyzer processing, with a few exceptions, and the stream (by then
called a session) was reset to a pre-parsing state after each response
to process the next one, resulting in great difficulties explaining
why only select analyzers were kept and the other ones dropped.
In 3.5 all of this is old history and it's a bit surprising and even
refreshing to find living witnesses of this antique era, having managed
to both adapt to and resist natural evolution.
What happened with the 3.5-dev6 patch above is that function
select_compression_request_header() is no longer called by the
compression code during requests, and is expected to be called by the
rest of the natural request processing flow. But the antique code that
only keeps a subset of analyzers didn't care about ANA_REQ_HTTP_INNER
by then since applets didn't need that, and since it's the only place
which can still make sure that this function is called, the result
is that the accept-encoding header is no longer inspected for applets,
because either it was a frontend and the flag was already missing, or
it was a backend and that flag which was set for the backend was reset
by that code. Because of this, compression of applet responses (stats,
cache, promex, Lua services) is silently disabled since 3.5-dev6.
Upon careful inspecting, it turns out that all flags that were added
by the antique code (AN_REQ_FLT_HTTP_HDRS, AN_REQ_HTTP_XFER_BODY etc)
are all properly set by http_process_common() which is called on
AN_REQ_HTTP_INNER. Thus the fix consists in removing all that old
code to stop purging flags, and only enabling AN_REQ_HTTP_INNER when
applets are branched from frontends, so that the flag is always set,
and that the rest of the processing is properly performed in both
cases.
With this patch, sending curl --compressed to a config with only
a frontend or only a backend containing "stats uri /" properly
returns compressed contents again.
No backport is needed since it only affects 3.5-dev6 (3.4 and older
still work fine thanks to the code that the patch above dropped).
Incidentally, regtest legacy-implicit-order.vtc needed to be fixed
because it was written after the change above, when the cache's
output wasn't compressed by the existing rules. Now with the fix,
the cache's output gets compressed again, so adding a separate
proxy for this allows to distinguish the request processing.
BUG/MEDIUM: proxy: fix stick table expired entries cleanup on stopping
Since the following patch, proxy internal management task is only
created for frontends or if a stick-table is present. This notably
serves to purge expired entries from tables on soft-stop.
However, the condition was incorrectly written, as it is never true for
the stick-table case. This is because table's <current> member is always
zero at parse time as the stick-table is still empty. Thus, the task is
not allocated for backends even if stick-table is declared. This results
in worker processes potentially leaving expired entries in stick-tables.
The error originates from reusing the condition from manage_proxy(),
which performs the stick-table cleanup. In this context it makes sense
to check <current> value.
Fix the condition to ensure the task is allocated when a stick-table is
declared. Checking the proxy's <table> member is sufficient for that.
CI: github: cache VTest binary in FreeBSD workflow
The FreeBSD job rebuilds VTest2 from source on every run. The shared
setup-vtest action cannot be used directly because it runs on the Ubuntu
host and would produce a Linux binary rather than a FreeBSD one.
Restore and save the workspace-relative vtest directory on the host with
actions/cache. Build into that directory inside the guest and explicitly
enable copyback so the resulting FreeBSD binary is available to the save
step after the VM exits.
Use the same release and architecture variables for the VM and cache key.
Keep the FreeBSD namespace separate from other jobs, and include the
upstream VTest SHA, build-script hash and a manual version suffix to
invalidate incompatible entries. Skip both cache steps if the SHA lookup
returns no output.
Probe the cached binary with -V and rebuild if it is missing or unusable.
Print the selected version and pass its absolute path to the full
regression suite. Save only after the VM step succeeds and the restore
was not an exact hit; retain the existing build and failure diagnostics.
The FreeBSD workflow was the only workflow in .github/workflows to
restrict itself to the master and next branches. Pull request branches,
merged topic branches and tags were therefore never built or tested on
FreeBSD, unlike macOS and Alpine, which are part of the push-triggered
VTest matrix. As a result changes touching platform specific code could
be merged without any FreeBSD validation.
Drop the branches filter so the workflow triggers on every push, as the
VTest, Windows and Contrib workflows already do. The existing job
condition still limits the run to the haproxy repository, or to a manual
workflow_dispatch, so forks are unaffected.
The FIPS branches of AWS-LC were covered by the VTest matrix, but only
built in non-FIPS mode: nothing exercised the FIPS-specific code paths in
HAProxy, in particular the compliance checks in src/fips.c which reject
non-approved ciphers, curves, signature algorithms and TLS versions at
configuration time. A regression there is invisible until someone builds
HAProxy against an AWS-LC FIPS library.
Add a nightly workflow that builds AWS-LC with FIPS=1 and runs the VTest
and unit test suites against it, in the same way the other SSL library
workflows do. It tests three sources, resolved through
determine_aws_lc():
- AWS-LC 3.x, the fips-2024-09-27 branch;
- AWS-LC 4.x, the fips-2025-09-12-lts branch;
- the latest AWS-LC release tag, whose FIPS module is the one currently
submitted for validation, so that the newest code is covered too
instead of only the two maintenance branches.
Branch specs resolve to the head commit of the branch, which is passed as
AWS_LC_VERSION and downloaded as an immutable archive by build-ssl.sh, so
a run is reproducible and the build cache is only renewed when the branch
advances. The resolved release and commit are both part of the cache
key, matching the version-specific identity used by the VTest job so the
two workflows never share a half-populated cache entry.
The workflow only runs on the upstream repository, or on manual dispatch
from a fork, like the other SSL library workflows.
Note that the AWS-LC latest release is not expected to be identical to
either FIPS branch; it is built in FIPS mode for its own FIPS module
version, which is what the extra job is meant to detect early.