diff --git a/CODEOWNERS b/CODEOWNERS index c0918fe362df8..66a89e71429fb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,6 +49,8 @@ extensions/filters/common/original_src @snowp @klarose /*/extensions/filters/http/dynamic_forward_proxy @mattklein123 @alyssawilk # omit_canary_hosts retry predicate /*/extensions/retry/host/omit_canary_hosts @sriduth @snowp +# HTTP caching extension +/*/extensions/filters/http/cache @toddmgreer @jmarantz # aws_iam grpc credentials /*/extensions/grpc_credentials/aws_iam @lavignes @mattklein123 /*/extensions/filters/http/common/aws @lavignes @mattklein123 diff --git a/source/extensions/filters/http/cache/BUILD b/source/extensions/filters/http/cache/BUILD new file mode 100644 index 0000000000000..327fc73eb02fa --- /dev/null +++ b/source/extensions/filters/http/cache/BUILD @@ -0,0 +1,21 @@ +licenses(["notice"]) # Apache 2 + +## Pluggable HTTP cache filter + +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_library", + "envoy_package", +) + +envoy_package() + +envoy_cc_library( + name = "http_cache_utils_lib", + srcs = ["http_cache_utils.cc"], + hdrs = ["http_cache_utils.h"], + deps = [ + "//include/envoy/common:time_interface", + "//include/envoy/http:header_map_interface", + ], +) diff --git a/source/extensions/filters/http/cache/http_cache_utils.cc b/source/extensions/filters/http/cache/http_cache_utils.cc new file mode 100644 index 0000000000000..26e235dfdd3fc --- /dev/null +++ b/source/extensions/filters/http/cache/http_cache_utils.cc @@ -0,0 +1,188 @@ +#include "extensions/filters/http/cache/http_cache_utils.h" + +#include +#include + +#include "absl/algorithm/container.h" +#include "absl/strings/ascii.h" +#include "absl/strings/numbers.h" +#include "absl/strings/strip.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { + +// True for characters defined as tchars by +// https://tools.ietf.org/html/rfc7230#section-3.2.6 +// +// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" +// / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +bool Utils::tchar(char c) { + switch (c) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + } + return absl::ascii_isalnum(c); +} + +// Removes an initial HTTP header field value token, as defined by +// https://tools.ietf.org/html/rfc7230#section-3.2.6. Returns true if an initial +// token was present. +// +// token = 1*tchar +bool Utils::eatToken(absl::string_view& s) { + const absl::string_view::iterator token_end = c_find_if_not(s, &tchar); + if (token_end == s.begin()) { + return false; + } + s.remove_prefix(token_end - s.begin()); + return true; +} + +// Removes an initial token or quoted-string (if present), as defined by +// https://tools.ietf.org/html/rfc7234#section-5.2. If a cache-control directive +// has an argument (as indicated by '='), it should be in this form. +// +// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE +// qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text +// obs-text = %x80-FF +// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) +// VCHAR = %x21-7E ; visible (printing) characters +// +// For example, the directive "my-extension=42" has an argument of "42", so an +// input of "public, my-extension=42, max-age=999" +void Utils::eatDirectiveArgument(absl::string_view& s) { + if (s.empty()) { + return; + } + if (s.front() == '"') { + // TODO(#9833): handle \-escaped quotes + const size_t closing_quote = s.find('"', 1); + s.remove_prefix(closing_quote); + } else { + eatToken(s); + } +} + +// If s is non-null and begins with a decimal number ([0-9]+), removes it from +// the input and returns a SystemTime::duration representing that many seconds. +// If s is null or doesn't begin with digits, returns +// SystemTime::duration::zero(). If parsing overflows, returns +// SystemTime::duration::max(). +SystemTime::duration Utils::eatLeadingDuration(absl::string_view& s) { + const absl::string_view::iterator digits_end = c_find_if_not(s, &absl::ascii_isdigit); + const size_t digits_length = digits_end - s.begin(); + if (digits_length == 0) { + return SystemTime::duration::zero(); + } + const absl::string_view digits(s.begin(), digits_length); + s.remove_prefix(digits_length); + uint64_t num; + return absl::SimpleAtoi(digits, &num) ? std::chrono::seconds(num) : SystemTime::duration::max(); +} + +// Returns the effective max-age represented by cache-control. If the result is +// SystemTime::duration::zero(), or is less than the response's, the response +// should be validated. +// +// TODO(#9833): Write a CacheControl class to fully parse the cache-control +// header value. Consider sharing with the gzip filter. +SystemTime::duration Utils::effectiveMaxAge(absl::string_view cache_control) { + // The grammar for This Cache-Control header value should be: + // Cache-Control = 1#cache-directive + // cache-directive = token [ "=" ( token / quoted-string ) ] + // token = 1*tchar + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" + // / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA + // quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE + // qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text + // obs-text = %x80-FF + // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) + // VCHAR = %x21-7E ; visible (printing) characters + SystemTime::duration max_age = SystemTime::duration::zero(); + bool found_s_maxage = false; + while (!cache_control.empty()) { + // Each time through the loop, we eat one cache-directive. Each branch + // either returns or completely eats a cache-directive. + if (ConsumePrefix(&cache_control, "no-cache")) { + if (eatToken(cache_control)) { + // The token wasn't no-cache; it just started that way, so we must + // finish eating this cache-directive. + if (ConsumePrefix(&cache_control, "=")) { + eatDirectiveArgument(cache_control); + } + } else { + // Found a no-cache directive, so validation is required. + return SystemTime::duration::zero(); + } + } else if (ConsumePrefix(&cache_control, "s-maxage=")) { + max_age = eatLeadingDuration(cache_control); + found_s_maxage = true; + cache_control = StripLeadingAsciiWhitespace(cache_control); + if (!cache_control.empty() && cache_control[0] != ',') { + // Unexpected text at end of directive + return SystemTime::duration::zero(); + } + } else if (!found_s_maxage && ConsumePrefix(&cache_control, "max-age=")) { + max_age = eatLeadingDuration(cache_control); + if (!cache_control.empty() && cache_control[0] != ',') { + // Unexpected text at end of directive + return SystemTime::duration::zero(); + } + } else if (eatToken(cache_control)) { + // Unknown directive--ignore. + if (ConsumePrefix(&cache_control, "=")) { + eatDirectiveArgument(cache_control); + } + } else { + // This directive starts with illegal characters. Require validation. + return SystemTime::duration::zero(); + } + // Whichever branch we took should have consumed the entire cache-directive, + // so we just need to eat the delimiter and optional whitespace. + ConsumePrefix(&cache_control, ","); + cache_control = StripLeadingAsciiWhitespace(cache_control); + } + return max_age; +} + +SystemTime Utils::httpTime(const Http::HeaderEntry* header_entry) { + if (!header_entry) { + return {}; + } + absl::Time time; + const std::string input(header_entry->value().getStringView()); + + // Acceptable Date/Time Formats per + // https://tools.ietf.org/html/rfc7231#section-7.1.1.1 + // + // Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate + // Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format + // Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format + static const auto& rfc7231_date_formats = *new std::array{ + "%a, %d %b %Y %H:%M:%S GMT", "%A, %d-%b-%y %H:%M:%S GMT", "%a %b %e %H:%M:%S %Y"}; + for (const std::string& format : rfc7231_date_formats) { + if (absl::ParseTime(format, input, &time, nullptr)) { + return ToChronoTime(time); + } + } + return {}; +} +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/http_cache_utils.h b/source/extensions/filters/http/cache/http_cache_utils.h new file mode 100644 index 0000000000000..d62599b8f5bb1 --- /dev/null +++ b/source/extensions/filters/http/cache/http_cache_utils.h @@ -0,0 +1,34 @@ +#pragma once + +#include "envoy/common/time.h" +#include "envoy/http/header_map.h" + +#include "absl/strings/string_view.h" +#include "absl/time/time.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +class Utils { +public: + // Parses and returns max-age or s-maxage (with s-maxage taking precedence), + // parsed into a SystemTime::Duration. Returns SystemTime::Duration::zero if + // neither is present, or there is a no-cache directive, or if max-age or + // s-maxage is malformed. + static SystemTime::duration effectiveMaxAge(absl::string_view cache_control); + + // Parses header_entry as an HTTP time. Returns SystemTime() if + // header_entry is null or malformed. + static SystemTime httpTime(const Http::HeaderEntry* header_entry); + +private: + static bool tchar(char c); + static bool eatToken(absl::string_view& s); + static void eatDirectiveArgument(absl::string_view& s); + static SystemTime::duration eatLeadingDuration(absl::string_view& s); +}; +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/BUILD b/test/extensions/filters/http/cache/BUILD new file mode 100644 index 0000000000000..449518f7ba59e --- /dev/null +++ b/test/extensions/filters/http/cache/BUILD @@ -0,0 +1,17 @@ +licenses(["notice"]) # Apache 2 + +load("//bazel:envoy_build_system.bzl", "envoy_cc_test", "envoy_package") + +envoy_package() + +# TODO(toddmgreer) Change to envoy_extension_cc_test when adding the full filter. +envoy_cc_test( + name = "http_cache_utils_test", + srcs = ["http_cache_utils_test.cc"], + deps = [ + "//include/envoy/http:header_map_interface", + "//source/common/http:header_map_lib", + "//source/extensions/filters/http/cache:http_cache_utils_lib", + "//test/test_common:utility_lib", + ], +) diff --git a/test/extensions/filters/http/cache/http_cache_utils_test.cc b/test/extensions/filters/http/cache/http_cache_utils_test.cc new file mode 100644 index 0000000000000..6a25808a55575 --- /dev/null +++ b/test/extensions/filters/http/cache/http_cache_utils_test.cc @@ -0,0 +1,81 @@ +#include + +#include "common/http/header_map_impl.h" + +#include "extensions/filters/http/cache/http_cache_utils.h" + +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace { + +// TODO(#9872): Add tests for eat* functions +// TODO(#9872): More tests for httpTime, effectiveMaxAge + +class HttpTimeTest : public testing::TestWithParam {}; + +const char* const ok_times[] = { + "Sun, 06 Nov 1994 08:49:37 GMT", // IMF-fixdate + "Sunday, 06-Nov-94 08:49:37 GMT", // obsolete RFC 850 format + "Sun Nov 6 08:49:37 1994" // ANSI C's asctime() format +}; + +INSTANTIATE_TEST_SUITE_P(Ok, HttpTimeTest, testing::ValuesIn(ok_times)); + +TEST_P(HttpTimeTest, Ok) { + Http::TestHeaderMapImpl response_headers{{"date", GetParam()}}; + // Manually confirmed that 784111777 is 11/6/94, 8:46:37. + EXPECT_EQ(784111777, SystemTime::clock::to_time_t(Utils::httpTime(response_headers.Date()))); +} + +TEST(HttpTime, Null) { EXPECT_EQ(Utils::httpTime(nullptr), SystemTime()); } + +struct EffectiveMaxAgeParams { + absl::string_view cache_control; + int effective_max_age_secs; +}; + +EffectiveMaxAgeParams params[] = { + {"public, max-age=3600", 3600}, + {"public, max-age=-1", 0}, + {"max-age=20", 20}, + {"max-age=86400, public", 86400}, + {"public,max-age=\"0\"", 0}, + {"public,max-age=8", 8}, + {"public,max-age=3,no-cache", 0}, + {"s-maxage=0", 0}, + {"max-age=10,s-maxage=0", 0}, + {"s-maxage=10", 10}, + {"no-cache", 0}, + {"max-age=0", 0}, + {"no-cache", 0}, + {"public", 0}, + // TODO(#9833): parse quoted forms + // {"max-age=20, s-maxage=\"25\"",25}, + // {"public,max-age=\"8\",foo=11",8}, + // {"public,max-age=\"8\",bar=\"11\"",8}, + // TODO(#9833): parse public/private + // {"private,max-age=10",0} + // {"private",0}, + // {"private,s-maxage=8",0}, +}; + +class EffectiveMaxAgeTest : public testing::TestWithParam {}; + +INSTANTIATE_TEST_SUITE_P(EffectiveMaxAgeTest, EffectiveMaxAgeTest, testing::ValuesIn(params)); + +TEST_P(EffectiveMaxAgeTest, EffectiveMaxAgeTest) { + EXPECT_EQ(Utils::effectiveMaxAge(GetParam().cache_control), + std::chrono::seconds(GetParam().effective_max_age_secs)); +} + +} // namespace +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/tools/spelling_dictionary.txt b/tools/spelling_dictionary.txt index f7f36268c350b..aafa29d7f61f3 100644 --- a/tools/spelling_dictionary.txt +++ b/tools/spelling_dictionary.txt @@ -58,6 +58,7 @@ DFATAL DGRAM DLOG DNS +DQUOTE DRYs DS DST @@ -121,6 +122,7 @@ HCM HDS HMAC HPACK +HTAB HTML HTTP HTTPS @@ -306,6 +308,7 @@ UTF UUID UUIDs VC +VCHAR VH VHDS VLOG @@ -354,6 +357,7 @@ args argv artisanal ary +asctime async atoi atomicity @@ -561,6 +565,7 @@ filenames fileno filesystem firefox +fixdate fixup fld fmt @@ -703,6 +708,7 @@ malloc matchable matcher matchers +maxage maxbuffer megamiss mem @@ -849,6 +855,7 @@ pts pubkey pwd py +qdtext qps quantile quantiles @@ -1018,6 +1025,8 @@ syscalls sysctl sz tbl +tchar +tchars tcmalloc tcpdump teardown