Check that port_only returns the port for the valid forms of a Host header
value (hostname, IPv4, bracketed IPv6, with and without a port, with
leading zeroes, and the highest valid port) and that it returns 0 for the
invalid ones: an empty input, an input with no colon, an empty port, a
non-numeric port, a port with a non-numeric prefix or suffix, a negative
port, a port out of the 0..65535 range, and digit-only ports that used to
wrap around on 32 bits into a plausible value ("4294967739" gave 443).

The converter is also exercised on a Host header sent by the client, since
that is the input it is normally used on.

The test fails without the previous commit, where "example.com:http" gives
63544 instead of 0.

This should be backported with the fix (2.8 and above).
---
 reg-tests/converter/port_only.vtc | 103 ++++++++++++++++++++++++++++++
 1 file changed, 103 insertions(+)
 create mode 100644 reg-tests/converter/port_only.vtc

diff --git a/reg-tests/converter/port_only.vtc
b/reg-tests/converter/port_only.vtc
new file mode 100644
index 0000000..c0a40d5
--- /dev/null
+++ b/reg-tests/converter/port_only.vtc
@@ -0,0 +1,103 @@
+varnishtest "port_only converter Test"
+
+feature ignore_unknown_macro
+
+server s1 {
+ rxreq
+ txresp
+} -repeat 4 -start
+
+haproxy h1 -conf {
+    global
+    .if feature(THREAD)
+        thread-groups 1
+    .endif
+
+ 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}"
+
+ # the client-controlled path the converter is normally used on
+ http-request set-var(txn.hport) req.hdr(host),port_only
+
+ # valid ports
+ http-response set-header p-noport  %[str(example.com),port_only]
+ http-response set-header p-port    %[str(example.com:8080),port_only]
+ http-response set-header p-zeroes  %[str(example.com:080),port_only]
+ http-response set-header p-zeroes2 %[str(example.com:0000080),port_only]
+ http-response set-header p-max     %[str(example.com:65535),port_only]
+ http-response set-header p-ipv4    %[str(127.0.0.1:80),port_only]
+ http-response set-header p-ipv6    %[str([::1]),port_only]
+ http-response set-header p-ipv6p   %[str([::1]:80),port_only]
+
+ # invalid ports, all of them must give 0
+ http-response set-header p-empty   %[str(),port_only]
+ http-response set-header p-nocolon %[str(8080),port_only]
+ http-response set-header p-colon   %[str(example.com:),port_only]
+ http-response set-header p-alpha   %[str(example.com:http),port_only]
+ http-response set-header p-prefix  %[str(example.com:80abc),port_only]
+ http-response set-header p-suffix  %[str(example.com:abc80),port_only]
+ http-response set-header p-neg     %[str(example.com:-80),port_only]
+ http-response set-header p-range   %[str(example.com:65536),port_only]
+ http-response set-header p-big     %[str(example.com:70000),port_only]
+ # these used to wrap around on 32 bits into a plausible port
+ http-response set-header p-wrap443 %[str(example.com:4294967739
),port_only]
+ http-response set-header p-wrap80  %[str(example.com:4294967376
),port_only]
+ http-response set-header p-huge    %[str(example.com:99999999999999999999
),port_only]
+
+ http-response set-header p-hdr     %[var(txn.hport)]
+
+ default_backend be
+
+ backend be
+ server s1 ${s1_addr}:${s1_port}
+} -start
+
+client c1 -connect ${h1_fe_sock} {
+ txreq -url "/" -hdr "Host: example.com"
+ rxresp
+ expect resp.status == 200
+ expect resp.http.p-noport  == "0"
+ expect resp.http.p-port    == "8080"
+ expect resp.http.p-zeroes  == "80"
+ expect resp.http.p-zeroes2 == "80"
+ expect resp.http.p-max     == "65535"
+ expect resp.http.p-ipv4    == "80"
+ expect resp.http.p-ipv6    == "0"
+ expect resp.http.p-ipv6p   == "80"
+ expect resp.http.p-empty   == "0"
+ expect resp.http.p-nocolon == "0"
+ expect resp.http.p-colon   == "0"
+ expect resp.http.p-alpha   == "0"
+ expect resp.http.p-prefix  == "0"
+ expect resp.http.p-suffix  == "0"
+ expect resp.http.p-neg     == "0"
+ expect resp.http.p-range   == "0"
+ expect resp.http.p-big     == "0"
+ expect resp.http.p-wrap443 == "0"
+ expect resp.http.p-wrap80  == "0"
+ expect resp.http.p-huge    == "0"
+ expect resp.http.p-hdr     == "0"
+
+ # the same converter on a Host header sent by the client
+ txreq -url "/" -hdr "Host: example.com:8443"
+ rxresp
+ expect resp.status == 200
+ expect resp.http.p-hdr == "8443"
+
+ txreq -url "/" -hdr "Host: example.com:ZG"
+ rxresp
+ expect resp.status == 200
+ expect resp.http.p-hdr == "0"
+
+ # this one used to wrap around on 32 bits and return 443
+ txreq -url "/" -hdr "Host: example.com:4294967739"
+ rxresp
+ expect resp.status == 200
+ expect resp.http.p-hdr == "0"
+} -run
-- 
2.47.3

