This is an automated email from the ASF dual-hosted git repository.
bneradt pushed a commit to branch 11-Dev
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/11-Dev by this push:
new d3196a87a2 Ignore malformed Cache-Control directives per RFC 7234
(#12670)
d3196a87a2 is described below
commit d3196a87a28a64bfd26a0a721ccc24305fe8cc0b
Author: Brian Neradt <[email protected]>
AuthorDate: Mon Feb 2 17:45:43 2026 -0600
Ignore malformed Cache-Control directives per RFC 7234 (#12670)
Detect and ignore Cache-Control directives that are malformed (e.g.,
using semicolons instead of commas). When the parser cannot fully
consume a Cache-Control directive value, leaving unparsed non-whitespace
content, the directive is treated as unrecognized and ignored per RFC
7234 Section 5.2.
For example, a malformed header such as 'public; max-age=30' (note the
incorrect semicolon separator) would previously set the 'public' bit but
ignore the unparsed 'max-age=30', causing the response to be cached with
incorrect heuristic lifetimes. Not only is this incorrect per the RFC,
but the intended max-age was, from the user's perspective, mysteriously
ignored. Now the entire malformed directive is ignored, allowing default
caching rules to apply correctly.
Fixes: #12029
---
src/proxy/hdrs/MIME.cc | 79 +++-
src/proxy/hdrs/unit_tests/test_HdrUtils.cc | 501 ++++++++++++++++++---
tests/gold_tests/cache/negative-caching.test.py | 3 +
.../negative-caching-malformed-cc.replay.yaml | 317 +++++++++++++
.../stale_response_no_default.replay.yaml | 2 +-
.../stale_response_with_force_sie.replay.yaml | 2 +-
.../stale_response_with_force_swr.replay.yaml | 4 +-
7 files changed, 816 insertions(+), 92 deletions(-)
diff --git a/src/proxy/hdrs/MIME.cc b/src/proxy/hdrs/MIME.cc
index 8eae5d7852..5f2a089c4b 100644
--- a/src/proxy/hdrs/MIME.cc
+++ b/src/proxy/hdrs/MIME.cc
@@ -3752,6 +3752,9 @@ MIMEHdrImpl::recompute_cooked_stuff(MIMEField
*changing_field_or_null)
for (s = csv_iter.get_first(field, &len); s != nullptr; s =
csv_iter.get_next(&len)) {
e = s + len;
+ // Store set mask bits from this CSV value so we can clear them if
needed.
+ uint32_t csv_value_mask = 0;
+
for (c = s; (c < e) && (ParseRules::is_token(*c)); c++) {
;
}
@@ -3766,6 +3769,7 @@ MIMEHdrImpl::recompute_cooked_stuff(MIMEField
*changing_field_or_null)
HdrTokenHeapPrefix *p =
hdrtoken_wks_to_prefix(token_wks);
mask =
p->wks_type_specific.u.cache_control.cc_mask;
m_cooked_stuff.m_cache_control.m_mask |= mask;
+ csv_value_mask |= mask;
#if TRACK_COOKING
Dbg(dbg_ctl_http, " set mask 0x%0X", mask);
@@ -3774,27 +3778,72 @@ MIMEHdrImpl::recompute_cooked_stuff(MIMEField
*changing_field_or_null)
if (mask & (MIME_COOKED_MASK_CC_MAX_AGE |
MIME_COOKED_MASK_CC_S_MAXAGE | MIME_COOKED_MASK_CC_MAX_STALE |
MIME_COOKED_MASK_CC_MIN_FRESH)) {
int value;
+ // Per RFC 7230 Section 3.2.3, there should be no whitespace
around '='.
+ const char *value_start = c;
+
+ // Check if the next character is '=' (no space allowed before
'=').
+ if (c < e && *c == '=') {
+ ++c; // Move past the '='
+
+ // Again: no whitespace after the '=' either. Keep in mind
that values can be negative.
+ bool valid_syntax = (c < e) && (is_digit(*c) || *c == '-');
- if (mime_parse_integer(c, e, &value)) {
+ if (valid_syntax) {
+ // Reset to value_start to let mime_parse_integer do its
work.
+ c = value_start;
+ if (mime_parse_integer(c, e, &value)) {
#if TRACK_COOKING
- Dbg(dbg_ctl_http, " set integer value
%d", value);
+ Dbg(dbg_ctl_http, " set integer
value %d", value);
#endif
- if (token_wks == MIME_VALUE_MAX_AGE.c_str()) {
- m_cooked_stuff.m_cache_control.m_secs_max_age = value;
- } else if (token_wks == MIME_VALUE_MIN_FRESH.c_str()) {
- m_cooked_stuff.m_cache_control.m_secs_min_fresh = value;
- } else if (token_wks == MIME_VALUE_MAX_STALE.c_str()) {
- m_cooked_stuff.m_cache_control.m_secs_max_stale = value;
- } else if (token_wks == MIME_VALUE_S_MAXAGE.c_str()) {
- m_cooked_stuff.m_cache_control.m_secs_s_maxage = value;
- }
- } else {
+ if (token_wks == MIME_VALUE_MAX_AGE.c_str()) {
+ m_cooked_stuff.m_cache_control.m_secs_max_age = value;
+ } else if (token_wks == MIME_VALUE_MIN_FRESH.c_str()) {
+ m_cooked_stuff.m_cache_control.m_secs_min_fresh = value;
+ } else if (token_wks == MIME_VALUE_MAX_STALE.c_str()) {
+ m_cooked_stuff.m_cache_control.m_secs_max_stale = value;
+ } else if (token_wks == MIME_VALUE_S_MAXAGE.c_str()) {
+ m_cooked_stuff.m_cache_control.m_secs_s_maxage = value;
+ }
+ } else {
#if TRACK_COOKING
- Dbg(dbg_ctl_http, " set integer value
%d", INT_MAX);
+ Dbg(dbg_ctl_http, " set integer
value %d", INT_MAX);
#endif
- if (token_wks == MIME_VALUE_MAX_STALE.c_str()) {
- m_cooked_stuff.m_cache_control.m_secs_max_stale = INT_MAX;
+ if (token_wks == MIME_VALUE_MAX_STALE.c_str()) {
+ m_cooked_stuff.m_cache_control.m_secs_max_stale =
INT_MAX;
+ }
+ }
+ } else {
+ // Syntax is malformed (e.g., whitespace after '=', quotes
around value, or no value).
+ // Treat this as unrecognized and clear the mask.
+ csv_value_mask = 0;
+ m_cooked_stuff.m_cache_control.m_mask &= ~mask;
}
+ } else {
+ // No '=' found, or whitespace before '='. This is malformed.
+ // For directives that require values, this is an error.
+ // Clear the mask for this directive.
+ csv_value_mask = 0;
+ m_cooked_stuff.m_cache_control.m_mask &= ~mask;
+ }
+ }
+
+ // Detect whether there is any more non-whitespace content after
the
+ // directive. This indicates an unrecognized or malformed
directive.
+ // This can happen, for instance, if the host uses semicolons
+ // instead of commas as separators which is against RFC 7234 (see
+ // issue #12029). Regardless of the cause, this means we need to
+ // ignore the directive and clear any mask bits we set from it.
+ while (c < e && ParseRules::is_ws(*c)) {
+ ++c;
+ }
+ if (c < e) {
+ // There's non-whitespace content that wasn't parsed. This means
+ // that we cannot really understand what this directive is.
+ // Per RFC 7234 Section 5.2: "A cache MUST ignore unrecognized
cache
+ // directives."
+ if (csv_value_mask != 0) {
+ // Reverse the mask that we set above.
+ m_cooked_stuff.m_cache_control.m_mask &= ~csv_value_mask;
}
}
}
diff --git a/src/proxy/hdrs/unit_tests/test_HdrUtils.cc
b/src/proxy/hdrs/unit_tests/test_HdrUtils.cc
index 119a4bb495..befd37b550 100644
--- a/src/proxy/hdrs/unit_tests/test_HdrUtils.cc
+++ b/src/proxy/hdrs/unit_tests/test_HdrUtils.cc
@@ -24,33 +24,110 @@
#include <bitset>
#include <initializer_list>
#include <new>
+#include <vector>
#include <catch2/catch_test_macros.hpp>
+#include <catch2/generators/catch_generators.hpp>
+#include <catch2/generators/catch_generators_range.hpp>
#include "proxy/hdrs/HdrHeap.h"
#include "proxy/hdrs/MIME.h"
#include "proxy/hdrs/HdrUtils.h"
-TEST_CASE("HdrUtils", "[proxy][hdrutils]")
+// Parameterized test for HdrCsvIter parsing.
+TEST_CASE("HdrCsvIter", "[proxy][hdrutils]")
{
- static constexpr swoc::TextView text{"One: alpha\r\n"
- "Two: alpha, bravo\r\n"
- "Three: zwoop, \"A,B\" , , phil ,
\"unterminated\r\n"
- "Five: alpha, bravo, charlie\r\n"
- "Four: itchi, \"ni, \\\"san\" , \"\" ,
\"\r\n"
- "Five: delta, echo\r\n"
- "\r\n"};
-
- static constexpr std::string_view ONE_TAG{"One"};
- static constexpr std::string_view TWO_TAG{"Two"};
- static constexpr std::string_view THREE_TAG{"Three"};
- static constexpr std::string_view FOUR_TAG{"Four"};
- static constexpr std::string_view FIVE_TAG{"Five"};
+ constexpr bool COMBINE_DUPLICATES = true;
+
+ // Structure for parameterized HdrCsvIter tests.
+ struct CsvIterTestCase {
+ const char *description;
+ const char *header_text;
+ const char *field_name;
+ std::vector<std::string_view> expected_values;
+ bool combine_dups; // Parameter for get_first()
+ };
+
+ // Test cases for HdrCsvIter parsing.
+ // clang-format off
+ static const std::vector<CsvIterTestCase> csv_iter_test_cases = {
+ // Basic CSV parsing tests
+ {"single value",
+ "One: alpha\r\n\r\n",
+ "One",
+ {"alpha"},
+ COMBINE_DUPLICATES},
+
+ {"two values",
+ "Two: alpha, bravo\r\n\r\n",
+ "Two",
+ {"alpha", "bravo"},
+ COMBINE_DUPLICATES},
+
+ {"quoted values and escaping",
+ "Three: zwoop, \"A,B\" , , phil , \"unterminated\r\n\r\n",
+ "Three",
+ {"zwoop", "A,B", "phil", "unterminated"},
+ COMBINE_DUPLICATES},
+
+ {"escaped quotes passed through",
+ "Four: itchi, \"ni, \\\"san\" , \"\" , \"\r\n\r\n",
+ "Four",
+ {"itchi", "ni, \\\"san"},
+ COMBINE_DUPLICATES},
+
+ {"duplicate fields combined",
+ "Five: alpha, bravo, charlie\r\nFive: delta, echo\r\n\r\n",
+ "Five",
+ {"alpha", "bravo", "charlie", "delta", "echo"},
+ COMBINE_DUPLICATES},
+
+ {"duplicate fields not combined",
+ "Five: alpha, bravo, charlie\r\nFive: delta, echo\r\n\r\n",
+ "Five",
+ {"alpha", "bravo", "charlie"},
+ !COMBINE_DUPLICATES},
+
+ // Cache-Control specific tests
+ {"Cache-Control: basic max-age and public",
+ "Cache-Control: max-age=30, public\r\n\r\n",
+ "Cache-Control",
+ {"max-age=30", "public"},
+ COMBINE_DUPLICATES},
+
+ {"Cache-Control: extension directives with values",
+ "Cache-Control: stale-if-error=1, stale-while-revalidate=60,
no-cache\r\n\r\n",
+ "Cache-Control",
+ {"stale-if-error=1", "stale-while-revalidate=60", "no-cache"},
+ COMBINE_DUPLICATES},
+
+ {"Cache-Control: mixed directives",
+ "Cache-Control: public, max-age=300, s-maxage=600\r\n\r\n",
+ "Cache-Control",
+ {"public", "max-age=300", "s-maxage=600"},
+ COMBINE_DUPLICATES},
+
+ {"Cache-Control: semicolon separator treated as single value",
+ "Cache-Control: public; max-age=30\r\n\r\n",
+ "Cache-Control",
+ {"public; max-age=30"},
+ COMBINE_DUPLICATES},
+
+ {"Cache-Control: empty value",
+ "Cache-Control: \r\n\r\n",
+ "Cache-Control",
+ {},
+ COMBINE_DUPLICATES},
+ };
+ // clang-format on
+ auto test_case = GENERATE(from_range(csv_iter_test_cases));
+
+ CAPTURE(test_case.description, test_case.header_text);
HdrHeap *heap = new_HdrHeap(HdrHeap::DEFAULT_SIZE + 64);
MIMEParser parser;
- char const *real_s = text.data();
- char const *real_e = text.data_end();
+ char const *real_s = test_case.header_text;
+ char const *real_e = test_case.header_text + strlen(test_case.header_text);
MIMEHdr mime;
mime.create(heap);
@@ -60,65 +137,26 @@ TEST_CASE("HdrUtils", "[proxy][hdrutils]")
REQUIRE(ParseResult::DONE == result);
HdrCsvIter iter;
-
- MIMEField *field{mime.field_find(ONE_TAG)};
+ MIMEField *field = mime.field_find(test_case.field_name);
REQUIRE(field != nullptr);
- auto value = iter.get_first(field);
- REQUIRE(value == "alpha");
-
- field = mime.field_find(TWO_TAG);
- value = iter.get_first(field);
- REQUIRE(value == "alpha");
- value = iter.get_next();
- REQUIRE(value == "bravo");
- value = iter.get_next();
- REQUIRE(value.empty());
-
- field = mime.field_find(THREE_TAG);
- value = iter.get_first(field);
- REQUIRE(value == "zwoop");
- value = iter.get_next();
- REQUIRE(value == "A,B"); // quotes escape separator, and are stripped.
- value = iter.get_next();
- REQUIRE(value == "phil");
- value = iter.get_next();
- REQUIRE(value == "unterminated");
- value = iter.get_next();
- REQUIRE(value.empty());
-
- field = mime.field_find(FOUR_TAG);
- value = iter.get_first(field);
- REQUIRE(value == "itchi");
- value = iter.get_next();
- REQUIRE(value == "ni, \\\"san"); // verify escaped quotes are passed through.
- value = iter.get_next();
- REQUIRE(value.empty());
-
- // Check that duplicates are handled correctly.
- field = mime.field_find(FIVE_TAG);
- value = iter.get_first(field);
- REQUIRE(value == "alpha");
- value = iter.get_next();
- REQUIRE(value == "bravo");
- value = iter.get_next();
- REQUIRE(value == "charlie");
- value = iter.get_next();
- REQUIRE(value == "delta");
- value = iter.get_next();
- REQUIRE(value == "echo");
- value = iter.get_next();
- REQUIRE(value.empty());
-
- field = mime.field_find(FIVE_TAG);
- value = iter.get_first(field, false);
- REQUIRE(value == "alpha");
- value = iter.get_next();
- REQUIRE(value == "bravo");
- value = iter.get_next();
- REQUIRE(value == "charlie");
- value = iter.get_next();
- REQUIRE(value.empty());
+ if (test_case.expected_values.empty()) {
+ auto value = iter.get_first(field, test_case.combine_dups);
+ REQUIRE(value.empty());
+ } else {
+ auto value = iter.get_first(field, test_case.combine_dups);
+ REQUIRE(value == test_case.expected_values[0]);
+
+ for (size_t i = 1; i < test_case.expected_values.size(); ++i) {
+ value = iter.get_next();
+ REQUIRE(value == test_case.expected_values[i]);
+ }
+
+ // After all expected values, the next should be empty.
+ value = iter.get_next();
+ REQUIRE(value.empty());
+ }
+
heap->destroy();
}
@@ -207,3 +245,320 @@ TEST_CASE("HdrUtils 3", "[proxy][hdrutils]")
REQUIRE(0 == memcmp(swoc::TextView(buff, idx), text));
heap->destroy();
};
+
+// Test that malformed Cache-Control directives are properly ignored during
cooking.
+// All malformed directives should result in mask == 0.
+TEST_CASE("Cache-Control Malformed Cooking", "[proxy][hdrutils]")
+{
+ struct MalformedCCTestCase {
+ const char *description;
+ const char *header_text;
+ };
+
+ // clang-format off
+ // These tests align with cache-tests.fyi/#cc-parse
+ static const std::vector<MalformedCCTestCase> malformed_cc_test_cases = {
+ // Separator issues
+ {"semicolon separator (should be comma)",
+ "Cache-Control: public; max-age=30\r\n\r\n"},
+
+ // Space around equals (cc-parse: max-age with space before/after =)
+ {"space before equals sign",
+ "Cache-Control: max-age =300\r\n\r\n"},
+
+ {"space after equals sign",
+ "Cache-Control: max-age= 300\r\n\r\n"},
+
+ {"space both before and after equals sign",
+ "Cache-Control: max-age = 300\r\n\r\n"},
+
+ // Quoted values (cc-parse: single-quoted max-age)
+ {"single quotes around value",
+ "Cache-Control: max-age='300'\r\n\r\n"},
+
+ {"double quotes around value",
+ "Cache-Control: max-age=\"300\"\r\n\r\n"},
+
+ // s-maxage variants
+ {"s-maxage with space before equals",
+ "Cache-Control: s-maxage =600\r\n\r\n"},
+
+ {"s-maxage with space after equals",
+ "Cache-Control: s-maxage= 600\r\n\r\n"},
+
+ // Invalid numeric values (cc-parse: decimal max-age)
+ {"decimal value in max-age (1.5)",
+ "Cache-Control: max-age=1.5\r\n\r\n"},
+
+ {"decimal value in max-age (3600.0)",
+ "Cache-Control: max-age=3600.0\r\n\r\n"},
+
+ {"decimal value starting with dot (.5)",
+ "Cache-Control: max-age=.5\r\n\r\n"},
+
+ {"decimal value in s-maxage",
+ "Cache-Control: s-maxage=1.5\r\n\r\n"},
+
+ // Leading and trailing alpha characters
+ {"leading alpha in max-age value",
+ "Cache-Control: max-age=a300\r\n\r\n"},
+
+ {"trailing alpha in max-age value",
+ "Cache-Control: max-age=300a\r\n\r\n"},
+
+ {"leading alpha in s-maxage value",
+ "Cache-Control: s-maxage=a600\r\n\r\n"},
+
+ {"trailing alpha in s-maxage value",
+ "Cache-Control: s-maxage=600a\r\n\r\n"},
+
+ // Empty and missing values
+ {"empty max-age value alone",
+ "Cache-Control: max-age=\r\n\r\n"},
+ };
+ // clang-format on
+
+ auto test_case = GENERATE(from_range(malformed_cc_test_cases));
+
+ CAPTURE(test_case.description, test_case.header_text);
+
+ HdrHeap *heap = new_HdrHeap(HdrHeap::DEFAULT_SIZE + 64);
+ MIMEParser parser;
+ char const *real_s = test_case.header_text;
+ char const *real_e = test_case.header_text + strlen(test_case.header_text);
+ MIMEHdr mime;
+
+ mime.create(heap);
+ mime_parser_init(&parser);
+
+ auto result = mime_parser_parse(&parser, heap, mime.m_mime, &real_s, real_e,
false, true, false);
+ REQUIRE(ParseResult::DONE == result);
+
+ mime.m_mime->recompute_cooked_stuff();
+
+ // All malformed directives should result in mask == 0.
+ auto mask = mime.get_cooked_cc_mask();
+ REQUIRE(mask == 0);
+
+ heap->destroy();
+}
+
+// Test that properly formed Cache-Control directives are correctly cooked.
+TEST_CASE("Cache-Control Valid Cooking", "[proxy][hdrutils]")
+{
+ struct ValidCCTestCase {
+ const char *description;
+ const char *header_text;
+ uint32_t expected_mask;
+ int32_t expected_max_age;
+ int32_t expected_s_maxage;
+ int32_t expected_max_stale;
+ int32_t expected_min_fresh;
+ };
+
+ // Use 0 to indicate "don't care" for integer values (mask determines which
are valid).
+ // clang-format off
+ static const std::vector<ValidCCTestCase> valid_cc_test_cases = {
+ // Basic directives without values
+ {"public only",
+ "Cache-Control: public\r\n\r\n",
+ MIME_COOKED_MASK_CC_PUBLIC,
+ 0, 0, 0, 0},
+
+ {"private only",
+ "Cache-Control: private\r\n\r\n",
+ MIME_COOKED_MASK_CC_PRIVATE,
+ 0, 0, 0, 0},
+
+ {"no-cache only",
+ "Cache-Control: no-cache\r\n\r\n",
+ MIME_COOKED_MASK_CC_NO_CACHE,
+ 0, 0, 0, 0},
+
+ {"no-store only",
+ "Cache-Control: no-store\r\n\r\n",
+ MIME_COOKED_MASK_CC_NO_STORE,
+ 0, 0, 0, 0},
+
+ {"no-transform only",
+ "Cache-Control: no-transform\r\n\r\n",
+ MIME_COOKED_MASK_CC_NO_TRANSFORM,
+ 0, 0, 0, 0},
+
+ {"must-revalidate only",
+ "Cache-Control: must-revalidate\r\n\r\n",
+ MIME_COOKED_MASK_CC_MUST_REVALIDATE,
+ 0, 0, 0, 0},
+
+ {"proxy-revalidate only",
+ "Cache-Control: proxy-revalidate\r\n\r\n",
+ MIME_COOKED_MASK_CC_PROXY_REVALIDATE,
+ 0, 0, 0, 0},
+
+ {"only-if-cached only",
+ "Cache-Control: only-if-cached\r\n\r\n",
+ MIME_COOKED_MASK_CC_ONLY_IF_CACHED,
+ 0, 0, 0, 0},
+
+ // Directives with values
+ {"max-age=0",
+ "Cache-Control: max-age=0\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ 0, 0, 0, 0},
+
+ {"max-age=300",
+ "Cache-Control: max-age=300\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ 300, 0, 0, 0},
+
+ {"max-age=86400",
+ "Cache-Control: max-age=86400\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ 86400, 0, 0, 0},
+
+ {"s-maxage=600",
+ "Cache-Control: s-maxage=600\r\n\r\n",
+ MIME_COOKED_MASK_CC_S_MAXAGE,
+ 0, 600, 0, 0},
+
+ {"max-stale=100",
+ "Cache-Control: max-stale=100\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_STALE,
+ 0, 0, 100, 0},
+
+ {"min-fresh=60",
+ "Cache-Control: min-fresh=60\r\n\r\n",
+ MIME_COOKED_MASK_CC_MIN_FRESH,
+ 0, 0, 0, 60},
+
+ // Multiple directives
+ {"max-age and public",
+ "Cache-Control: max-age=300, public\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_PUBLIC,
+ 300, 0, 0, 0},
+
+ {"public and max-age (reversed order)",
+ "Cache-Control: public, max-age=300\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_PUBLIC,
+ 300, 0, 0, 0},
+
+ {"max-age and s-maxage",
+ "Cache-Control: max-age=300, s-maxage=600\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_S_MAXAGE,
+ 300, 600, 0, 0},
+
+ {"private and no-cache",
+ "Cache-Control: private, no-cache\r\n\r\n",
+ MIME_COOKED_MASK_CC_PRIVATE | MIME_COOKED_MASK_CC_NO_CACHE,
+ 0, 0, 0, 0},
+
+ {"no-store and no-cache",
+ "Cache-Control: no-store, no-cache\r\n\r\n",
+ MIME_COOKED_MASK_CC_NO_STORE | MIME_COOKED_MASK_CC_NO_CACHE,
+ 0, 0, 0, 0},
+
+ {"must-revalidate and proxy-revalidate",
+ "Cache-Control: must-revalidate, proxy-revalidate\r\n\r\n",
+ MIME_COOKED_MASK_CC_MUST_REVALIDATE |
MIME_COOKED_MASK_CC_PROXY_REVALIDATE,
+ 0, 0, 0, 0},
+
+ {"complex: public, max-age, s-maxage, must-revalidate",
+ "Cache-Control: public, max-age=300, s-maxage=600,
must-revalidate\r\n\r\n",
+ MIME_COOKED_MASK_CC_PUBLIC | MIME_COOKED_MASK_CC_MAX_AGE |
+ MIME_COOKED_MASK_CC_S_MAXAGE | MIME_COOKED_MASK_CC_MUST_REVALIDATE,
+ 300, 600, 0, 0},
+
+ {"all request directives: max-age, max-stale, min-fresh, no-cache,
no-store, no-transform, only-if-cached",
+ "Cache-Control: max-age=100, max-stale=200, min-fresh=50, no-cache,
no-store, no-transform, only-if-cached\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_MAX_STALE |
MIME_COOKED_MASK_CC_MIN_FRESH |
+ MIME_COOKED_MASK_CC_NO_CACHE | MIME_COOKED_MASK_CC_NO_STORE |
+ MIME_COOKED_MASK_CC_NO_TRANSFORM | MIME_COOKED_MASK_CC_ONLY_IF_CACHED,
+ 100, 0, 200, 50},
+
+ // Edge cases - whitespace
+ {"extra whitespace around directive",
+ "Cache-Control: max-age=300 \r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ 300, 0, 0, 0},
+
+ {"extra whitespace between directives",
+ "Cache-Control: max-age=300 , public\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE | MIME_COOKED_MASK_CC_PUBLIC,
+ 300, 0, 0, 0},
+
+ {"tab character in header value",
+ "Cache-Control:\tmax-age=300\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ 300, 0, 0, 0},
+
+ // Edge cases - unknown directives
+ {"unknown directive ignored, known directive parsed",
+ "Cache-Control: unknown-directive, max-age=300\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ 300, 0, 0, 0},
+
+ {"unknown directive with value ignored",
+ "Cache-Control: unknown=value, public\r\n\r\n",
+ MIME_COOKED_MASK_CC_PUBLIC,
+ 0, 0, 0, 0},
+
+ // Edge cases - numeric values (cc-parse: 0000 max-age, large max-age)
+ {"max-age with leading zeros (cc-parse: 0000 max-age)",
+ "Cache-Control: max-age=0000\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ 0, 0, 0, 0},
+
+ {"max-age with leading zeros and value",
+ "Cache-Control: max-age=00300\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ 300, 0, 0, 0},
+
+ {"large max-age value",
+ "Cache-Control: max-age=999999999\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ 999999999, 0, 0, 0},
+
+ // Edge cases - negative values should be parsed (behavior per
implementation)
+ {"negative max-age value",
+ "Cache-Control: max-age=-1\r\n\r\n",
+ MIME_COOKED_MASK_CC_MAX_AGE,
+ -1, 0, 0, 0},
+ };
+ // clang-format on
+
+ auto test_case = GENERATE(from_range(valid_cc_test_cases));
+
+ CAPTURE(test_case.description, test_case.header_text);
+
+ HdrHeap *heap = new_HdrHeap(HdrHeap::DEFAULT_SIZE + 64);
+ MIMEParser parser;
+ char const *real_s = test_case.header_text;
+ char const *real_e = test_case.header_text + strlen(test_case.header_text);
+ MIMEHdr mime;
+
+ mime.create(heap);
+ mime_parser_init(&parser);
+
+ auto result = mime_parser_parse(&parser, heap, mime.m_mime, &real_s, real_e,
false, true, false);
+ REQUIRE(ParseResult::DONE == result);
+
+ mime.m_mime->recompute_cooked_stuff();
+
+ auto mask = mime.get_cooked_cc_mask();
+ REQUIRE(mask == test_case.expected_mask);
+
+ if (test_case.expected_mask & MIME_COOKED_MASK_CC_MAX_AGE) {
+ REQUIRE(mime.get_cooked_cc_max_age() == test_case.expected_max_age);
+ }
+ if (test_case.expected_mask & MIME_COOKED_MASK_CC_S_MAXAGE) {
+ REQUIRE(mime.get_cooked_cc_s_maxage() == test_case.expected_s_maxage);
+ }
+ if (test_case.expected_mask & MIME_COOKED_MASK_CC_MAX_STALE) {
+ REQUIRE(mime.get_cooked_cc_max_stale() == test_case.expected_max_stale);
+ }
+ if (test_case.expected_mask & MIME_COOKED_MASK_CC_MIN_FRESH) {
+ REQUIRE(mime.get_cooked_cc_min_fresh() == test_case.expected_min_fresh);
+ }
+
+ heap->destroy();
+}
diff --git a/tests/gold_tests/cache/negative-caching.test.py
b/tests/gold_tests/cache/negative-caching.test.py
index 0e09ae17bb..853624e634 100644
--- a/tests/gold_tests/cache/negative-caching.test.py
+++ b/tests/gold_tests/cache/negative-caching.test.py
@@ -132,3 +132,6 @@ p = tr.AddVerifierClientProcess("client-ttl-in-cache",
replay_file, http_ports=[
p.StartBefore(dns)
p.StartBefore(server)
p.StartBefore(ts)
+
+# Test malformed Cache-Control header with semicolons instead of commas (issue
#12029)
+Test.ATSReplayTest(replay_file="replay/negative-caching-malformed-cc.replay.yaml")
diff --git
a/tests/gold_tests/cache/replay/negative-caching-malformed-cc.replay.yaml
b/tests/gold_tests/cache/replay/negative-caching-malformed-cc.replay.yaml
new file mode 100644
index 0000000000..c973aa14b7
--- /dev/null
+++ b/tests/gold_tests/cache/replay/negative-caching-malformed-cc.replay.yaml
@@ -0,0 +1,317 @@
+# 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.
+
+#
+# This replay file tests negative caching with malformed Cache-Control headers
+# that use semicolons instead of commas as separators (issue #12029).
+#
+
+meta:
+ version: "1.0"
+
+# Configuration section for autest integration
+autest:
+ description: 'Test malformed Cache-Control header with semicolons instead of
commas (issue #12029)'
+
+ # Server configuration
+ server:
+ name: 'server-malformed-cc'
+
+ # Client configuration
+ client:
+ name: 'client-malformed-cc'
+
+ # ATS configuration
+ ats:
+ name: 'ts-malformed-cc'
+
+ # ATS records.config settings
+ records_config:
+ proxy.config.diags.debug.enabled: 1
+ proxy.config.diags.debug.tags: 'http'
+ proxy.config.http.insert_age_in_response: 0
+ proxy.config.http.negative_caching_enabled: 0
+
+ # Remap configuration
+ remap_config:
+ - from: "/"
+ to: "http://127.0.0.1:{SERVER_HTTP_PORT}/"
+
+sessions:
+- transactions:
+
+ #
+ # Phase 1: Initial requests to populate the cache.
+ #
+ # These requests are sent to the origin server and the responses are cached
+ # (or not cached, depending on the Cache-Control header).
+ #
+
+ # First, verify that a 400 response with a proper Cache-Control header is
cached.
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_proper_cc
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, proper_cc ]
+
+ server-response:
+ status: 400
+ reason: "Bad Request"
+ headers:
+ fields:
+ - [ Content-Length, 0 ]
+ - [ Cache-Control, max-age=300 ]
+
+ proxy-response:
+ status: 400
+
+ # Test: Verify that a 400 response with malformed Cache-Control using
+ # semicolons is not cached. The header "Cache-Control: public; max-age=30"
+ # uses semicolons instead of commas as separators, violating RFC 7234.
+ # Per RFC 7234 Section 5.2, caches must ignore unrecognized directives.
+ # Since the directive is malformed, it should be ignored, and the response
+ # should not be cached (no valid Cache-Control + negative_caching_enabled=0).
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_malformed_cc_semicolon
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, semicolon ]
+
+ server-response:
+ status: 400
+ reason: "Bad Request"
+ headers:
+ fields:
+ - [ Content-Length, 0 ]
+ # Note: Using semicolon instead of comma - this is malformed per RFC
7234
+ - [ Cache-Control, "public; max-age=30" ]
+
+ proxy-response:
+ status: 400
+
+ # Test: Verify that a 400 response with space before = in max-age is not
cached.
+ # The header "Cache-Control: max-age =300" is malformed per RFC 7230.
+ # Per RFC 7234 Section 5.2, caches must ignore unrecognized directives.
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_malformed_cc_space_before_equals
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, space_before_equals ]
+
+ server-response:
+ status: 400
+ reason: "Bad Request"
+ headers:
+ fields:
+ - [ Content-Length, 0 ]
+ # Note: Space before = is malformed per RFC 7230
+ - [ Cache-Control, "max-age =300" ]
+
+ proxy-response:
+ status: 400
+
+ # Test: Verify that a 400 response with space after = in max-age is not
cached.
+ # The header "Cache-Control: max-age= 300" is malformed per RFC 7230.
+ # Per RFC 7234 Section 5.2, caches must ignore unrecognized directives.
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_malformed_cc_space_after_equals
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, space_after_equals ]
+
+ server-response:
+ status: 400
+ reason: "Bad Request"
+ headers:
+ fields:
+ - [ Content-Length, 0 ]
+ # Note: Space after = is malformed per RFC 7230
+ - [ Cache-Control, "max-age= 300" ]
+
+ proxy-response:
+ status: 400
+
+ # Test: Verify that a 400 response with single-quoted max-age value is not
cached.
+ # The header "Cache-Control: max-age='300'" is malformed per RFC 7230.
+ # Per RFC 7234 Section 5.2, caches must ignore unrecognized directives.
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_malformed_cc_single_quotes
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, single_quotes ]
+
+ server-response:
+ status: 400
+ reason: "Bad Request"
+ headers:
+ fields:
+ - [ Content-Length, 0 ]
+ # Note: Single quotes around value are malformed per RFC 7230
+ - [ Cache-Control, "max-age='300'" ]
+
+ proxy-response:
+ status: 400
+
+ #
+ # Phase 2: Verification requests to check if responses were cached.
+ #
+ # These requests should be served from the cache (if cached) or from the
+ # origin server (if not cached).
+ #
+
+ # Second request to /path/400_proper_cc should be served from cache.
+ - client-request:
+
+ # Add delay to ensure time for cache IO processing.
+ delay: 100ms
+
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_proper_cc
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, proper_cc_verify ]
+
+ # The server should not receive this request because it should be served
from cache.
+ server-response:
+ status: 200
+ reason: "OK"
+ headers:
+ fields:
+ - [ Content-Length, 0 ]
+ - [ Cache-Control, max-age=300 ]
+
+ proxy-response:
+ status: 400
+
+ # Second request to /path/400_malformed_cc_semicolon should NOT be served
from cache.
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_malformed_cc_semicolon
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, semicolon_verify ]
+
+ # Since the initial CC was malformed, the response should not be cached.
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 16 ]
+ - [ Cache-Control, max-age=300 ]
+
+ # Expect the origin's 200 response.
+ proxy-response:
+ status: 200
+
+ # Second request to /path/400_malformed_cc_space_before_equals should NOT be
served from cache.
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_malformed_cc_space_before_equals
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, space_before_equals_verify ]
+
+ # Since the initial CC was malformed, the response should not be cached.
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 16 ]
+ - [ Cache-Control, max-age=300 ]
+
+ # Expect the origin's 200 response.
+ proxy-response:
+ status: 200
+
+ # Second request to /path/400_malformed_cc_space_after_equals should NOT be
served from cache.
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_malformed_cc_space_after_equals
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, space_after_equals_verify ]
+
+ # Since the initial CC was malformed, the response should not be cached.
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 16 ]
+ - [ Cache-Control, max-age=300 ]
+
+ # Expect the origin's 200 response.
+ proxy-response:
+ status: 200
+
+ # Second request to /path/400_malformed_cc_single_quotes should NOT be
served from cache.
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ scheme: "http"
+ url: /path/400_malformed_cc_single_quotes
+ headers:
+ fields:
+ - [ Host, example.com ]
+ - [ uuid, single_quotes_verify ]
+
+ # Since the initial CC was malformed, the response should not be cached.
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 16 ]
+ - [ Cache-Control, max-age=300 ]
+
+ # Expect the origin's 200 response.
+ proxy-response:
+ status: 200
+
diff --git
a/tests/gold_tests/pluginTest/stale_response/stale_response_no_default.replay.yaml
b/tests/gold_tests/pluginTest/stale_response/stale_response_no_default.replay.yaml
index 35d72c5973..d0961053b5 100644
---
a/tests/gold_tests/pluginTest/stale_response/stale_response_no_default.replay.yaml
+++
b/tests/gold_tests/pluginTest/stale_response/stale_response_no_default.replay.yaml
@@ -135,7 +135,7 @@ sessions:
- [ Content-Type, image/jpeg ]
- [ Content-Length, 100 ]
- [ Connection, keep-alive ]
- - [ Cache-Control, max-age=1 stale-if-error=30 ]
+ - [ Cache-Control, "max-age=1, stale-if-error=30" ]
- [ X-Response, fourth-response ]
# We better have gone back to the origin and gotten second-response.
diff --git
a/tests/gold_tests/pluginTest/stale_response/stale_response_with_force_sie.replay.yaml
b/tests/gold_tests/pluginTest/stale_response/stale_response_with_force_sie.replay.yaml
index 10a8cd68f2..5837d68384 100644
---
a/tests/gold_tests/pluginTest/stale_response/stale_response_with_force_sie.replay.yaml
+++
b/tests/gold_tests/pluginTest/stale_response/stale_response_with_force_sie.replay.yaml
@@ -43,7 +43,7 @@ sessions:
- [ Content-Length, 100 ]
- [ Connection, keep-alive ]
# Configure a small stale-if-error.
- - [ Cache-Control, max-age=1 stale-if-error=1 ]
+ - [ Cache-Control, "max-age=1, stale-if-error=1" ]
- [ X-Response, first-response ]
proxy-response:
diff --git
a/tests/gold_tests/pluginTest/stale_response/stale_response_with_force_swr.replay.yaml
b/tests/gold_tests/pluginTest/stale_response/stale_response_with_force_swr.replay.yaml
index a980bb5010..7a54ee94b3 100644
---
a/tests/gold_tests/pluginTest/stale_response/stale_response_with_force_swr.replay.yaml
+++
b/tests/gold_tests/pluginTest/stale_response/stale_response_with_force_swr.replay.yaml
@@ -42,14 +42,14 @@ sessions:
- [ Connection, keep-alive ]
# The low stale-while-revalidate should be overridden by
# --force-stale-while-revalidate.
- - [ Cache-Control, max-age=1 stale-while-revalidate=1 ]
+ - [ Cache-Control, "max-age=1, stale-while-revalidate=1" ]
- [ X-Response, first-response ]
proxy-response:
status: 200
headers:
fields:
- - [ Cache-Control, { value: "max-age=1 stale-while-revalidate=1", as:
equal } ]
+ - [ Cache-Control, { value: "max-age=1, stale-while-revalidate=1", as:
equal } ]
- [ X-Response, { value: first-response, as: equal } ]
- client-request: