From 86bdfa1edf21293e2c0f3ca8b31808a21882cb99 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Mon, 12 Apr 2021 23:16:27 -0400 Subject: [PATCH] Browser: Implement spec-compliant cookie storage https://tools.ietf.org/html/rfc6265#section-5.3 This includes a bit of an update to how cookies are first parsed. The storage spec requires some extra information from the parsing steps than just the actual values that were parsed. For example, it needs to know whether Max-Age or Expires (or both) were specified to give precedence to Max-Age. To accommodate this, the parser now uses an intermediate struct for storing this information. The final Cookie struct is not created until the storage steps. The storage itself is also updated to be keyed by a combo of the cookie name, domain, and path. Retrieving cookies was updated to use the spec's domain-matching algorithm, but otherwise is not written to the spec yet. This also does not handle evicting expired cookies yet. --- Userland/Applications/Browser/CookieJar.cpp | 238 ++++++++++++++------ Userland/Applications/Browser/CookieJar.h | 55 ++++- 2 files changed, 216 insertions(+), 77 deletions(-) diff --git a/Userland/Applications/Browser/CookieJar.cpp b/Userland/Applications/Browser/CookieJar.cpp index 9ddbc9fce9..577233aa11 100644 --- a/Userland/Applications/Browser/CookieJar.cpp +++ b/Userland/Applications/Browser/CookieJar.cpp @@ -26,12 +26,25 @@ #include "CookieJar.h" #include +#include #include #include +#include #include namespace Browser { +struct ParsedCookie { + String name; + String value; + Optional expiry_time_from_expires_attribute {}; + Optional expiry_time_from_max_age_attribute {}; + Optional domain {}; + Optional path {}; + bool secure_attribute_present { false }; + bool http_only_attribute_present { false }; +}; + String CookieJar::get_cookie(const URL& url) const { auto domain = canonicalize_domain(url); @@ -40,12 +53,13 @@ String CookieJar::get_cookie(const URL& url) const StringBuilder builder; - if (auto it = m_cookies.find(*domain); it != m_cookies.end()) { - for (const auto& cookie : it->value) { - if (!builder.is_empty()) - builder.append("; "); - builder.appendff("{}={}", cookie.name, cookie.value); - } + for (const auto& cookie : m_cookies) { + if (!domain_matches(domain.value(), cookie.value.domain)) + continue; + + if (!builder.is_empty()) + builder.append("; "); + builder.appendff("{}={}", cookie.value.name, cookie.value.value); } return builder.build(); @@ -57,47 +71,35 @@ void CookieJar::set_cookie(const URL& url, const String& cookie_string) if (!domain.has_value()) return; - auto new_cookie = parse_cookie(cookie_string, *domain, default_path(url)); - if (!new_cookie.has_value()) + auto parsed_cookie = parse_cookie(cookie_string); + if (!parsed_cookie.has_value()) return; - auto it = m_cookies.find(*domain); - if (it == m_cookies.end()) { - m_cookies.set(*domain, { move(*new_cookie) }); - return; - } - - for (auto& cookie : it->value) { - if (cookie.name == new_cookie->name) { - cookie = move(*new_cookie); - return; - } - } - - it->value.append(move(*new_cookie)); + store_cookie(parsed_cookie.value(), url, move(domain.value())); } void CookieJar::dump_cookies() const { - static const char* url_color = "\033[34;1m"; - static const char* cookie_color = "\033[31m"; + static const char* key_color = "\033[34;1m"; static const char* attribute_color = "\033[33m"; static const char* no_color = "\033[0m"; StringBuilder builder; - builder.appendff("{} URLs with cookies\n", m_cookies.size()); + builder.appendff("{} cookies stored\n", m_cookies.size()); - for (const auto& url_and_cookies : m_cookies) { - builder.appendff("{}Cookies for:{} {}\n", url_color, no_color, url_and_cookies.key.is_empty() ? "file://" : url_and_cookies.key); + for (const auto& cookie : m_cookies) { + builder.appendff("{}{}{} - ", key_color, cookie.key.name, no_color); + builder.appendff("{}{}{} - ", key_color, cookie.key.domain, no_color); + builder.appendff("{}{}{}\n", key_color, cookie.key.path, no_color); - for (const auto& cookie : url_and_cookies.value) { - builder.appendff("\t{}{}{} = {}{}{}\n", cookie_color, cookie.name, no_color, cookie_color, cookie.value, no_color); - builder.appendff("\t\t{}Expiry{} = {}\n", attribute_color, no_color, cookie.expiry_time.to_string()); - builder.appendff("\t\t{}Domain{} = {}\n", attribute_color, no_color, cookie.domain); - builder.appendff("\t\t{}Path{} = {}\n", attribute_color, no_color, cookie.path); - builder.appendff("\t\t{}Secure{} = {:s}\n", attribute_color, no_color, cookie.secure); - builder.appendff("\t\t{}HttpOnly{} = {:s}\n", attribute_color, no_color, cookie.http_only); - } + builder.appendff("\t{}Value{} = {}\n", attribute_color, no_color, cookie.value.value); + builder.appendff("\t{}CreationTime{} = {}\n", attribute_color, no_color, cookie.value.creation_time.to_string()); + builder.appendff("\t{}LastAccessTime{} = {}\n", attribute_color, no_color, cookie.value.last_access_time.to_string()); + builder.appendff("\t{}ExpiryTime{} = {}\n", attribute_color, no_color, cookie.value.expiry_time.to_string()); + builder.appendff("\t{}Secure{} = {:s}\n", attribute_color, no_color, cookie.value.secure); + builder.appendff("\t{}HttpOnly{} = {:s}\n", attribute_color, no_color, cookie.value.http_only); + builder.appendff("\t{}HostOnly{} = {:s}\n", attribute_color, no_color, cookie.value.host_only); + builder.appendff("\t{}Persistent{} = {:s}\n", attribute_color, no_color, cookie.value.persistent); } dbgln("{}", builder.build()); @@ -135,7 +137,7 @@ String CookieJar::default_path(const URL& url) return uri_path.substring(0, last_separator); } -Optional CookieJar::parse_cookie(const String& cookie_string, String default_domain, String default_path) +Optional CookieJar::parse_cookie(const String& cookie_string) { // https://tools.ietf.org/html/rfc6265#section-5.2 StringView name_value_pair; @@ -177,17 +179,13 @@ Optional CookieJar::parse_cookie(const String& cookie_string, String def return {}; // 6. The cookie-name is the name string, and the cookie-value is the value string. - Cookie cookie { name, value }; + ParsedCookie parsed_cookie { name, value }; - cookie.expiry_time = Core::DateTime::create(9999, 12, 31, 23, 59, 59); - cookie.domain = move(default_domain); - cookie.path = move(default_path); - - parse_attributes(cookie, unparsed_attributes); - return cookie; + parse_attributes(parsed_cookie, unparsed_attributes); + return parsed_cookie; } -void CookieJar::parse_attributes(Cookie& cookie, StringView unparsed_attributes) +void CookieJar::parse_attributes(ParsedCookie& parsed_cookie, StringView unparsed_attributes) { // 1. If the unparsed-attributes string is empty, skip the rest of these steps. if (unparsed_attributes.is_empty()) @@ -231,37 +229,37 @@ void CookieJar::parse_attributes(Cookie& cookie, StringView unparsed_attributes) // 6. Process the attribute-name and attribute-value according to the requirements in the following subsections. // (Notice that attributes with unrecognized attribute-names are ignored.) - process_attribute(cookie, attribute_name, attribute_value); + process_attribute(parsed_cookie, attribute_name, attribute_value); // 7. Return to Step 1 of this algorithm. - parse_attributes(cookie, unparsed_attributes); + parse_attributes(parsed_cookie, unparsed_attributes); } -void CookieJar::process_attribute(Cookie& cookie, StringView attribute_name, StringView attribute_value) +void CookieJar::process_attribute(ParsedCookie& parsed_cookie, StringView attribute_name, StringView attribute_value) { if (attribute_name.equals_ignoring_case("Expires")) { - on_expires_attribute(cookie, attribute_value); + on_expires_attribute(parsed_cookie, attribute_value); } else if (attribute_name.equals_ignoring_case("Max-Age")) { - on_max_age_attribute(cookie, attribute_value); + on_max_age_attribute(parsed_cookie, attribute_value); } else if (attribute_name.equals_ignoring_case("Domain")) { - on_domain_attribute(cookie, attribute_value); + on_domain_attribute(parsed_cookie, attribute_value); } else if (attribute_name.equals_ignoring_case("Path")) { - on_path_attribute(cookie, attribute_value); + on_path_attribute(parsed_cookie, attribute_value); } else if (attribute_name.equals_ignoring_case("Secure")) { - on_secure_attribute(cookie); + on_secure_attribute(parsed_cookie); } else if (attribute_name.equals_ignoring_case("HttpOnly")) { - on_http_only_attribute(cookie); + on_http_only_attribute(parsed_cookie); } } -void CookieJar::on_expires_attribute(Cookie& cookie, StringView attribute_value) +void CookieJar::on_expires_attribute(ParsedCookie& parsed_cookie, StringView attribute_value) { // https://tools.ietf.org/html/rfc6265#section-5.2.1 if (auto expiry_time = parse_date_time(attribute_value); expiry_time.has_value()) - cookie.expiry_time = *expiry_time; + parsed_cookie.expiry_time_from_expires_attribute = move(*expiry_time); } -void CookieJar::on_max_age_attribute(Cookie& cookie, StringView attribute_value) +void CookieJar::on_max_age_attribute(ParsedCookie& parsed_cookie, StringView attribute_value) { // https://tools.ietf.org/html/rfc6265#section-5.2.2 @@ -275,16 +273,16 @@ void CookieJar::on_max_age_attribute(Cookie& cookie, StringView attribute_value) if (*delta_seconds <= 0) { // If delta-seconds is less than or equal to zero (0), let expiry-time be the earliest representable date and time. - cookie.expiry_time = Core::DateTime::from_timestamp(0); + parsed_cookie.expiry_time_from_max_age_attribute = Core::DateTime::from_timestamp(0); } else { // Otherwise, let the expiry-time be the current date and time plus delta-seconds seconds. time_t now = Core::DateTime::now().timestamp(); - cookie.expiry_time = Core::DateTime::from_timestamp(now + *delta_seconds); + parsed_cookie.expiry_time_from_max_age_attribute = Core::DateTime::from_timestamp(now + *delta_seconds); } } } -void CookieJar::on_domain_attribute(Cookie& cookie, StringView attribute_value) +void CookieJar::on_domain_attribute(ParsedCookie& parsed_cookie, StringView attribute_value) { // https://tools.ietf.org/html/rfc6265#section-5.2.3 @@ -304,10 +302,10 @@ void CookieJar::on_domain_attribute(Cookie& cookie, StringView attribute_value) } // Convert the cookie-domain to lower case. - cookie.domain = String(cookie_domain).to_lowercase(); + parsed_cookie.domain = String(cookie_domain).to_lowercase(); } -void CookieJar::on_path_attribute(Cookie& cookie, StringView attribute_value) +void CookieJar::on_path_attribute(ParsedCookie& parsed_cookie, StringView attribute_value) { // https://tools.ietf.org/html/rfc6265#section-5.2.4 @@ -317,19 +315,19 @@ void CookieJar::on_path_attribute(Cookie& cookie, StringView attribute_value) return; // Let cookie-path be the attribute-value - cookie.path = attribute_value; + parsed_cookie.path = attribute_value; } -void CookieJar::on_secure_attribute(Cookie& cookie) +void CookieJar::on_secure_attribute(ParsedCookie& parsed_cookie) { // https://tools.ietf.org/html/rfc6265#section-5.2.5 - cookie.secure = true; + parsed_cookie.secure_attribute_present = true; } -void CookieJar::on_http_only_attribute(Cookie& cookie) +void CookieJar::on_http_only_attribute(ParsedCookie& parsed_cookie) { // https://tools.ietf.org/html/rfc6265#section-5.2.6 - cookie.http_only = true; + parsed_cookie.http_only_attribute_present = true; } Optional CookieJar::parse_date_time(StringView date_string) @@ -443,4 +441,112 @@ Optional CookieJar::parse_date_time(StringView date_string) return Core::DateTime::create(year, month, day_of_month, hour, minute, second); } +bool CookieJar::domain_matches(const String& string, const String& domain_string) +{ + // https://tools.ietf.org/html/rfc6265#section-5.1.3 + + // A string domain-matches a given domain string if at least one of the following conditions hold: + + // The domain string and the string are identical. + if (string == domain_string) + return true; + + // All of the following conditions hold: + // - The domain string is a suffix of the string. + // - The last character of the string that is not included in the domain string is a %x2E (".") character. + // - The string is a host name (i.e., not an IP address). + if (!string.ends_with(domain_string)) + return false; + if (string[string.length() - domain_string.length() - 1] != '.') + return false; + if (AK::IPv4Address::from_string(string).has_value()) + return false; + + return true; +} + +void CookieJar::store_cookie(ParsedCookie& parsed_cookie, const URL& url, String canonicalized_domain) +{ + // https://tools.ietf.org/html/rfc6265#section-5.3 + + // 2. Create a new cookie with name cookie-name, value cookie-value. Set the creation-time and the last-access-time to the current date and time. + Cookie cookie { move(parsed_cookie.name), move(parsed_cookie.value) }; + cookie.creation_time = Core::DateTime::now(); + cookie.last_access_time = cookie.creation_time; + + if (parsed_cookie.expiry_time_from_max_age_attribute.has_value()) { + // 3. If the cookie-attribute-list contains an attribute with an attribute-name of "Max-Age": Set the cookie's persistent-flag to true. + // Set the cookie's expiry-time to attribute-value of the last attribute in the cookie-attribute-list with an attribute-name of "Max-Age". + cookie.persistent = true; + cookie.expiry_time = move(parsed_cookie.expiry_time_from_max_age_attribute.value()); + } else if (parsed_cookie.expiry_time_from_expires_attribute.has_value()) { + // If the cookie-attribute-list contains an attribute with an attribute-name of "Expires": Set the cookie's persistent-flag to true. + // Set the cookie's expiry-time to attribute-value of the last attribute in the cookie-attribute-list with an attribute-name of "Expires". + cookie.persistent = true; + cookie.expiry_time = move(parsed_cookie.expiry_time_from_expires_attribute.value()); + } else { + // Set the cookie's persistent-flag to false. Set the cookie's expiry-time to the latest representable gddate. + cookie.persistent = false; + cookie.expiry_time = Core::DateTime::create(9999, 12, 31, 23, 59, 59); + } + + // 4. If the cookie-attribute-list contains an attribute with an attribute-name of "Domain": + if (parsed_cookie.domain.has_value()) { + // Let the domain-attribute be the attribute-value of the last attribute in the cookie-attribute-list with an attribute-name of "Domain". + cookie.domain = move(parsed_cookie.domain.value()); + } + + // 5. If the user agent is configured to reject "public suffixes" and the domain-attribute is a public suffix: + // FIXME: Support rejection of public suffixes. The full list is here: https://publicsuffix.org/list/public_suffix_list.dat + + // 6. If the domain-attribute is non-empty: + if (!cookie.domain.is_empty()) { + // If the canonicalized request-host does not domain-match the domain-attribute: Ignore the cookie entirely and abort these steps. + if (!domain_matches(canonicalized_domain, cookie.domain)) + return; + + // Set the cookie's host-only-flag to false. Set the cookie's domain to the domain-attribute. + cookie.host_only = false; + } else { + // Set the cookie's host-only-flag to true. Set the cookie's domain to the canonicalized request-host. + cookie.host_only = true; + cookie.domain = move(canonicalized_domain); + } + + // 7. If the cookie-attribute-list contains an attribute with an attribute-name of "Path": + if (parsed_cookie.path.has_value()) { + // Set the cookie's path to attribute-value of the last attribute in the cookie-attribute-list with an attribute-name of "Path". + cookie.path = move(parsed_cookie.path.value()); + } else { + cookie.path = default_path(url); + } + + // 8. If the cookie-attribute-list contains an attribute with an attribute-name of "Secure", set the cookie's secure-only-flag to true. + cookie.secure = parsed_cookie.secure_attribute_present; + + // 9. If the cookie-attribute-list contains an attribute with an attribute-name of "HttpOnly", set the cookie's http-only-flag to false. + cookie.http_only = parsed_cookie.http_only_attribute_present; + + // 10. If the cookie was received from a "non-HTTP" API and the cookie's http-only-flag is set, abort these steps and ignore the cookie entirely. + // FIXME: Update CookieJar to track where the cookie originated (an HTTP request vs document.cookie). + + // 11. If the cookie store contains a cookie with the same name, domain, and path as the newly created cookie: + CookieStorageKey key { cookie.name, cookie.domain, cookie.path }; + + if (auto old_cookie = m_cookies.find(key); old_cookie != m_cookies.end()) { + // If the newly created cookie was received from a "non-HTTP" API and the old-cookie's http-only-flag is set, abort these + // steps and ignore the newly created cookie entirely. + // FIXME: Similar to step 10, CookieJar needs to track where the cookie originated. + + // Update the creation-time of the newly created cookie to match the creation-time of the old-cookie. + cookie.creation_time = old_cookie->value.creation_time; + + // Remove the old-cookie from the cookie store. + m_cookies.remove(old_cookie); + } + + // 12. Insert the newly created cookie into the cookie store. + m_cookies.set(key, move(cookie)); +} + } diff --git a/Userland/Applications/Browser/CookieJar.h b/Userland/Applications/Browser/CookieJar.h index 6380b6d4f9..2576266b2f 100644 --- a/Userland/Applications/Browser/CookieJar.h +++ b/Userland/Applications/Browser/CookieJar.h @@ -29,7 +29,7 @@ #include #include #include -#include +#include #include namespace Browser { @@ -37,11 +37,25 @@ namespace Browser { struct Cookie { String name; String value; + Core::DateTime creation_time {}; + Core::DateTime last_access_time {}; Core::DateTime expiry_time {}; String domain {}; String path {}; bool secure { false }; bool http_only { false }; + bool host_only { false }; + bool persistent { false }; +}; + +struct ParsedCookie; + +struct CookieStorageKey { + bool operator==(const CookieStorageKey&) const = default; + + String name; + String domain; + String path; }; class CookieJar { @@ -53,18 +67,37 @@ public: private: static Optional canonicalize_domain(const URL& url); static String default_path(const URL& url); - static Optional parse_cookie(const String& cookie_string, String default_domain, String default_path); - static void parse_attributes(Cookie& cookie, StringView unparsed_attributes); - static void process_attribute(Cookie& cookie, StringView attribute_name, StringView attribute_value); - static void on_expires_attribute(Cookie& cookie, StringView attribute_value); - static void on_max_age_attribute(Cookie& cookie, StringView attribute_value); - static void on_domain_attribute(Cookie& cookie, StringView attribute_value); - static void on_path_attribute(Cookie& cookie, StringView attribute_value); - static void on_secure_attribute(Cookie& cookie); - static void on_http_only_attribute(Cookie& cookie); + static Optional parse_cookie(const String& cookie_string); + static void parse_attributes(ParsedCookie& parsed_cookie, StringView unparsed_attributes); + static void process_attribute(ParsedCookie& parsed_cookie, StringView attribute_name, StringView attribute_value); + static void on_expires_attribute(ParsedCookie& parsed_cookie, StringView attribute_value); + static void on_max_age_attribute(ParsedCookie& parsed_cookie, StringView attribute_value); + static void on_domain_attribute(ParsedCookie& parsed_cookie, StringView attribute_value); + static void on_path_attribute(ParsedCookie& parsed_cookie, StringView attribute_value); + static void on_secure_attribute(ParsedCookie& parsed_cookie); + static void on_http_only_attribute(ParsedCookie& parsed_cookie); static Optional parse_date_time(StringView date_string); + static bool domain_matches(const String& string, const String& domain_string); - HashMap> m_cookies; + void store_cookie(ParsedCookie& parsed_cookie, const URL& url, String canonicalized_domain); + + HashMap m_cookies; +}; + +} + +namespace AK { + +template<> +struct Traits : public GenericTraits { + static unsigned hash(const Browser::CookieStorageKey& key) + { + unsigned hash = 0; + hash = pair_int_hash(hash, string_hash(key.name.characters(), key.name.length())); + hash = pair_int_hash(hash, string_hash(key.domain.characters(), key.domain.length())); + hash = pair_int_hash(hash, string_hash(key.path.characters(), key.path.length())); + return hash; + } }; }