2026년 9월 10일 (목) 오후 8:56, Haerang Kim <[email protected]>님이 작성:

> The port_only converter looks for the last ':' (or ']') in its input and
> hands everything that follows to strl2ui(), which is documented as doing
> "no check at all". Every byte is accumulated as if it were a digit using
> "i = i * 10 - '0' + c" on an unsigned int, so a malformed port is not
> rejected but silently turned into an arbitrary number:
>
>     str(example.com:abc),port_only                   returns 5451
>     str(example.com:80abc),port_only                 returns 85451
>     str(example.com:-80),port_only                   returns 4294967076
>     str(example.com:99999999999999999999),port_only  returns 1661992959
>     str(example.com:4294967739),port_only            returns 443
>
> The input of this converter is normally the Host header, which is under
> the client's control and which the HTTP parsers accept with such a port,
> over HTTP/1 as well as HTTP/2. As a result the client picks the value the
> converter returns: "Host: example.com:ZG" makes it return 443, and so
> does "Host: example.com:4294967739" by wrapping around on 32 bits, which
> does not even require a non-digit character. A configuration matching on
> the port (for example "req.hdr(host),port_only -m int eq 443") can be
> fooled this way, and a port forwarded to the server (for example in an
> X-Forwarded-Port header) may carry a value the client never sent. Values
> above 65535 could be returned as well.
>
> Fix this by using http_get_host_port(), which already performs the lookup
> correctly by only accepting a ':' immediately followed by digits up to
> the end of the input, and by converting the result with strl2irc(), which
> rejects non-digits and overflows on its own, the way other ports are
> already parsed in src/http_fetch.c. As before, an input without a usable
> port makes the converter return 0, so an invalid port is not
> distinguishable from an absent one. The documentation is updated to say
> so.
>
> The host_only converter is left untouched on purpose: it keeps stripping
> everything after the last colon, so for an input such as
> "example.com:http" it still returns "example.com" while port_only now
> returns 0. Making the pair agree would change the result for valid inputs
> and is not needed to fix this bug.
>
> The converter was introduced in 2.7 by commit dd754cba1 ("MINOR: sample:
> add the host_only and port_only converters"), so this patch should be
> backported to all stable branches having it (2.8 and above).
>
> This depends on commit 3e666065e ("BUG/MINOR: http: fix an out-of-bounds
> read in http_get_host_port() on empty host"), without which an empty Host
> header makes the helper read one byte past the value. That commit is
> already present in 2.8.28, 3.0.27 and 3.2.23, but not in 3.1.17, where it
> must be backported first.
> ---
> Notes for reviewers, not part of the commit message:
>
> Testing:
>  - built and checked on master and on the 3.2.21 release tarball, which
>    this patch applies to with line offsets only;
>  - the reg-test added by the next patch passes on both patched builds and
>    fails on an unpatched 3.2.21, where "example.com:http" gives 63544;
>  - reg-tests/converter passes as a whole (19 passed, 6 skipped for the
>    SSL/Lua features my build lacks, 0 failed);
>  - valid ports are unchanged over HTTP/1 and HTTP/2, including leading
>    zeroes and 65535.
>
> Two more things I deliberately did not change, in case you would rather
> see them handled differently (the host_only case is in the commit message
> above):
>
>  - an invalid port and an absent port both give 0 and stay
>    indistinguishable. Making the converter fail instead would also change
>    the existing "no port" behaviour and break working configurations.
>
>  - a bracket-less IPv6 literal such as "::1" still gives 1. It is not a
>    valid Host header value, and the lookup involved is shared with h1.c
>    and http_htx.c.
>
>  doc/configuration.txt | 21 +++++++++++++++++++--
>  src/sample.c          | 25 +++++++++++++------------
>  2 files changed, 32 insertions(+), 14 deletions(-)
>
> diff --git a/doc/configuration.txt b/doc/configuration.txt
> index 8e3bc2d..46a70d4 100644
> --- a/doc/configuration.txt
> +++ b/doc/configuration.txt
> @@ -22846,9 +22846,26 @@ port_only
>    (rfc9110#section-7.2). It will support that kind of input: hostname,
>    hostname:80, 127.0.0.1, 127.0.0.1:80, [::1], [::1]:80.
>
> -  If no port were provided in the input, it will return 0.
> +  If no port were provided in the input, it will return 0. This is also
> the
> +  case when what follows the last colon is not a valid port, either
> because
> +  there is nothing after it ("example.com:"), because it is not only
> made of
> +  digits ("example.com:http"), or because it does not fit in the 0..65535
> +  range ("example.com:70000"). Leading zeroes are accepted. An explicit
> port
> +  0 returns 0 as well, so the returned value alone does not tell a missing
> +  port from an invalid one.
>
> -  See also: "host_only" converter which will return the host.
> +  Example :
> +      str(example.com:8080),port_only   # 8080
> +      str(example.com),port_only        # 0
> +      str([::1]:80),port_only           # 80
> +      str(example.com:080),port_only    # 80
> +      str(example.com:),port_only       # 0
> +      str(example.com:http),port_only   # 0
> +      str(example.com:70000),port_only  # 0
> +
> +  See also: "host_only" converter which will return the host. It does not
> +  perform the same validation: for "example.com:http" it returns
> +  "example.com" while "port_only" returns 0.
>
>  protobuf(<field_number>[,<field_type>])
>    This extracts the protocol buffers message field in raw mode of an
> input binary
> diff --git a/src/sample.c b/src/sample.c
> index 59c14b7..239e980 100644
> --- a/src/sample.c
> +++ b/src/sample.c
> @@ -4059,21 +4059,22 @@ static int sample_conv_host_only(const struct arg
> *arg_p, struct sample *smp, vo
>  static int sample_conv_port_only(const struct arg *arg_p, struct sample
> *smp, void *private)
>  {
>   /* Working cases: hostname00, hostname00:80, 127.0.0.1, 127.0.0.1:80,
> [::1], [::1]:80 */
> - char *beg = smp->data.u.str.area;
> - char *end = smp->data.u.str.area + smp->data.u.str.data - 1;
> - char *p;
> + struct ist port;
> + int port_val;
>
> - for (p = end; p >= beg; p--) {
> - if (*p == ':' || *p == ']')
> - break;
> - }
> + port = http_get_host_port(ist2(smp->data.u.str.area,
> smp->data.u.str.data));
>
>   smp->data.type = SMP_T_SINT;
> - if (p >= beg && *p == ':' && ++p <= end) {
> - smp->data.u.sint = strl2ui(p, smp->data.u.str.data +
> smp->data.u.str.area - p);
> - } else {
> - smp->data.u.sint = 0;
> - }
> + smp->data.u.sint = 0;
> +
> + /* Anything that is not a valid port leaves the result at 0, just like an
> + * input that has no port at all. strl2irc() rejects non-digits and
> + * overflows by itself, so the result does not depend on what
> + * http_get_host_port() lets through.
> + */
> + if (strl2irc(istptr(port), istlen(port), &port_val) == 0 &&
> +    port_val >= 0 && port_val <= 65535)
> + smp->data.u.sint = port_val;
>   return 1;
>  }
>
> --
> 2.47.3
>
>

Reply via email to