/* * Copyright (c) 2025, Idan Horowitz * * SPDX-License-Identifier: BSD-2-Clause */ #include #include #include #include #include #include #include #include #include #include #include #include #include namespace Web::CookieStore { GC_DEFINE_ALLOCATOR(CookieStore); CookieStore::CookieStore(JS::Realm& realm, PageClient& client) : DOM::EventTarget(realm) , m_client(client) { } void CookieStore::initialize(JS::Realm& realm) { WEB_SET_PROTOTYPE_FOR_INTERFACE(CookieStore); Base::initialize(realm); } void CookieStore::visit_edges(Cell::Visitor& visitor) { Base::visit_edges(visitor); visitor.visit(m_client); } // https://cookiestore.spec.whatwg.org/#create-a-cookielistitem static CookieListItem create_a_cookie_list_item(Cookie::Cookie const& cookie) { // 1. Let name be the result of running UTF-8 decode without BOM on cookie’s name. // 2. Let value be the result of running UTF-8 decode without BOM on cookie’s value. // 3. Return «[ "name" → name, "value" → value ]» return CookieListItem { .name = cookie.name, .value = cookie.value, }; } // https://cookiestore.spec.whatwg.org/#query-cookies static Vector query_cookies(PageClient& client, URL::URL const& url, Optional const& name) { // 1. Perform the steps defined in Cookies § Retrieval Model to compute the "cookie-string from a given cookie store" // with url as request-uri. The cookie-string itself is ignored, but the intermediate cookie-list is used in subsequent steps. // For the purposes of the steps, the cookie-string is being generated for a "non-HTTP" API. auto cookie_list = client.page_did_request_all_cookies_cookiestore(url); // 2. Let list be a new list. Vector list; // 3. For each cookie in cookie-list, run these steps: for (auto const& cookie : cookie_list) { // 1. Assert: cookie’s http-only-flag is false. VERIFY(!cookie.http_only); // 2. If name is given, then run these steps: if (name.has_value()) { // 1. Let cookieName be the result of running UTF-8 decode without BOM on cookie’s name. // 2. If cookieName does not equal name, then continue. if (cookie.name != name.value()) continue; } // 3. Let item be the result of running create a CookieListItem from cookie. auto item = create_a_cookie_list_item(cookie); // 4. Append item to list. list.append(move(item)); } // 4. Return list. return list; } // https://cookiestore.spec.whatwg.org/#dom-cookiestore-get GC::Ref CookieStore::get(String name) { auto& realm = this->realm(); // 1. Let settings be this’s relevant settings object. auto const& settings = HTML::relevant_settings_object(*this); // 2. Let origin be settings’s origin. auto const& origin = settings.origin(); // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException. if (origin.is_opaque()) return WebIDL::create_rejected_promise(realm, WebIDL::SecurityError::create(realm, "Document origin is opaque"_string)); // 4. Let url be settings’s creation URL. auto url = settings.creation_url; // 5. Let p be a new promise. auto promise = WebIDL::create_promise(realm); // 6. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, client = m_client, promise, url = move(url), name = move(name)]() { // 1. Let list be the results of running query cookies with url and name. auto list = query_cookies(client, url, name); // AD-HOC: Queue a global task to perform the next steps // Spec issue: https://github.com/whatwg/cookiestore/issues/239 queue_global_task(HTML::Task::Source::Unspecified, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, list = move(list)]() { HTML::TemporaryExecutionContext execution_context { realm }; // 2. If list is failure, then reject p with a TypeError and abort these steps. // 3. If list is empty, then resolve p with null. if (list.is_empty()) WebIDL::resolve_promise(realm, promise, JS::js_null()); // 4. Otherwise, resolve p with the first item of list. else WebIDL::resolve_promise(realm, promise, Bindings::cookie_list_item_to_value(realm, list[0])); })); })); // 7. Return p. return promise; } // https://cookiestore.spec.whatwg.org/#dom-cookiestore-get-options GC::Ref CookieStore::get(CookieStoreGetOptions const& options) { auto& realm = this->realm(); // 1. Let settings be this’s relevant settings object. auto const& settings = HTML::relevant_settings_object(*this); // 2. Let origin be settings’s origin. auto const& origin = settings.origin(); // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException. if (origin.is_opaque()) return WebIDL::create_rejected_promise(realm, WebIDL::SecurityError::create(realm, "Document origin is opaque"_string)); // 4. Let url be settings’s creation URL. auto url = settings.creation_url; // 5. If options is empty, then return a promise rejected with a TypeError. if (!options.name.has_value() && !options.url.has_value()) return WebIDL::create_rejected_promise(realm, JS::TypeError::create(realm, "CookieStoreGetOptions is empty"sv)); // 6. If options["url"] is present, then run these steps: if (options.url.has_value()) { // 1. Let parsed be the result of parsing options["url"] with settings’s API base URL. auto parsed = URL::Parser::basic_parse(options.url.value(), settings.api_base_url()); // AD-HOC: This isn't explicitly mentioned in the specification, but we have to reject invalid URLs as well if (!parsed.has_value()) return WebIDL::create_rejected_promise(realm, JS::TypeError::create(realm, "url is invalid"sv)); // 2. If this’s relevant global object is a Window object and parsed does not equal url with exclude fragments // set to true, then return a promise rejected with a TypeError. if (is(HTML::relevant_global_object(*this)) && !parsed->equals(url, URL::ExcludeFragment::Yes)) return WebIDL::create_rejected_promise(realm, JS::TypeError::create(realm, "url does not match creation URL"sv)); // 3. If parsed’s origin and url’s origin are not the same origin, then return a promise rejected with a TypeError. if (parsed->origin() != url.origin()) return WebIDL::create_rejected_promise(realm, JS::TypeError::create(realm, "url's origin does not match creation URL's origin"sv)); // 4. Set url to parsed. url = parsed.value(); } // 7. Let p be a new promise. auto promise = WebIDL::create_promise(realm); // 8. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, client = m_client, promise, url = move(url), name = options.name]() { // 1. Let list be the results of running query cookies with url and options["name"] (if present). auto list = query_cookies(client, url, name); // AD-HOC: Queue a global task to perform the next steps // Spec issue: https://github.com/whatwg/cookiestore/issues/239 queue_global_task(HTML::Task::Source::Unspecified, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, list = move(list)]() { HTML::TemporaryExecutionContext execution_context { realm }; // 2. If list is failure, then reject p with a TypeError and abort these steps. // 3. If list is empty, then resolve p with null. if (list.is_empty()) WebIDL::resolve_promise(realm, promise, JS::js_null()); // 4. Otherwise, resolve p with the first item of list. else WebIDL::resolve_promise(realm, promise, Bindings::cookie_list_item_to_value(realm, list[0])); })); })); // 9. Return p. return promise; } static JS::Value cookie_list_to_value(JS::Realm& realm, Vector const& cookie_list) { return JS::Array::create_from(realm, cookie_list, [&](auto const& cookie) { return Bindings::cookie_list_item_to_value(realm, cookie); }); } // https://cookiestore.spec.whatwg.org/#dom-cookiestore-getall GC::Ref CookieStore::get_all(String name) { auto& realm = this->realm(); // 1. Let settings be this’s relevant settings object. auto const& settings = HTML::relevant_settings_object(*this); // 2. Let origin be settings’s origin. auto const& origin = settings.origin(); // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException. if (origin.is_opaque()) return WebIDL::create_rejected_promise(realm, WebIDL::SecurityError::create(realm, "Document origin is opaque"_string)); // 4. Let url be settings’s creation URL. auto url = settings.creation_url; // 5. Let p be a new promise. auto promise = WebIDL::create_promise(realm); // 6. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, client = m_client, promise, url = move(url), name = move(name)]() { // 1. Let list be the results of running query cookies with url and name. auto list = query_cookies(client, url, name); // AD-HOC: Queue a global task to perform the next steps // Spec issue: https://github.com/whatwg/cookiestore/issues/239 queue_global_task(HTML::Task::Source::Unspecified, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, list = move(list)]() { HTML::TemporaryExecutionContext execution_context { realm }; // 2. If list is failure, then reject p with a TypeError and abort these steps. // 3. Otherwise, resolve p with list. WebIDL::resolve_promise(realm, promise, cookie_list_to_value(realm, list)); })); })); // 7. Return p. return promise; } // https://cookiestore.spec.whatwg.org/#dom-cookiestore-getall-options GC::Ref CookieStore::get_all(CookieStoreGetOptions const& options) { auto& realm = this->realm(); // 1. Let settings be this’s relevant settings object. auto const& settings = HTML::relevant_settings_object(*this); // 2. Let origin be settings’s origin. auto const& origin = settings.origin(); // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException. if (origin.is_opaque()) return WebIDL::create_rejected_promise(realm, WebIDL::SecurityError::create(realm, "Document origin is opaque"_string)); // 4. Let url be settings’s creation URL. auto url = settings.creation_url; // 5. If options["url"] is present, then run these steps: if (options.url.has_value()) { // 1. Let parsed be the result of parsing options["url"] with settings’s API base URL. auto parsed = URL::Parser::basic_parse(options.url.value(), settings.api_base_url()); // AD-HOC: This isn't explicitly mentioned in the specification, but we have to reject invalid URLs as well if (!parsed.has_value()) return WebIDL::create_rejected_promise(realm, JS::TypeError::create(realm, "url is invalid"sv)); // 2. If this’s relevant global object is a Window object and parsed does not equal url with exclude fragments // set to true, then return a promise rejected with a TypeError. if (is(HTML::relevant_global_object(*this)) && !parsed->equals(url, URL::ExcludeFragment::Yes)) return WebIDL::create_rejected_promise(realm, JS::TypeError::create(realm, "url does not match creation URL"sv)); // 3. If parsed’s origin and url’s origin are not the same origin, then return a promise rejected with a TypeError. if (parsed->origin() != url.origin()) return WebIDL::create_rejected_promise(realm, JS::TypeError::create(realm, "url's origin does not match creation URL's origin"sv)); // 4. Set url to parsed. url = parsed.value(); } // 6. Let p be a new promise. auto promise = WebIDL::create_promise(realm); // 7. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, client = m_client, promise, url = move(url), name = options.name]() { // 1. Let list be the results of running query cookies with url and options["name"] (if present). auto list = query_cookies(client, url, name); // AD-HOC: Queue a global task to perform the next steps // Spec issue: https://github.com/whatwg/cookiestore/issues/239 queue_global_task(HTML::Task::Source::Unspecified, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, list = move(list)]() { HTML::TemporaryExecutionContext execution_context { realm }; // 2. If list is failure, then reject p with a TypeError and abort these steps. // 3. Otherwise, resolve p with list. WebIDL::resolve_promise(realm, promise, cookie_list_to_value(realm, list)); })); })); // 8. Return p. return promise; } static constexpr size_t maximum_name_value_pair_size = 4096; static constexpr size_t maximum_attribute_value_size = 1024; // https://cookiestore.spec.whatwg.org/#set-a-cookie static bool set_a_cookie(PageClient& client, URL::URL const& url, String name, String value, Optional expires, Optional const& domain, Optional const& path, Bindings::CookieSameSite same_site, bool partitioned) { // 1. Remove all U+0009 TAB and U+0020 SPACE that are at the start or end of name. name = MUST(name.trim("\t "sv)); // 2. Remove all U+0009 TAB and U+0020 SPACE that are at the start or end of value. value = MUST(value.trim("\t "sv)); // 3. If name or value contain U+003B (;), any C0 control character except U+0009 TAB, or U+007F DELETE, then return failure. if (name.contains(';') || value.contains(';')) return false; for (auto c = '\x00'; c <= '\x1F'; ++c) { if (c == '\t') continue; if (name.contains(c) || value.contains(c)) return false; } if (name.contains('\x7F') || value.contains('\x7F')) return false; // 4. If name contains U+003D (=), then return failure. if (name.contains('=')) return false; // 5. If name’s length is 0: if (name.is_empty()) { // 1. If value contains U+003D (=), then return failure. if (value.contains('=')) return false; // 2. If value’s length is 0, then return failure. if (value.is_empty()) return false; // 3. If value, byte-lowercased, starts with `__host-`, `__hosthttp-`, `__http-`, or `__secure-`, then return failure. auto value_byte_lowercased = value.to_ascii_lowercase(); if (value_byte_lowercased.starts_with_bytes("__host-"sv) || value_byte_lowercased.starts_with_bytes("__hosthttp-"sv) || value_byte_lowercased.starts_with_bytes("__http-"sv) || value_byte_lowercased.starts_with_bytes("__secure-"sv)) return false; } // 6. If name, byte-lowercased, starts with `__http-` or `__hosthttp-`, then return failure. auto name_byte_lowercased = name.to_ascii_lowercase(); if (name_byte_lowercased.starts_with_bytes("__http-"sv) || name_byte_lowercased.starts_with_bytes("__hosthttp-"sv)) return false; // 7. Let encodedName be the result of UTF-8 encoding name. // 8. Let encodedValue be the result of UTF-8 encoding value. // 9. If the byte sequence length of encodedName plus the byte sequence length of encodedValue is greater than the // maximum name/value pair size, then return failure. if (name.byte_count() + value.byte_count() > maximum_name_value_pair_size) return false; // 10. Let host be url’s host auto const& host = url.host(); // 11. Let attributes be a new list. Cookie::ParsedCookie parsed_cookie {}; parsed_cookie.name = move(name); parsed_cookie.value = move(value); // 12. If domain is not null, then run these steps: if (domain.has_value()) { // 1. If domain starts with U+002E (.), then return failure. if (domain->starts_with('.')) return false; // 2. If name, byte-lowercased, starts with `__host-`, then return failure. if (name_byte_lowercased.starts_with_bytes("__host-"sv)) return false; // 3. If domain is not a registrable domain suffix of and is not equal to host, then return failure. if (!host.has_value() || !DOM::is_a_registrable_domain_suffix_of_or_is_equal_to(domain.value(), host.value())) return false; // 4. Let parsedDomain be the result of host parsing domain. auto parsed_domain = URL::Parser::parse_host(domain.value()); // 5. Assert: parsedDomain is not failure. VERIFY(parsed_domain.has_value()); // 6. Let encodedDomain be the result of UTF-8 encoding parsedDomain. auto encoded_domain = parsed_domain->serialize(); // 7. If the byte sequence length of encodedDomain is greater than the maximum attribute value size, then return failure. if (encoded_domain.byte_count() > maximum_attribute_value_size) return false; // 8. Append `Domain`/encodedDomain to attributes. parsed_cookie.domain = move(encoded_domain); } // 13. If expires is given, then append `Expires`/expires (date serialized) to attributes. if (expires.has_value()) parsed_cookie.expiry_time_from_expires_attribute = UnixDateTime::from_milliseconds_since_epoch(expires.value()); // 14. If path is not null: if (path.has_value()) { // 1. If path does not start with U+002F (/), then return failure. if (!path->starts_with('/')) return false; // 2. If path is not U+002F (/), and name, byte-lowercased, starts with `__host-`, then return failure. if (path != "/"sv && name_byte_lowercased.starts_with_bytes("__host-"sv)) return false; // 3. Let encodedPath be the result of UTF-8 encoding path. // 4. If the byte sequence length of encodedPath is greater than the maximum attribute value size, then return failure. if (path->byte_count() > maximum_attribute_value_size) return false; // 5. Append `Path`/encodedPath to attributes. parsed_cookie.path = path; } // 15. Otherwise, append `Path`/ U+002F (/) to attributes. else { parsed_cookie.path = "/"_string; } // 16. Append `Secure`/`` to attributes. parsed_cookie.secure_attribute_present = true; // 17. Switch on sameSite: switch (same_site) { // -> "none" case Bindings::CookieSameSite::None: // Append `SameSite`/`None` to attributes. parsed_cookie.same_site_attribute = Cookie::SameSite::None; break; // -> "strict" case Bindings::CookieSameSite::Strict: // Append `SameSite`/`Strict` to attributes. parsed_cookie.same_site_attribute = Cookie::SameSite::Strict; break; // -> "lax" case Bindings::CookieSameSite::Lax: // Append `SameSite`/`Lax` to attributes. parsed_cookie.same_site_attribute = Cookie::SameSite::Lax; break; } // FIXME: 18. If partitioned is true, Append `Partitioned`/`` to attributes. (void)partitioned; // 19. Perform the steps defined in Cookies § Storage Model for when the user agent "receives a cookie" with url as // request-uri, encodedName as cookie-name, encodedValue as cookie-value, and attributes as cookie-attribute-list. // For the purposes of the steps, the newly-created cookie was received from a "non-HTTP" API. client.page_did_set_cookie(url, parsed_cookie, Cookie::Source::NonHttp); // 20. Return success. return true; } // https://cookiestore.spec.whatwg.org/#dom-cookiestore-set GC::Ref CookieStore::set(String name, String value) { auto& realm = this->realm(); // 1. Let settings be this’s relevant settings object. auto const& settings = HTML::relevant_settings_object(*this); // 2. Let origin be settings’s origin. auto const& origin = settings.origin(); // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException. if (origin.is_opaque()) return WebIDL::create_rejected_promise(realm, WebIDL::SecurityError::create(realm, "Document origin is opaque"_string)); // 4. Let url be settings’s creation URL. auto url = settings.creation_url; // 5. Let domain be null. // 6. Let path be "/". // 7. Let sameSite be strict. // 8. Let partitioned be false. // 9. Let p be a new promise. auto promise = WebIDL::create_promise(realm); // 10. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, client = m_client, promise, url = move(url), name = move(name), value = move(value)]() { // 1. Let r be the result of running set a cookie with url, name, value, domain, path, sameSite, and partitioned. auto result = set_a_cookie(client, url, move(name), move(value), {}, {}, "/"_string, Bindings::CookieSameSite::Strict, false); // AD-HOC: Queue a global task to perform the next steps // Spec issue: https://github.com/whatwg/cookiestore/issues/239 queue_global_task(HTML::Task::Source::Unspecified, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, result]() { HTML::TemporaryExecutionContext execution_context { realm }; // 2. If r is failure, then reject p with a TypeError and abort these steps. if (!result) return WebIDL::reject_promise(realm, promise, JS::TypeError::create(realm, "Name or value are malformed"sv)); // 3. Resolve p with undefined. WebIDL::resolve_promise(realm, promise); })); })); // 11. Return p. return promise; } // https://cookiestore.spec.whatwg.org/#dom-cookiestore-set-options GC::Ref CookieStore::set(CookieInit const& options) { auto& realm = this->realm(); // 1. Let settings be this’s relevant settings object. auto const& settings = HTML::relevant_settings_object(*this); // 2. Let origin be settings’s origin. auto const& origin = settings.origin(); // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException. if (origin.is_opaque()) return WebIDL::create_rejected_promise(realm, WebIDL::SecurityError::create(realm, "Document origin is opaque"_string)); // 4. Let url be settings’s creation URL. auto url = settings.creation_url; // 5. Let p be a new promise. auto promise = WebIDL::create_promise(realm); // 6. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, client = m_client, promise, url = move(url), options = options]() { // 1. Let r be the result of running set a cookie with url, options["name"], options["value"], options["expires"], // options["domain"], options["path"], options["sameSite"], and options["partitioned"]. auto result = set_a_cookie(client, url, options.name, options.value, options.expires, options.domain, options.path, options.same_site, options.partitioned); // AD-HOC: Queue a global task to perform the next steps // Spec issue: https://github.com/whatwg/cookiestore/issues/239 queue_global_task(HTML::Task::Source::Unspecified, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, result]() { HTML::TemporaryExecutionContext execution_context { realm }; // 2. If r is failure, then reject p with a TypeError and abort these steps. if (!result) return WebIDL::reject_promise(realm, promise, JS::TypeError::create(realm, "Name, value, domain or path are malformed"sv)); // 3. Resolve p with undefined. WebIDL::resolve_promise(realm, promise); })); })); // 7. Return p. return promise; } }