You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by ma...@apache.org on 2026/09/16 22:18:18 UTC

(trafficserver) branch master updated: Fix strncasecmp checks that accepted a prefix as equal (#13697)

This is an automated email from the ASF dual-hosted git repository.

masaori335 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 13ef6a7618 Fix strncasecmp checks that accepted a prefix as equal (#13697)
13ef6a7618 is described below

commit 13ef6a7618bda4c36835aab291de95d02e86f2ed
Author: Masaori Koshiba <ma...@apache.org>
AuthorDate: Thu Sep 17 07:18:11 2026 +0900

    Fix strncasecmp checks that accepted a prefix as equal (#13697)
    
    * Fix strncasecmp checks that accepted a prefix as equal
    
    strncasecmp(a, b, n) is an equality test only when the lengths are
    compared first. Without that, any input that is a prefix of the
    expected literal matches: `Vary: Accept` suppressed the
    `Vary: Accept-Encoding` compress adds, `escape: jsonX` loaded as JSON,
    and isTrue("1abc") was true. Replace these with ts::iequals, which
    short-circuits on a length mismatch.
    
    Prefix and suffix matches keep using strncasecmp. StringCompare.h joins
    the installed tsutil headers because plugins now include it.
    
    * Address Copilot's comment
    
    * Add an autest for compress appending to an existing Vary header
    
    The existing compress tests only ever see an origin `Vary: Accept-Encoding`,
    so they still pass if the prefix-match fix in vary_header() is reverted.
    Verified: this test fails with the old strncasecmp, reporting
    `vary` as "Accept" instead of "Accept, Accept-Encoding".
---
 plugins/cache_promote/configs.cc                   |   5 +-
 plugins/cachekey/configs.cc                        |   3 +-
 plugins/compress/compress.cc                       |   3 +-
 plugins/escalate/escalate.cc                       |   7 +-
 plugins/experimental/access_control/config.cc      |   3 +-
 plugins/experimental/cache_fill/configs.h          |   3 +-
 .../custom_redirect/custom_redirect.cc             |   5 +-
 plugins/lua/ts_lua.cc                              |   3 +-
 plugins/prefetch/configs.cc                        |  23 ++--
 plugins/regex_revalidate/regex_revalidate.cc       |   9 +-
 plugins/stats_over_http/stats_over_http.cc         |  19 ++--
 src/iocore/cache/HttpTransactCache.cc              |   5 +-
 src/proxy/ControlBase.cc                           |   6 +-
 src/proxy/logging/YamlLogConfig.cc                 |   5 +-
 src/proxy/logging/YamlLogConfigDecoders.cc         |   6 +-
 src/tsutil/CMakeLists.txt                          |   2 +
 src/tsutil/unit_tests/test_StringCompare.cc        |  71 +++++++++++++
 .../compress/compress-vary-accept.config           |   4 +
 .../compress/compress-vary-accept.test.py          |  27 +++++
 .../replay/compress-vary-accept.replay.yaml        | 117 +++++++++++++++++++++
 20 files changed, 280 insertions(+), 46 deletions(-)

diff --git a/plugins/cache_promote/configs.cc b/plugins/cache_promote/configs.cc
index a9d8b96092..68bd8e41bb 100644
--- a/plugins/cache_promote/configs.cc
+++ b/plugins/cache_promote/configs.cc
@@ -20,6 +20,7 @@
 #include "configs.h"
 #include "lru_policy.h"
 #include "chance_policy.h"
+#include "tsutil/StringCompare.h"
 
 //////////////////////////////////////////////////////////////////////////////////////////////
 // ToDo: It's ugly that this is a "global" options list, clearly each policy should be able
@@ -58,9 +59,9 @@ PromotionConfig::factory(int argc, char *argv[])
     if (opt == -1) {
       break;
     } else if (opt == 'p') {
-      if (0 == strncasecmp(optarg, "chance", 6)) {
+      if (ts::iequals(optarg, "chance")) {
         _policy = new ChancePolicy();
-      } else if (0 == strncasecmp(optarg, "lru", 3)) {
+      } else if (ts::iequals(optarg, "lru")) {
         _policy = new LRUPolicy();
       } else {
         TSError("[%s] Unknown policy --policy=%s", PLUGIN_NAME, optarg);
diff --git a/plugins/cachekey/configs.cc b/plugins/cachekey/configs.cc
index d08d7a2094..55db9b699c 100644
--- a/plugins/cachekey/configs.cc
+++ b/plugins/cachekey/configs.cc
@@ -28,6 +28,7 @@
 #include <cstring>   /* strlen() */
 
 #include "configs.h"
+#include "tsutil/StringCompare.h"
 
 template <typename ContainerType>
 static void
@@ -44,7 +45,7 @@ commaSeparateString(ContainerType &c, const String &input)
 static bool
 isTrue(const char *arg)
 {
-  return (nullptr == arg || 0 == strncasecmp("true", arg, 4) || 0 == strncasecmp("1", arg, 1) || 0 == strncasecmp("yes", arg, 3));
+  return (nullptr == arg || ts::iequals("true", arg) || ts::iequals("1", arg) || ts::iequals("yes", arg));
 }
 
 void
diff --git a/plugins/compress/compress.cc b/plugins/compress/compress.cc
index 3994be9b72..1cae68a992 100644
--- a/plugins/compress/compress.cc
+++ b/plugins/compress/compress.cc
@@ -29,6 +29,7 @@
 
 #include <tsutil/PostScript.h>
 #include <tsutil/Metrics.h>
+#include <tsutil/StringCompare.h>
 
 #include "ts/ts.h"
 #include "tscore/ink_defs.h"
@@ -250,7 +251,7 @@ vary_header(TSMBuffer bufp, TSMLoc hdr_loc)
     count = TSMimeHdrFieldValuesCount(bufp, hdr_loc, ce_loc);
     for (idx = 0; idx < count; idx++) {
       const char *value = TSMimeHdrFieldValueStringGet(bufp, hdr_loc, ce_loc, idx, &len);
-      if (len && strncasecmp("Accept-Encoding", value, len) == 0) {
+      if (len > 0 && ts::iequals("Accept-Encoding", std::string_view{value, static_cast<std::string_view::size_type>(len)})) {
         // Bail, Vary: Accept-Encoding already sent from origin
         TSHandleMLocRelease(bufp, hdr_loc, ce_loc);
         return TS_SUCCESS;
diff --git a/plugins/escalate/escalate.cc b/plugins/escalate/escalate.cc
index 67b6d77518..56c6ad8f80 100644
--- a/plugins/escalate/escalate.cc
+++ b/plugins/escalate/escalate.cc
@@ -35,6 +35,7 @@
 
 #include "swoc/IPEndpoint.h"
 #include "swoc/TextView.h"
+#include "tsutil/StringCompare.h"
 
 // Constants and some declarations
 
@@ -258,11 +259,11 @@ TSRemapNewInstance(int argc, char *argv[], void **instance, char *errbuf, int er
     char *sep, *token, *save;
 
     // Ugly, but we set the precedence before with non-command line parsing of args
-    if (0 == strncasecmp(argv[i], "--pristine", 10)) {
+    if (ts::iequals(argv[i], "--pristine")) {
       es->use_pristine = true;
-    } else if (0 == strncasecmp(argv[i], "--no-redirect-header", 20)) {
+    } else if (ts::iequals(argv[i], "--no-redirect-header")) {
       es->add_redirect_header = false;
-    } else if (0 == strncasecmp(argv[i], "--escalate-non-get-methods", 26)) {
+    } else if (ts::iequals(argv[i], "--escalate-non-get-methods")) {
       es->escalate_non_get_methods = true;
     } else {
       // Each token should be a status code then a URL, separated by ':'.
diff --git a/plugins/experimental/access_control/config.cc b/plugins/experimental/access_control/config.cc
index 30b65077b3..d2f2e186ad 100644
--- a/plugins/experimental/access_control/config.cc
+++ b/plugins/experimental/access_control/config.cc
@@ -28,11 +28,12 @@
 
 #include "common.h"
 #include "config.h"
+#include "tsutil/StringCompare.h"
 
 static bool
 isTrue(const char *arg)
 {
-  return (nullptr == arg || 0 == strncasecmp("true", arg, 4) || 0 == strncasecmp("1", arg, 1) || 0 == strncasecmp("yes", arg, 3));
+  return (nullptr == arg || ts::iequals("true", arg) || ts::iequals("1", arg) || ts::iequals("yes", arg));
 }
 
 /**
diff --git a/plugins/experimental/cache_fill/configs.h b/plugins/experimental/cache_fill/configs.h
index 81e31b05ab..489dc90c88 100644
--- a/plugins/experimental/cache_fill/configs.h
+++ b/plugins/experimental/cache_fill/configs.h
@@ -32,6 +32,7 @@
 #include <sys/socket.h>
 
 #include "rules.h"
+#include "tsutil/StringCompare.h"
 
 ///////////////////////////////////////////////////////////////////////////
 // This holds one complete background fetch rule
@@ -73,7 +74,7 @@ public:
   static bool
   isTrue(const char *arg)
   {
-    return (nullptr == arg || 0 == strncasecmp("true", arg, 4) || 0 == strncasecmp("1", arg, 1) || 0 == strncasecmp("yes", arg, 3));
+    return (nullptr == arg || ts::iequals("true", arg) || ts::iequals("1", arg) || ts::iequals("yes", arg));
   }
 
   // This parses and populates the BgFetchRule linked list (_rules).
diff --git a/plugins/experimental/custom_redirect/custom_redirect.cc b/plugins/experimental/custom_redirect/custom_redirect.cc
index 84ee69e123..91949eab53 100644
--- a/plugins/experimental/custom_redirect/custom_redirect.cc
+++ b/plugins/experimental/custom_redirect/custom_redirect.cc
@@ -31,6 +31,7 @@
 #include <arpa/inet.h>
 #include <netdb.h>
 #include <ts/ts.h>
+#include <tsutil/StringCompare.h>
 #include <cstring>
 #include <cstdlib>
 
@@ -63,8 +64,8 @@ handle_response(TSHttpTxn txnp, TSCont /* contp ATS_UNUSED */)
       } else {
         int         method_len;
         const char *method = TSHttpHdrMethodGet(req_bufp, req_loc, &method_len);
-        if ((return_code == TS_HTTP_STATUS_NONE || return_code == status) &&
-            ((strncasecmp(method, TS_HTTP_METHOD_GET, TS_HTTP_LEN_GET) == 0))) {
+        if ((return_code == TS_HTTP_STATUS_NONE || return_code == status) && method != nullptr &&
+            ts::iequals(std::string_view{method, static_cast<std::string_view::size_type>(method_len)}, TS_HTTP_METHOD_GET)) {
           redirect_url_loc = TSMimeHdrFieldFind(resp_bufp, resp_loc, redirect_url_header, redirect_url_header_len);
 
           if (redirect_url_loc) {
diff --git a/plugins/lua/ts_lua.cc b/plugins/lua/ts_lua.cc
index f4af729070..943a3e12d8 100644
--- a/plugins/lua/ts_lua.cc
+++ b/plugins/lua/ts_lua.cc
@@ -24,6 +24,7 @@
 #include <pthread.h>
 
 #include "ts_lua_util.h"
+#include "tsutil/StringCompare.h"
 
 extern "C" {
 #include "luajit.h"
@@ -242,7 +243,7 @@ lifecycleHandler(TSCont contp, TSEvent /* event ATS_UNUSED */, void *edata)
 {
   // ensure the message is for ts_lua
   TSPluginMsg *const msgp = (TSPluginMsg *)edata;
-  if (0 != strncasecmp(msgp->tag, TS_LUA_DEBUG_TAG, strlen(msgp->tag))) {
+  if (!ts::iequals(msgp->tag, TS_LUA_DEBUG_TAG)) {
     return TS_EVENT_NONE;
   }
 
diff --git a/plugins/prefetch/configs.cc b/plugins/prefetch/configs.cc
index 9e86a9a2bc..9263f787a3 100644
--- a/plugins/prefetch/configs.cc
+++ b/plugins/prefetch/configs.cc
@@ -21,14 +21,14 @@
  * @brief Plugin configuration.
  */
 
-#include <charconv>  /* std::from_chars() */
-#include <cstring>   /* strlen() */
-#include <fstream>   /* std::ifstream */
-#include <getopt.h>  /* getopt_long() */
-#include <sstream>   /* std::istringstream */
-#include <strings.h> /* strncasecmp() */
+#include <charconv> /* std::from_chars() */
+#include <cstring>  /* strlen() */
+#include <fstream>  /* std::ifstream */
+#include <getopt.h> /* getopt_long() */
+#include <sstream>  /* std::istringstream */
 
 #include "configs.h"
+#include "tsutil/StringCompare.h"
 
 DbgCtl Bg_dbg_ctl{PLUGIN_NAME};
 
@@ -47,7 +47,7 @@ commaSeparateString(ContainerType &c, const String &input)
 static bool
 isTrue(const char *arg)
 {
-  return (0 == strncasecmp("true", arg, 4) || 0 == strncasecmp("1", arg, 1) || 0 == strncasecmp("yes", arg, 3));
+  return (ts::iequals("true", arg) || ts::iequals("1", arg) || ts::iequals("yes", arg));
 }
 
 static String
@@ -66,13 +66,6 @@ fetchOverflowString(const EvalPolicy policy)
   }
 }
 
-static bool
-iequals(const StringView lhs, const StringView rhs)
-{
-  return std::equal(lhs.begin(), lhs.end(), rhs.begin(), rhs.end(),
-                    [](const char a, const char b) { return tolower(a) == tolower(b); });
-}
-
 bool
 PrefetchConfig::setFetchOverflow(const char *optarg)
 {
@@ -83,7 +76,7 @@ PrefetchConfig::setFetchOverflow(const char *optarg)
     _fetchOverflow = EvalPolicy::Overflow32;
   } else if (StringView("64") == optarg) {
     _fetchOverflow = EvalPolicy::Overflow64;
-  } else if (iequals("bignum", optarg)) {
+  } else if (ts::iequals("bignum", optarg)) {
     _fetchOverflow = EvalPolicy::Bignum;
   } else {
     return false;
diff --git a/plugins/regex_revalidate/regex_revalidate.cc b/plugins/regex_revalidate/regex_revalidate.cc
index 11fc3720e1..ef45897188 100644
--- a/plugins/regex_revalidate/regex_revalidate.cc
+++ b/plugins/regex_revalidate/regex_revalidate.cc
@@ -34,6 +34,7 @@
 #include <unistd.h>
 
 #include "tsutil/Regex.h"
+#include "tsutil/StringCompare.h"
 
 #define CONFIG_TMOUT      60000
 #define FREE_TMOUT        300000
@@ -323,9 +324,9 @@ load_state(plugin_state_t *pstate, invalidate_t **ilist)
       }
 
       auto const type = matches[4];
-      if (0 == strncasecmp(type.data(), RESULT_STALE, type.length())) {
+      if (ts::iequals(type, RESULT_STALE)) {
         Dbg(dbg_ctl, "state: regex line set to result type %s: '%s'", RESULT_STALE, inv->regex_text);
-      } else if (0 == strncasecmp(type.data(), RESULT_MISS, type.length())) {
+      } else if (ts::iequals(type, RESULT_MISS)) {
         Dbg(dbg_ctl, "state: regex line set to result type %s: '%s'", RESULT_MISS, inv->regex_text);
         inv->new_result = TS_CACHE_LOOKUP_MISS;
       } else {
@@ -439,10 +440,10 @@ load_config(plugin_state_t *pstate, invalidate_t **ilist)
 
         if (5 == rc) {
           auto const type = matches[4];
-          if (0 == strncasecmp(type.data(), RESULT_MISS, type.length())) {
+          if (ts::iequals(type, RESULT_MISS)) {
             Dbg(dbg_ctl, "Regex line set to result type %s: '%s'", RESULT_MISS, i->regex_text);
             i->new_result = TS_CACHE_LOOKUP_MISS;
-          } else if (0 != strncasecmp(type.data(), RESULT_STALE, type.length())) {
+          } else if (!ts::iequals(type, RESULT_STALE)) {
             Dbg(dbg_ctl, "Unknown regex line result type '%.*s', using default '%s' '%s'", (int)type.length(), type.data(),
                 RESULT_STALE, i->regex_text);
           }
diff --git a/plugins/stats_over_http/stats_over_http.cc b/plugins/stats_over_http/stats_over_http.cc
index 954e894ffa..98d39f2eee 100644
--- a/plugins/stats_over_http/stats_over_http.cc
+++ b/plugins/stats_over_http/stats_over_http.cc
@@ -49,6 +49,7 @@
 #include "swoc/TextView.h"
 #include "tscore/ink_config.h"
 #include <tsutil/ts_ip.h>
+#include <tsutil/StringCompare.h>
 
 #if HAVE_BROTLI_ENCODE_H
 #include <brotli/encode.h>
@@ -1151,21 +1152,27 @@ stats_origin(TSCont contp, TSEvent /* event ATS_UNUSED */, void *edata)
     my_state->output_format = output_format_t::JSON_OUTPUT; // default to json output
     // accept header exists, use it to determine response type
     if (accept_field != TS_NULL_MLOC) {
-      int         len = -1;
-      const char *str = TSMimeHdrFieldValueStringGet(reqp, hdr_loc, accept_field, -1, &len);
+      int              len = -1;
+      const char      *str = TSMimeHdrFieldValueStringGet(reqp, hdr_loc, accept_field, -1, &len);
+      std::string_view accept{};
+
+      if (str != nullptr && len > 0) {
+        accept = std::string_view{str, static_cast<std::string_view::size_type>(len)};
+      }
 
       // Parse the Accept header, default to JSON output unless its another supported format
-      if (!strncasecmp(str, "text/csv", len)) {
+      if (ts::iequals(accept, "text/csv")) {
         Dbg(dbg_ctl, "Saw text/csv in accept header, sending CSV output.");
         my_state->output_format = output_format_t::CSV_OUTPUT;
-      } else if (!strncasecmp(str, "text/plain; version=0.0.4", len)) {
+      } else if (ts::iequals(accept, "text/plain; version=0.0.4")) {
         Dbg(dbg_ctl, "Saw text/plain; version=0.0.4 in accept header, sending Prometheus output.");
         my_state->output_format = output_format_t::PROMETHEUS_OUTPUT;
-      } else if (!strncasecmp(str, "text/plain; version=2.0.0", len)) {
+      } else if (ts::iequals(accept, "text/plain; version=2.0.0")) {
         Dbg(dbg_ctl, "Saw text/plain; version=2.0.0 in accept header, sending Prometheus v2 output.");
         my_state->output_format = output_format_t::PROMETHEUS_V2_OUTPUT;
       } else {
-        Dbg(dbg_ctl, "Saw %.*s in accept header, defaulting to JSON output.", len, str);
+        Dbg(dbg_ctl, "Saw %.*s in accept header, defaulting to JSON output.", static_cast<int>(accept.size()),
+            accept.empty() ? "" : accept.data());
         my_state->output_format = output_format_t::JSON_OUTPUT;
       }
     }
diff --git a/src/iocore/cache/HttpTransactCache.cc b/src/iocore/cache/HttpTransactCache.cc
index be8f0864d1..43f62e483d 100644
--- a/src/iocore/cache/HttpTransactCache.cc
+++ b/src/iocore/cache/HttpTransactCache.cc
@@ -30,6 +30,7 @@
 #include "proxy/hdrs/HTTP.h"
 #include "proxy/hdrs/HttpCompat.h"
 #include "tscore/ink_time.h"
+#include "tsutil/StringCompare.h"
 
 #include <ctime>
 
@@ -941,9 +942,7 @@ HttpTransactCache::calculate_quality_of_accept_encoding_match(MIMEField *accept_
     } else {
       // does this document have the identity encoding? //
       for (c_value = c_values_list.head; c_value; c_value = c_value->next) {
-        auto c_encoding     = c_value->str;
-        auto c_encoding_len = c_value->len;
-        if ((c_encoding_len >= 8) && (strncasecmp(c_encoding, "identity", 8) == 0)) {
+        if (ts::iequals(std::string_view{c_value->str, c_value->len}, "identity")) {
           is_identity_encoding = true;
           break;
         }
diff --git a/src/proxy/ControlBase.cc b/src/proxy/ControlBase.cc
index 564889183d..0cfbb4ac3f 100644
--- a/src/proxy/ControlBase.cc
+++ b/src/proxy/ControlBase.cc
@@ -34,6 +34,8 @@
 #include "tscore/ink_defs.h"
 #include "tscore/ink_time.h"
 
+#include "tsutil/StringCompare.h"
+
 #include "proxy/hdrs/URL.h"
 #include "tscore/Tokenizer.h"
 #include "proxy/ControlBase.h"
@@ -661,9 +663,9 @@ InternalMod::make(char *value, const char **error)
 {
   InternalMod tmp;
 
-  if (0 == strncasecmp("false", value, 5)) {
+  if (ts::iequals("false", value)) {
     tmp.flag = false;
-  } else if (0 == strncasecmp("true", value, 4)) {
+  } else if (ts::iequals("true", value)) {
     tmp.flag = true;
   } else {
     *error = "Value must be true or false";
diff --git a/src/proxy/logging/YamlLogConfig.cc b/src/proxy/logging/YamlLogConfig.cc
index 99b376507e..b179988ceb 100644
--- a/src/proxy/logging/YamlLogConfig.cc
+++ b/src/proxy/logging/YamlLogConfig.cc
@@ -27,6 +27,7 @@
 #include "proxy/logging/LogObject.h"
 
 #include "tscore/EnumDescriptor.h"
+#include "tsutil/StringCompare.h"
 
 #include <yaml-cpp/yaml.h>
 #include <algorithm>
@@ -176,9 +177,9 @@ YamlLogConfig::decodeLogObject(const YAML::Node &node)
   LogFileFormat file_type = LOG_FILE_ASCII; // default value
   if (node["mode"]) {
     std::string mode = node["mode"].as<std::string>();
-    file_type        = (0 == strncasecmp(mode.c_str(), "bin", 3) || (1 == mode.size() && mode[0] == 'b') ?
+    file_type        = (ts::iequals(mode, "binary") || ts::iequals(mode, "bin") || ts::iequals(mode, "b") ?
                           LOG_FILE_BINARY :
-                          (0 == strcasecmp(mode.c_str(), "ascii_pipe") ? LOG_FILE_PIPE : LOG_FILE_ASCII));
+                          (ts::iequals(mode, "ascii_pipe") ? LOG_FILE_PIPE : LOG_FILE_ASCII));
   }
 
   int obj_rolling_enabled      = cfg->rolling_enabled;
diff --git a/src/proxy/logging/YamlLogConfigDecoders.cc b/src/proxy/logging/YamlLogConfigDecoders.cc
index 0c3b438c35..cbabf591d4 100644
--- a/src/proxy/logging/YamlLogConfigDecoders.cc
+++ b/src/proxy/logging/YamlLogConfigDecoders.cc
@@ -24,6 +24,8 @@
 #include "proxy/logging/LogConfig.h"
 #include "proxy/logging/LogObject.h"
 
+#include "tsutil/StringCompare.h"
+
 #include <yaml-cpp/yaml.h>
 #include <algorithm>
 #include <memory>
@@ -73,9 +75,9 @@ convert<std::unique_ptr<LogFormat>>::decode(const Node &node, std::unique_ptr<Lo
   LogEscapeType escape_type = LOG_ESCAPE_NONE; // default value
   if (node["escape"]) {
     std::string escape = node["escape"].as<std::string>();
-    if (!strncasecmp(escape.c_str(), "json", 4)) {
+    if (ts::iequals(escape, "json")) {
       escape_type = LOG_ESCAPE_JSON;
-    } else if (!strncasecmp(escape.c_str(), "none", 4)) {
+    } else if (ts::iequals(escape, "none")) {
       escape_type = LOG_ESCAPE_NONE;
     } else {
       throw YAML::ParserException(node.Mark(), "invalid 'escape' argument '" + escape + "' for format name '" + name + "'");
diff --git a/src/tsutil/CMakeLists.txt b/src/tsutil/CMakeLists.txt
index 56dd2ee8ed..b6f6224f9b 100644
--- a/src/tsutil/CMakeLists.txt
+++ b/src/tsutil/CMakeLists.txt
@@ -30,6 +30,7 @@ set(TSUTIL_PUBLIC_HEADERS
     ${PROJECT_SOURCE_DIR}/include/tsutil/LocalBuffer.h
     ${PROJECT_SOURCE_DIR}/include/tsutil/PostScript.h
     ${PROJECT_SOURCE_DIR}/include/tsutil/Strerror.h
+    ${PROJECT_SOURCE_DIR}/include/tsutil/StringCompare.h
     ${PROJECT_SOURCE_DIR}/include/tsutil/StringConvert.h
     ${PROJECT_SOURCE_DIR}/include/tsutil/Regex.h
     ${PROJECT_SOURCE_DIR}/include/tsutil/TsMutex.h
@@ -81,6 +82,7 @@ if(BUILD_TESTING)
     unit_tests/test_Metrics.cc
     unit_tests/test_PostScript.cc
     unit_tests/test_Strerror.cc
+    unit_tests/test_StringCompare.cc
     unit_tests/test_StringConvert.cc
     unit_tests/test_Regex.cc
     unit_tests/test_ts_meta.cc
diff --git a/src/tsutil/unit_tests/test_StringCompare.cc b/src/tsutil/unit_tests/test_StringCompare.cc
new file mode 100644
index 0000000000..53ff2696fd
--- /dev/null
+++ b/src/tsutil/unit_tests/test_StringCompare.cc
@@ -0,0 +1,71 @@
+/** @file
+
+    Unit tests for StringCompare.h.
+
+    @section license License
+
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+ */
+
+#include <catch2/catch_test_macros.hpp>
+
+#include <string_view>
+#include <tsutil/StringCompare.h>
+
+TEST_CASE("iequals", "[STE]")
+{
+  SECTION("equal")
+  {
+    REQUIRE(ts::iequals("Accept-Encoding", "Accept-Encoding"));
+    REQUIRE(ts::iequals("", ""));
+  }
+
+  SECTION("differing case")
+  {
+    REQUIRE(ts::iequals("ACCEPT-ENCODING", "accept-encoding"));
+    REQUIRE(ts::iequals("Accept-Encoding", "aCCePt-eNCoDiNG"));
+  }
+
+  SECTION("prefix is not equal")
+  {
+    REQUIRE_FALSE(ts::iequals("Accept", "Accept-Encoding"));
+    REQUIRE_FALSE(ts::iequals("Accept-Encoding", "Accept"));
+    REQUIRE_FALSE(ts::iequals("", "Accept-Encoding"));
+    REQUIRE_FALSE(ts::iequals("Accept-Encoding", ""));
+  }
+
+  SECTION("suffix is not equal")
+  {
+    REQUIRE_FALSE(ts::iequals("Encoding", "Accept-Encoding"));
+    REQUIRE_FALSE(ts::iequals("Accept-Encoding", "Encoding"));
+  }
+
+  SECTION("same length, differing content")
+  {
+    REQUIRE_FALSE(ts::iequals("gzip", "zstd"));
+    REQUIRE_FALSE(ts::iequals("Accept-Encoding", "Accept-Language"));
+  }
+
+  SECTION("length is honored over NUL termination")
+  {
+    // Callers build views from a pointer and a length; the terminator must not decide the result.
+    static constexpr char raw[] = "Accept-Encoding-and-more";
+
+    REQUIRE(ts::iequals(std::string_view{raw, 15}, "Accept-Encoding"));
+    REQUIRE_FALSE(ts::iequals(std::string_view{raw, 16}, "Accept-Encoding"));
+  }
+}
diff --git a/tests/gold_tests/pluginTest/compress/compress-vary-accept.config b/tests/gold_tests/pluginTest/compress/compress-vary-accept.config
new file mode 100644
index 0000000000..34c9f1be12
--- /dev/null
+++ b/tests/gold_tests/pluginTest/compress/compress-vary-accept.config
@@ -0,0 +1,4 @@
+cache false
+compressible-content-type text/*
+supported-algorithms gzip
+minimum-content-length 0
diff --git a/tests/gold_tests/pluginTest/compress/compress-vary-accept.test.py b/tests/gold_tests/pluginTest/compress/compress-vary-accept.test.py
new file mode 100644
index 0000000000..02c95f5bdb
--- /dev/null
+++ b/tests/gold_tests/pluginTest/compress/compress-vary-accept.test.py
@@ -0,0 +1,27 @@
+'''
+Verify compress appends Accept-Encoding to an existing origin Vary header.
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+Test.Summary = '''
+Verify compress appends Accept-Encoding to an origin Vary header whose value is
+a prefix of Accept-Encoding.
+'''
+
+Test.SkipUnless(Condition.PluginExists('compress.so'))
+
+Test.ATSReplayTest(replay_file="replay/compress-vary-accept.replay.yaml")
diff --git a/tests/gold_tests/pluginTest/compress/replay/compress-vary-accept.replay.yaml b/tests/gold_tests/pluginTest/compress/replay/compress-vary-accept.replay.yaml
new file mode 100644
index 0000000000..a40d17a95a
--- /dev/null
+++ b/tests/gold_tests/pluginTest/compress/replay/compress-vary-accept.replay.yaml
@@ -0,0 +1,117 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+meta:
+  version: "1.0"
+
+  blocks:
+  - compressible-body: &compressible-body
+      content:
+        encoding: plain
+        size: 1024
+
+autest:
+  description: 'Verify compress appends Accept-Encoding to an existing origin Vary header'
+
+  server:
+    name: 'server'
+
+  client:
+    name: 'client'
+
+  ats:
+    name: 'ts'
+
+    copy_to_config_dir:
+      - 'compress-vary-accept.config'
+
+    records_config:
+      proxy.config.diags.debug.enabled: 1
+      proxy.config.diags.debug.tags: 'compress'
+
+    # The pparam resolves relative to the ATS config dir, where
+    # copy_to_config_dir places it.
+    remap_config:
+      - from: "http://compress.example/"
+        to: "http://127.0.0.1:{SERVER_HTTP_PORT}/"
+        plugins:
+          - name: 'compress.so'
+            args: ['compress-vary-accept.config']
+
+sessions:
+- transactions:
+
+  #############################################################################
+  # An origin Vary value that is a prefix of "Accept-Encoding" must not be
+  # mistaken for it: compress has to append Accept-Encoding alongside Accept.
+  #############################################################################
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /vary-accept
+      headers:
+        fields:
+        - [ Host, compress.example ]
+        - [ Accept-Encoding, gzip ]
+        - [ uuid, vary-accept ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Type, text/javascript ]
+        - [ Vary, Accept ]
+        - [ Content-Length, 1024 ]
+      <<: *compressible-body
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Content-Encoding, { value: gzip, as: equal } ]
+        - [ Vary, { value: "Accept, Accept-Encoding", as: equal } ]
+
+  #############################################################################
+  # Control: an origin that already varies on Accept-Encoding is left alone,
+  # so the value is not duplicated.
+  #############################################################################
+  - client-request:
+      method: "GET"
+      version: "1.1"
+      url: /vary-accept-encoding
+      headers:
+        fields:
+        - [ Host, compress.example ]
+        - [ Accept-Encoding, gzip ]
+        - [ uuid, vary-accept-encoding ]
+
+    server-response:
+      status: 200
+      reason: OK
+      headers:
+        fields:
+        - [ Content-Type, text/javascript ]
+        - [ Vary, Accept-Encoding ]
+        - [ Content-Length, 1024 ]
+      <<: *compressible-body
+
+    proxy-response:
+      status: 200
+      headers:
+        fields:
+        - [ Content-Encoding, { value: gzip, as: equal } ]
+        - [ Vary, { value: "Accept-Encoding", as: equal } ]