This is an automated email from the ASF dual-hosted git repository. bneradt 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 78777f3373 ESI: --allowed-response-codes (#12459) 78777f3373 is described below commit 78777f337363565097fe9d9345b43f19a94f09c2 Author: Brian Neradt <brian.ner...@gmail.com> AuthorDate: Tue Aug 19 11:20:24 2025 -0500 ESI: --allowed-response-codes (#12459) Adds --allowed-response-codes to the ESI plugin for response code filtering for which ESI transformations will take place. Some origins may add X-Esi headers to every response, even 5xx responses or the like. Setting those up for transformation can be wasteful. --- doc/admin-guide/plugins/esi.en.rst | 3 + plugins/esi/esi.cc | 102 ++++++++++++++++++++-------- tests/gold_tests/pluginTest/esi/esi.test.py | 30 +++++++- 3 files changed, 105 insertions(+), 30 deletions(-) diff --git a/doc/admin-guide/plugins/esi.en.rst b/doc/admin-guide/plugins/esi.en.rst index 21855d4a40..0c0994ae51 100644 --- a/doc/admin-guide/plugins/esi.en.rst +++ b/doc/admin-guide/plugins/esi.en.rst @@ -92,6 +92,9 @@ Enabling ESI 1024 * 1024. Example values: 500, 5K, 2M. If this option is omitted, the maximum document size defaults to 1M. - ``--max-inclusion-depth <max-depth>`` controls the maximum depth of recursive ESI inclusion allowed (between 0 and 9). Default is 3. +- ``--allowed-response-codes <code1,code2,...>`` specifies a comma-separated list of HTTP response codes that should + be processed for ESI transformation. Only responses with these status codes will be examined for ESI content. Default + is ``200,304``. 3. ``HTTP_COOKIE`` variable support is turned off by default. It can be turned on with ``-f <handler_config>`` or ``-handler <handler_config>``. For example: diff --git a/plugins/esi/esi.cc b/plugins/esi/esi.cc index 229f68dac0..a93eae0ab2 100644 --- a/plugins/esi/esi.cc +++ b/plugins/esi/esi.cc @@ -33,9 +33,11 @@ #include <limits> #include <arpa/inet.h> #include <getopt.h> +#include <unordered_set> #include "ts/ts.h" #include "ts/remap.h" +#include "swoc/TextView.h" #include "Utils.h" #include "gzip.h" @@ -52,13 +54,16 @@ using std::string; using namespace EsiLib; using namespace Stats; +using response_codes_t = std::unordered_set<int>; + struct OptionInfo { - bool packed_node_support{false}; - bool private_response{false}; - bool disable_gzip_output{false}; - bool first_byte_flush{false}; - unsigned max_doc_size{1024 * 1024}; - unsigned max_inclusion_depth{3}; + bool packed_node_support{false}; + bool private_response{false}; + bool disable_gzip_output{false}; + bool first_byte_flush{false}; + unsigned max_doc_size{1024 * 1024}; + unsigned max_inclusion_depth{3}; + response_codes_t allowed_response_codes{200, 304}; }; static HandlerManager *gHandlerManager = nullptr; @@ -1314,17 +1319,20 @@ isTxnTransformable(TSHttpTxn txnp, bool is_cache_txn, const OptionInfo *pOptionI return false; } - // if origin returns status 304, check cached response instead - int response_status; - if (is_cache_txn == false) { - response_status = TSHttpHdrStatusGet(bufp, hdr_loc); - if (response_status == TS_HTTP_STATUS_NOT_MODIFIED) { - TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc); - header_obtained = TSHttpTxnCachedRespGet(txnp, &bufp, &hdr_loc); - if (header_obtained != TS_SUCCESS) { - TSError("[esi][%s] Couldn't get txn cache response header", __FUNCTION__); - return false; - } + int const response_status = TSHttpHdrStatusGet(bufp, hdr_loc); + Dbg(dbg_ctl_local, "Checking status: %d", response_status); + if (pOptionInfo->allowed_response_codes.find(response_status) == pOptionInfo->allowed_response_codes.end()) { + Dbg(dbg_ctl_local, "Not transforming response of status: %d (not in configured transform response codes)", response_status); + TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc); + return false; + } + if (!is_cache_txn && response_status == TS_HTTP_STATUS_NOT_MODIFIED) { + // if origin returns status 304, check cached response instead + TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc); + header_obtained = TSHttpTxnCachedRespGet(txnp, &bufp, &hdr_loc); + if (header_obtained != TS_SUCCESS) { + TSError("[esi][%s] Couldn't get txn cache response header", __FUNCTION__); + return false; } } @@ -1614,18 +1622,19 @@ esiPluginInit(int argc, const char *argv[], OptionInfo *pOptionInfo) if (argc > 1) { int c; static const struct option longopts[] = { - {const_cast<char *>("packed-node-support"), no_argument, nullptr, 'n'}, - {const_cast<char *>("private-response"), no_argument, nullptr, 'p'}, - {const_cast<char *>("disable-gzip-output"), no_argument, nullptr, 'z'}, - {const_cast<char *>("first-byte-flush"), no_argument, nullptr, 'b'}, - {const_cast<char *>("handler-filename"), required_argument, nullptr, 'f'}, - {const_cast<char *>("max-doc-size"), required_argument, nullptr, 'd'}, - {const_cast<char *>("max-inclusion-depth"), required_argument, nullptr, 'i'}, - {nullptr, 0, nullptr, 0 }, + {const_cast<char *>("packed-node-support"), no_argument, nullptr, 'n'}, + {const_cast<char *>("private-response"), no_argument, nullptr, 'p'}, + {const_cast<char *>("disable-gzip-output"), no_argument, nullptr, 'z'}, + {const_cast<char *>("first-byte-flush"), no_argument, nullptr, 'b'}, + {const_cast<char *>("handler-filename"), required_argument, nullptr, 'f'}, + {const_cast<char *>("max-doc-size"), required_argument, nullptr, 'd'}, + {const_cast<char *>("max-inclusion-depth"), required_argument, nullptr, 'i'}, + {const_cast<char *>("allowed-response-codes"), required_argument, nullptr, 'r'}, + {nullptr, 0, nullptr, 0 }, }; int longindex = 0; - while ((c = getopt_long(argc, const_cast<char *const *>(argv), "npzbf:d:i:", longopts, &longindex)) != -1) { + while ((c = getopt_long(argc, const_cast<char *const *>(argv), "npzbf:d:i:r:", longopts, &longindex)) != -1) { switch (c) { case 'n': pOptionInfo->packed_node_support = true; @@ -1679,6 +1688,34 @@ esiPluginInit(int argc, const char *argv[], OptionInfo *pOptionInfo) pOptionInfo->max_inclusion_depth = max; break; } + case 'r': { + // Parse comma-separated list of response codes using TextView. + pOptionInfo->allowed_response_codes.clear(); + swoc::TextView codes_view(optarg); + + while (codes_view) { + auto code_view = codes_view.take_prefix_at(','); + if (code_view.empty() && codes_view.empty()) { + break; // Handle trailing comma case + } + + swoc::TextView parsed; + auto code_value = swoc::svtou(code_view, &parsed); + + // Check if the entire token was parsed and is a valid HTTP status code + if (parsed.size() == code_view.size() && code_value >= 100 && code_value < 600) { + pOptionInfo->allowed_response_codes.insert(static_cast<int>(code_value)); + } else if (!code_view.empty()) { // Ignore empty tokens (e.g., from trailing commas) + TSEmergency("[esi][%s] invalid response code format or value (%.*s) - must be between 100 and 599", __FUNCTION__, + static_cast<int>(code_view.size()), code_view.data()); + } + } + + if (pOptionInfo->allowed_response_codes.empty()) { + TSEmergency("[esi][%s] no valid response codes specified", __FUNCTION__); + } + break; + } default: TSEmergency("[esi][%s] bad option", __FUNCTION__); return -1; @@ -1686,12 +1723,21 @@ esiPluginInit(int argc, const char *argv[], OptionInfo *pOptionInfo) } } + // Format response codes for logging. + string response_codes_str = ""; + for (auto const response_code : pOptionInfo->allowed_response_codes) { + if (!response_codes_str.empty()) { + response_codes_str += ","; + } + response_codes_str += std::to_string(response_code); + } + Dbg(dbg_ctl_local, "[%s] Plugin started, " "packed-node-support: %d, private-response: %d, disable-gzip-output: %d, first-byte-flush: %d, max-doc-size %u, " - "max-inclusion-depth %u ", + "max-inclusion-depth %u, allowed-response-codes: [%s]", __FUNCTION__, pOptionInfo->packed_node_support, pOptionInfo->private_response, pOptionInfo->disable_gzip_output, - pOptionInfo->first_byte_flush, pOptionInfo->max_doc_size, pOptionInfo->max_inclusion_depth); + pOptionInfo->first_byte_flush, pOptionInfo->max_doc_size, pOptionInfo->max_inclusion_depth, response_codes_str.c_str()); return 0; } diff --git a/tests/gold_tests/pluginTest/esi/esi.test.py b/tests/gold_tests/pluginTest/esi/esi.test.py index 0d83a8e5ee..9e647942b8 100644 --- a/tests/gold_tests/pluginTest/esi/esi.test.py +++ b/tests/gold_tests/pluginTest/esi/esi.test.py @@ -186,9 +186,9 @@ echo date('l jS \of F Y h:i:s A'); client_process.Streams.stdout = "gold/esi_body.gold" if self._cc_behavior == CcBehaviorT.REMOVE_CC: client_process.Streams.stderr += Testers.ExcludesExpression( - 'cache-control:', 'The Cache-Control field not be present in the response', reflags=re.IGNORECASE) + 'cache-control:', 'The Cache-Control field should not be present in the response', reflags=re.IGNORECASE) client_process.Streams.stderr += Testers.ExcludesExpression( - 'expires:', 'The Expires field not be present in the response', reflags=re.IGNORECASE) + 'expires:', 'The Expires field should not be present in the response', reflags=re.IGNORECASE) if self._cc_behavior == CcBehaviorT.MAKE_PRIVATE: client_process.Streams.stderr += Testers.ContainsExpression( 'cache-control:.*max-age=0, private', @@ -310,6 +310,22 @@ echo date('l jS \of F Y h:i:s A'); tr.StillRunningAfter = self._server tr.StillRunningAfter = self._ts + def run_cases_expecting_no_transformation(self): + tr = Test.AddTestRun(f"Verify the ESI plugin does not transform responses: {self._plugin_config}") + client = tr.MakeCurlCommand( + f'http://127.0.0.1:{self._ts.Variables.port}/esi.php ' + '-H"Host: www.example.com" -H"Accept: */*" --verbose', + ts=self._ts) + client.ReturnCode = 0 + + # Expect no transformation: the tag should be present without any transformation. + client.Streams.stdout += Testers.ContainsExpression( + 'Hello, <esi:include src="http://www.example.com/date.php"/>', + 'The response should not be transformed', + reflags=re.IGNORECASE) + tr.StillRunningAfter = self._server + tr.StillRunningAfter = self._ts + # # Configure and run the test cases. @@ -352,3 +368,13 @@ max_doc_2K_test = EsiTest(plugin_config='esi.so --max-doc-size 2K') max_doc_2K_test.run_cases_expecting_gzip() max_doc_20M_test = EsiTest(plugin_config='esi.so --max-doc-size 20M') max_doc_20M_test.run_cases_expecting_gzip() + +# The test doesn't use 304 redirect, so restricting the allowed response codes to 200 +# should not affect the test. +allowed_response_codes_test = EsiTest(plugin_config='esi.so --allowed-response-codes 200') +allowed_response_codes_test.run_cases_expecting_gzip() + +# Do not allow transforming the 200 OK response. Since the test uses a 200 OK response, +# the plugin should not transform it. +response_not_allowed_test = EsiTest(plugin_config='esi.so --allowed-response-codes 304') +response_not_allowed_test.run_cases_expecting_no_transformation()