From fc4490bdb2c5507c7ce703d2b8094aaeb64c62f5 Mon Sep 17 00:00:00 2001 From: bneradt Date: Thu, 13 Aug 2026 14:48:08 -0500 Subject: [PATCH] Honor valueless max-stale requests A valueless Cache-Control: max-stale directive permits clients to accept responses stale by any amount. Header cooking instead discarded the directive, causing ATS to revalidate an otherwise acceptable cached response. Commit d3196a87a28a64bfd26a0a721ccc24305fe8cc0b ("Ignore malformed Cache-Control directives per RFC 7234", #12670) introduced the regression by treating every numeric directive without an equals sign as malformed. This patch represents valueless max-stale with the existing unlimited sentinel and adds parser and end-to-end regression coverage. --- src/proxy/hdrs/MIME.cc | 8 +- src/proxy/hdrs/unit_tests/test_HdrUtils.cc | 6 ++ .../replay/cache-control-basic.replay.yaml | 73 ++++++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/proxy/hdrs/MIME.cc b/src/proxy/hdrs/MIME.cc index 9430385d42c..d479c6fe72e 100644 --- a/src/proxy/hdrs/MIME.cc +++ b/src/proxy/hdrs/MIME.cc @@ -3812,9 +3812,13 @@ MIMEHdrImpl::recompute_cooked_stuff(MIMEField *changing_field_or_null, const std csv_value_mask = 0; m_cooked_stuff.m_cache_control.m_mask &= ~mask; } + } else if (token_wks == MIME_VALUE_MAX_STALE.c_str()) { + // RFC 9111, Section 5.2.1.2: a valueless max-stale request + // directive permits a stale response of any age. + m_cooked_stuff.m_cache_control.m_secs_max_stale = INT_MAX; } else { - // No '=' found, or whitespace before '='. This is malformed. - // For directives that require values, this is an error. + // No '=' found, or whitespace before '='. This is malformed + // for directives that require values. // Clear the mask for this directive. csv_value_mask = 0; m_cooked_stuff.m_cache_control.m_mask &= ~mask; diff --git a/src/proxy/hdrs/unit_tests/test_HdrUtils.cc b/src/proxy/hdrs/unit_tests/test_HdrUtils.cc index befd37b5502..c6b60a87bcb 100644 --- a/src/proxy/hdrs/unit_tests/test_HdrUtils.cc +++ b/src/proxy/hdrs/unit_tests/test_HdrUtils.cc @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -426,6 +427,11 @@ TEST_CASE("Cache-Control Valid Cooking", "[proxy][hdrutils]") MIME_COOKED_MASK_CC_MAX_STALE, 0, 0, 100, 0}, + {"max-stale without a value", + "Cache-Control: max-stale\r\n\r\n", + MIME_COOKED_MASK_CC_MAX_STALE, + 0, 0, INT_MAX, 0}, + {"min-fresh=60", "Cache-Control: min-fresh=60\r\n\r\n", MIME_COOKED_MASK_CC_MIN_FRESH, diff --git a/tests/gold_tests/cache/replay/cache-control-basic.replay.yaml b/tests/gold_tests/cache/replay/cache-control-basic.replay.yaml index 02a6264256f..cb6d670e124 100644 --- a/tests/gold_tests/cache/replay/cache-control-basic.replay.yaml +++ b/tests/gold_tests/cache/replay/cache-control-basic.replay.yaml @@ -19,7 +19,7 @@ meta: # Configuration section for autest integration autest: - description: 'Test basic cache operations: miss, hit, no-cache-control, stale, and only-if-cached' + description: 'Test basic cache operations: miss, hit, no-cache-control, stale, only-if-cached, and max-stale' dns: name: 'dns-cache-basic' @@ -221,3 +221,74 @@ sessions: fields: - [X-Cache, { value: "miss", as: equal }] - [Cache-Control, { value: "no-store", as: equal }] + + ############################################################################# + # Test 6: Populate an entry for a valueless max-stale request + ############################################################################# + - client-request: + method: GET + url: /max-stale + version: '1.1' + headers: + fields: + - [Host, www.example.com] + - [x-debug, "x-cache,x-cache-key,via"] + - [uuid, max-stale-populate] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [Content-Type, text/plain] + - [Content-Length, "5"] + # max-age=0 makes the cached response stale immediately. + - [Cache-Control, "max-age=0"] + content: + encoding: plain + data: stale + + proxy-response: + status: 200 + headers: + fields: + - [X-Cache, { value: "miss", as: equal }] + content: + encoding: plain + data: stale + verify: { as: equal } + + ############################################################################# + # Test 7: Valueless max-stale permits serving a stale response of any age + ############################################################################# + - client-request: + # Allow the cache write from the preceding transaction to finish. + delay: 100ms + method: GET + url: /max-stale + version: '1.1' + headers: + fields: + - [Host, www.example.com] + - [Cache-Control, "max-stale"] + - [x-debug, "x-cache,x-cache-key,via"] + - [uuid, max-stale-serve] + + proxy-request: + expect: absent + + # The server response is a sentinel and must not be used. + server-response: + status: 502 + reason: Bad Gateway + + proxy-response: + status: 200 + headers: + fields: + # ATS reports an acceptable-stale lookup as a fresh cache hit. + - [X-Cache, { value: "hit-fresh", as: equal }] + content: + encoding: plain + data: stale + verify: { as: equal }