LibHTTP: Implement a strict method to extract Cache-Control directives

Our previous implementation was a bit too tolerant of bad header values.
For example, extracting a "max-age" from a header value of "abmax-agecd"
would have incorrectly parsed successfully.

We now find exact (case-insensitive) directive matches. We also handle
quoted string values, which may contain important delimeters that we
would have previously split on.
This commit is contained in:
Timothy Flynn 2026-01-27 08:10:11 -05:00 committed by Tim Flynn
parent 6840571cb3
commit 40800fd91e
3 changed files with 127 additions and 27 deletions

View file

@ -4,34 +4,17 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/GenericLexer.h>
#include <AK/QuickSort.h>
#include <AK/StringBuilder.h>
#include <LibCrypto/Hash/SHA1.h>
#include <LibHTTP/Cache/DiskCache.h>
#include <LibHTTP/Cache/Utilities.h>
#include <LibHTTP/HTTP.h>
#include <LibURL/URL.h>
namespace HTTP {
static Optional<StringView> extract_cache_control_directive(StringView cache_control, StringView directive)
{
Optional<StringView> result;
cache_control.for_each_split_view(","sv, SplitBehavior::Nothing, [&](StringView candidate) {
if (!candidate.contains(directive, CaseSensitivity::CaseInsensitive))
return IterationDecision::Continue;
auto index = candidate.find('=');
if (!index.has_value())
return IterationDecision::Continue;
result = candidate.substring_view(*index + 1);
return IterationDecision::Break;
});
return result;
}
// https://httpwg.org/specs/rfc9110.html#field.date
static Optional<UnixDateTime> parse_http_date(Optional<ByteString const&> date)
{
@ -184,8 +167,7 @@ bool is_cacheable(u32 status_code, HeaderList const& headers)
// FIXME: must-understand is not implemented.
// * the no-store cache directive is not present in the response (see Section 5.2.2.5);
if (cache_control.has_value()
&& cache_control->contains("no-store"sv, CaseSensitivity::CaseInsensitive))
if (cache_control.has_value() && contains_cache_control_directive(*cache_control, "no-store"sv))
return false;
// * if the cache is shared: the private response directive is either not present or allows a shared cache to store
@ -213,9 +195,9 @@ bool is_cacheable(u32 status_code, HeaderList const& headers)
bool has_max_age = false;
if (cache_control.has_value()) {
has_public = cache_control->contains("public"sv, CaseSensitivity::CaseInsensitive);
has_private = cache_control->contains("private"sv, CaseSensitivity::CaseInsensitive);
has_max_age = cache_control->contains("max-age"sv, CaseSensitivity::CaseInsensitive);
has_public = contains_cache_control_directive(*cache_control, "public"sv);
has_private = contains_cache_control_directive(*cache_control, "private"sv);
has_max_age = contains_cache_control_directive(*cache_control, "max-age"sv);
// FIXME: cache extensions that explicitly allow caching are not interpreted.
}
@ -340,7 +322,7 @@ AK::Duration calculate_freshness_lifetime(u32 status_code, HeaderList const& hea
// been marked as explicitly cacheable (e.g., with a public response directive).
if (is_heuristically_cacheable_status(status_code)) {
heuristics_allowed = true;
} else if (cache_control.has_value() && cache_control->contains("public"sv, CaseSensitivity::CaseInsensitive)) {
} else if (cache_control.has_value() && contains_cache_control_directive(*cache_control, "public"sv)) {
heuristics_allowed = true;
}
@ -415,7 +397,7 @@ CacheLifetimeStatus cache_lifetime_status(HeaderList const& headers, AK::Duratio
// NOT be used to satisfy any other request without forwarding it for validation and receiving a successful response
//
// FIXME: Handle the qualified form of the no-cache directive, which may allow us to re-use the response.
if (cache_control.has_value() && cache_control->contains("no-cache"sv, CaseSensitivity::CaseInsensitive))
if (cache_control.has_value() && contains_cache_control_directive(*cache_control, "no-cache"sv))
return revalidation_status(CacheLifetimeStatus::MustRevalidate);
// https://httpwg.org/specs/rfc9111.html#expiration.model
@ -436,7 +418,7 @@ CacheLifetimeStatus cache_lifetime_status(HeaderList const& headers, AK::Duratio
// https://httpwg.org/specs/rfc9111.html#cache-response-directive.must-revalidate
// The must-revalidate response directive indicates that once the response has become stale, a cache MUST NOT reuse
// that response to satisfy another request until it has been successfully validated by the origin
if (cache_control->contains("must-revalidate"sv, CaseSensitivity::CaseInsensitive))
if (contains_cache_control_directive(*cache_control, "must-revalidate"sv))
return revalidation_status(CacheLifetimeStatus::MustRevalidate);
return CacheLifetimeStatus::Expired;
@ -495,6 +477,57 @@ void update_header_fields(HeaderList& stored_headers, HeaderList const& updated_
}
}
bool contains_cache_control_directive(StringView cache_control, StringView directive)
{
return extract_cache_control_directive(cache_control, directive).has_value();
}
// This is a modified version of the "get, decode, and split" algorithm. This version stops at the first match found,
// does not un-escape quoted strings, and deals only with ASCII encodings. See:
// https://fetch.spec.whatwg.org/#header-value-get-decode-and-split
Optional<StringView> extract_cache_control_directive(StringView cache_control, StringView directive)
{
VERIFY(!directive.is_empty());
GenericLexer lexer { cache_control };
size_t directive_start { 0 };
while (true) {
lexer.consume_until(is_any_of("\","sv));
if (!lexer.is_eof() && lexer.peek() == '"') {
auto quoted_string_start = lexer.tell();
lexer.consume_quoted_string('\\');
// FIXME: We currently bail if we come across an unterminated quoted string. Do other engines behave this
// way, or do they try to move on by finding the next comma?
if (quoted_string_start == lexer.tell())
return {};
if (!lexer.is_eof())
continue;
}
auto name = cache_control.substring_view(directive_start, lexer.tell() - directive_start);
StringView value;
if (auto index = name.find_any_of("=\""sv); index.has_value() && name[*index] == '=') {
value = name.substring_view(*index + 1);
name = name.substring_view(0, *index);
}
if (name.trim(HTTP_WHITESPACE).equals_ignoring_ascii_case(directive))
return value.trim(HTTP_WHITESPACE);
if (lexer.is_eof())
return {};
VERIFY(lexer.peek() == ',');
lexer.ignore(1);
directive_start = lexer.tell();
}
}
// https://httpwg.org/specs/rfc9111.html#caching.negotiated.responses
ByteString normalize_request_vary_header_values(StringView header, HeaderList const& request_headers)
{

View file

@ -52,6 +52,9 @@ struct RevalidationAttributes {
void store_header_and_trailer_fields(HeaderList&, HeaderList const&);
void update_header_fields(HeaderList&, HeaderList const&);
bool contains_cache_control_directive(StringView cache_control, StringView directive);
Optional<StringView> extract_cache_control_directive(StringView cache_control, StringView directive);
ByteString normalize_request_vary_header_values(StringView header, HeaderList const& request_headers);
AK::Duration compute_current_time_offset_for_testing(Optional<DiskCache&>, HeaderList const& request_headers);

View file

@ -8,6 +8,7 @@
#include <AK/GenericLexer.h>
#include <AK/String.h>
#include <LibHTTP/Cache/Utilities.h>
#include <LibHTTP/HTTP.h>
TEST_CASE(collect_an_http_quoted_string)
@ -61,3 +62,66 @@ TEST_CASE(collect_an_http_quoted_string)
EXPECT_EQ(result, "\"abc\""_string);
}
}
TEST_CASE(extract_cache_control_directive)
{
EXPECT(!HTTP::contains_cache_control_directive({}, "no-cache"sv));
EXPECT(!HTTP::contains_cache_control_directive(","sv, "no-cache"sv));
EXPECT(!HTTP::contains_cache_control_directive("no-cache"sv, "no"sv));
EXPECT(!HTTP::contains_cache_control_directive("no-cache"sv, "cache"sv));
EXPECT(!HTTP::contains_cache_control_directive("no-cache"sv, "no cache"sv));
EXPECT(!HTTP::contains_cache_control_directive("abno-cache"sv, "no-cache"sv));
EXPECT(!HTTP::contains_cache_control_directive("no-cachecd"sv, "no-cache"sv));
EXPECT(!HTTP::contains_cache_control_directive("abno-cachecd"sv, "no-cache"sv));
EXPECT_EQ(HTTP::extract_cache_control_directive("no-cache"sv, "no-cache"sv), ""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=4"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age = 4"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age= 4"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age =4"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age = 4 , no-cache"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("no-cache , max-age = 4"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("s-maxage=4, max-age=5"sv, "max-age"sv), "5"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("Max-Age=4"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("MAX-AGE=4"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=4"sv, "MAX-AGE"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("No-Cache"sv, "no-cache"sv), ""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=4,"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("no-cache,"sv, "no-cache"sv), ""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("no-cache, "sv, "no-cache"sv), ""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=4, max-age=5"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("no-cache, max-age=4, max-age=5"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=4, no-cache"sv, "no-cache"sv), ""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=\"4\""sv, "max-age"sv), "\"4\""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=\"004\""sv, "max-age"sv), "\"004\""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=\"4\", no-cache"sv, "max-age"sv), "\"4\""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("foo=\"bar\", max-age=\"4\""sv, "max-age"sv), "\"4\""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=\"4,5\", no-cache"sv, "max-age"sv), "\"4,5\""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=\"4\\5\""sv, "max-age"sv), "\"4\\5\""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=\"4\\\"5\""sv, "max-age"sv), "\"4\\\"5\""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=\"4\\\\5\""sv, "max-age"sv), "\"4\\\\5\""sv);
EXPECT(!HTTP::contains_cache_control_directive("max-age\"4\""sv, "max-age"sv));
EXPECT(!HTTP::contains_cache_control_directive("max-age=\"4"sv, "max-age"sv));
EXPECT(!HTTP::contains_cache_control_directive("foo=\"bar, max-age=4"sv, "max-age"sv));
EXPECT(!HTTP::contains_cache_control_directive("\"unterminated"sv, "max-age"sv));
EXPECT(!HTTP::contains_cache_control_directive("max-age=\"4, no-cache"sv, "max-age"sv));
EXPECT(!HTTP::contains_cache_control_directive("max-age=\"4, no-cache"sv, "no-cache"sv));
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=\"4, no-cache\", foo=bar"sv, "max-age"sv), "\"4, no-cache\""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=\"4, no-cache\", foo=bar"sv, "foo"sv), "bar"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("foo=\"bar,baz\", max-age=4"sv, "foo"sv), "\"bar,baz\""sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("foo=\"bar,baz\", max-age=4"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive(",,max-age=4"sv, "max-age"sv), "4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age==4"sv, "max-age"sv), "=4"sv);
EXPECT_EQ(HTTP::extract_cache_control_directive("max-age=4="sv, "max-age"sv), "4="sv);
EXPECT(!HTTP::contains_cache_control_directive("=4"sv, "max-age"sv));
}