| // Copyright 2017 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "third_party/blink/renderer/modules/cookie_store/cookie_store.h" |
| |
| #include <utility> |
| |
| #include "base/optional.h" |
| #include "net/cookies/canonical_cookie.h" |
| #include "services/network/public/mojom/restricted_cookie_manager.mojom-blink.h" |
| #include "third_party/blink/renderer/bindings/core/v8/script_promise_resolver.h" |
| #include "third_party/blink/renderer/bindings/core/v8/v8_throw_dom_exception.h" |
| #include "third_party/blink/renderer/bindings/modules/v8/v8_cookie_init.h" |
| #include "third_party/blink/renderer/bindings/modules/v8/v8_cookie_list_item.h" |
| #include "third_party/blink/renderer/bindings/modules/v8/v8_cookie_store_delete_options.h" |
| #include "third_party/blink/renderer/bindings/modules/v8/v8_cookie_store_get_options.h" |
| #include "third_party/blink/renderer/core/dom/document.h" |
| #include "third_party/blink/renderer/core/dom/dom_exception.h" |
| #include "third_party/blink/renderer/core/frame/local_dom_window.h" |
| #include "third_party/blink/renderer/modules/cookie_store/cookie_change_event.h" |
| #include "third_party/blink/renderer/modules/event_modules.h" |
| #include "third_party/blink/renderer/modules/event_target_modules.h" |
| #include "third_party/blink/renderer/modules/service_worker/service_worker_global_scope.h" |
| #include "third_party/blink/renderer/modules/service_worker/service_worker_registration.h" |
| #include "third_party/blink/renderer/platform/bindings/exception_state.h" |
| #include "third_party/blink/renderer/platform/bindings/script_state.h" |
| #include "third_party/blink/renderer/platform/heap/handle.h" |
| #include "third_party/blink/renderer/platform/heap/heap.h" |
| #include "third_party/blink/renderer/platform/instrumentation/use_counter.h" |
| #include "third_party/blink/renderer/platform/weborigin/kurl.h" |
| #include "third_party/blink/renderer/platform/weborigin/security_origin.h" |
| #include "third_party/blink/renderer/platform/wtf/functional.h" |
| #include "third_party/blink/renderer/platform/wtf/text/wtf_string.h" |
| |
| namespace blink { |
| |
| namespace { |
| |
| // Returns null if and only if an exception is thrown. |
| network::mojom::blink::CookieManagerGetOptionsPtr ToBackendOptions( |
| const CookieStoreGetOptions* options, |
| ExceptionState& exception_state) { |
| auto backend_options = network::mojom::blink::CookieManagerGetOptions::New(); |
| |
| // TODO(crbug.com/1124499): Cleanup matchType after evaluation. |
| backend_options->match_type = network::mojom::blink::CookieMatchType::EQUALS; |
| |
| if (options->hasName()) { |
| backend_options->name = options->name(); |
| } else { |
| // No name provided. Use a filter that matches all cookies. This overrides |
| // a user-provided matchType. |
| backend_options->match_type = |
| network::mojom::blink::CookieMatchType::STARTS_WITH; |
| backend_options->name = g_empty_string; |
| } |
| |
| return backend_options; |
| } |
| |
| // Returns no value if and only if an exception is thrown. |
| std::unique_ptr<net::CanonicalCookie> ToCanonicalCookie( |
| const KURL& cookie_url, |
| const CookieInit* options, |
| ExceptionState& exception_state) { |
| const String& name = options->name(); |
| const String& value = options->value(); |
| if (name.IsEmpty() && value.Contains('=')) { |
| exception_state.ThrowTypeError( |
| "Cookie value cannot contain '=' if the name is empty"); |
| return nullptr; |
| } |
| if (name.IsEmpty() && value.IsEmpty()) { |
| exception_state.ThrowTypeError( |
| "Cookie name and value both cannot be empty"); |
| return nullptr; |
| } |
| |
| base::Time expires = options->hasExpiresNonNull() |
| ? base::Time::FromJavaTime(options->expiresNonNull()) |
| : base::Time(); |
| |
| String cookie_url_host = cookie_url.Host(); |
| String domain; |
| if (!options->domain().IsNull()) { |
| if (name.StartsWith("__Host-")) { |
| exception_state.ThrowTypeError( |
| "Cookies with \"__Host-\" prefix cannot have a domain"); |
| return nullptr; |
| } |
| // The leading dot (".") from the domain attribute is stripped in the |
| // Set-Cookie header, for compatibility. This API doesn't have compatibility |
| // constraints, so reject the edge case outright. |
| if (options->domain().StartsWith(".")) { |
| exception_state.ThrowTypeError("Cookie domain cannot start with \".\""); |
| return nullptr; |
| } |
| |
| domain = String(".") + options->domain(); |
| if (!cookie_url_host.EndsWith(domain) && |
| cookie_url_host != options->domain()) { |
| exception_state.ThrowTypeError( |
| "Cookie domain must domain-match current host"); |
| return nullptr; |
| } |
| } |
| |
| String path = options->path(); |
| if (!path.IsEmpty()) { |
| if (name.StartsWith("__Host-") && path != "/") { |
| exception_state.ThrowTypeError( |
| "Cookies with \"__Host-\" prefix cannot have a non-\"/\" path"); |
| return nullptr; |
| } |
| if (!path.StartsWith("/")) { |
| exception_state.ThrowTypeError("Cookie path must start with \"/\""); |
| return nullptr; |
| } |
| if (!path.EndsWith("/")) { |
| path = path + String("/"); |
| } |
| } |
| |
| // The Cookie Store API will only write secure cookies but will read insecure |
| // cookies. As a result, |
| // cookieStore.get("name", "value") can get an insecure cookie, but when |
| // modifying a retrieved insecure cookie via the Cookie Store API, it will |
| // automatically turn it into a secure cookie without any warning. |
| // |
| // The Cookie Store API can only set secure cookies, so it is unusable on |
| // insecure origins. |
| // TODO(crbug.com/1153336) Use network::IsUrlPotentiallyTrustworthy(). |
| if (!SecurityOrigin::IsSecure(cookie_url)) { |
| exception_state.ThrowTypeError( |
| "Cannot modify a secure cookie on insecure origin"); |
| return nullptr; |
| } |
| |
| net::CookieSameSite same_site; |
| if (options->sameSite() == "strict") { |
| same_site = net::CookieSameSite::STRICT_MODE; |
| } else if (options->sameSite() == "lax") { |
| same_site = net::CookieSameSite::LAX_MODE; |
| } else { |
| DCHECK_EQ(options->sameSite(), "none"); |
| same_site = net::CookieSameSite::NO_RESTRICTION; |
| } |
| |
| // TODO(crbug.com/1144187): Add support for SameParty attribute. |
| return net::CanonicalCookie::CreateSanitizedCookie( |
| cookie_url, name.Utf8(), value.Utf8(), domain.Utf8(), path.Utf8(), |
| base::Time() /*creation*/, expires, base::Time() /*last_access*/, |
| true /*secure*/, false /*http_only*/, same_site, |
| net::CookiePriority::COOKIE_PRIORITY_DEFAULT, false /*same_party*/); |
| } |
| |
| const KURL DefaultCookieURL(ExecutionContext* execution_context) { |
| DCHECK(execution_context); |
| |
| if (auto* window = DynamicTo<LocalDOMWindow>(execution_context)) |
| return window->document()->CookieURL(); |
| |
| return KURL(To<ServiceWorkerGlobalScope>(execution_context) |
| ->serviceWorker() |
| ->scriptURL()); |
| } |
| |
| // Return empty KURL if and only if an exception is thrown. |
| KURL CookieUrlForRead(const CookieStoreGetOptions* options, |
| const KURL& default_cookie_url, |
| ScriptState* script_state, |
| ExceptionState& exception_state) { |
| ExecutionContext* context = ExecutionContext::From(script_state); |
| |
| if (!options->hasUrl()) |
| return default_cookie_url; |
| |
| KURL cookie_url = KURL(default_cookie_url, options->url()); |
| |
| if (auto* window = DynamicTo<LocalDOMWindow>(context)) { |
| DCHECK_EQ(default_cookie_url, window->document()->CookieURL()); |
| |
| if (cookie_url.GetString() != default_cookie_url.GetString()) { |
| exception_state.ThrowTypeError("URL must match the document URL"); |
| return KURL(); |
| } |
| } else { |
| DCHECK(context->IsServiceWorkerGlobalScope()); |
| DCHECK_EQ( |
| default_cookie_url.GetString(), |
| To<ServiceWorkerGlobalScope>(context)->serviceWorker()->scriptURL()); |
| |
| if (!cookie_url.GetString().StartsWith(default_cookie_url.GetString())) { |
| exception_state.ThrowTypeError("URL must be within Service Worker scope"); |
| return KURL(); |
| } |
| } |
| |
| return cookie_url; |
| } |
| |
| net::SiteForCookies DefaultSiteForCookies(ExecutionContext* execution_context) { |
| DCHECK(execution_context); |
| |
| if (auto* window = DynamicTo<LocalDOMWindow>(execution_context)) |
| return window->document()->SiteForCookies(); |
| |
| auto* scope = To<ServiceWorkerGlobalScope>(execution_context); |
| return net::SiteForCookies::FromUrl(scope->Url()); |
| } |
| |
| scoped_refptr<SecurityOrigin> DefaultTopFrameOrigin( |
| ExecutionContext* execution_context) { |
| DCHECK(execution_context); |
| |
| if (auto* window = DynamicTo<LocalDOMWindow>(execution_context)) { |
| // Can we avoid the copy? TopFrameOrigin is returned as const& but we need |
| // a scoped_refptr. |
| return window->document()->TopFrameOrigin()->IsolatedCopy(); |
| } |
| |
| auto* scope = To<ServiceWorkerGlobalScope>(execution_context); |
| return scope->GetSecurityOrigin()->IsolatedCopy(); |
| } |
| |
| } // namespace |
| |
| CookieStore::CookieStore( |
| ExecutionContext* execution_context, |
| mojo::Remote<network::mojom::blink::RestrictedCookieManager> backend) |
| : ExecutionContextLifecycleObserver(execution_context), |
| backend_(std::move(backend)), |
| change_listener_receiver_(this, execution_context), |
| default_cookie_url_(DefaultCookieURL(execution_context)), |
| default_site_for_cookies_(DefaultSiteForCookies(execution_context)), |
| default_top_frame_origin_(DefaultTopFrameOrigin(execution_context)) { |
| DCHECK(backend_); |
| } |
| |
| CookieStore::~CookieStore() = default; |
| |
| ScriptPromise CookieStore::getAll(ScriptState* script_state, |
| const String& name, |
| ExceptionState& exception_state) { |
| CookieStoreGetOptions* options = CookieStoreGetOptions::Create(); |
| options->setName(name); |
| return getAll(script_state, options, exception_state); |
| } |
| |
| ScriptPromise CookieStore::getAll(ScriptState* script_state, |
| const CookieStoreGetOptions* options, |
| ExceptionState& exception_state) { |
| UseCounter::Count(CurrentExecutionContext(script_state->GetIsolate()), |
| WebFeature::kCookieStoreAPI); |
| |
| return DoRead(script_state, options, &CookieStore::GetAllForUrlToGetAllResult, |
| exception_state); |
| } |
| |
| ScriptPromise CookieStore::get(ScriptState* script_state, |
| const String& name, |
| ExceptionState& exception_state) { |
| CookieStoreGetOptions* options = CookieStoreGetOptions::Create(); |
| options->setName(name); |
| return get(script_state, options, exception_state); |
| } |
| |
| ScriptPromise CookieStore::get(ScriptState* script_state, |
| const CookieStoreGetOptions* options, |
| ExceptionState& exception_state) { |
| UseCounter::Count(CurrentExecutionContext(script_state->GetIsolate()), |
| WebFeature::kCookieStoreAPI); |
| |
| if (!options->hasName() && !options->hasUrl()) { |
| exception_state.ThrowTypeError("CookieStoreGetOptions must not be empty"); |
| return ScriptPromise(); |
| } |
| |
| return DoRead(script_state, options, &CookieStore::GetAllForUrlToGetResult, |
| exception_state); |
| } |
| |
| ScriptPromise CookieStore::set(ScriptState* script_state, |
| const String& name, |
| const String& value, |
| ExceptionState& exception_state) { |
| CookieInit* set_options = CookieInit::Create(); |
| set_options->setName(name); |
| set_options->setValue(value); |
| return set(script_state, set_options, exception_state); |
| } |
| |
| ScriptPromise CookieStore::set(ScriptState* script_state, |
| const CookieInit* options, |
| ExceptionState& exception_state) { |
| UseCounter::Count(CurrentExecutionContext(script_state->GetIsolate()), |
| WebFeature::kCookieStoreAPI); |
| |
| return DoWrite(script_state, options, exception_state); |
| } |
| |
| ScriptPromise CookieStore::Delete(ScriptState* script_state, |
| const String& name, |
| ExceptionState& exception_state) { |
| UseCounter::Count(CurrentExecutionContext(script_state->GetIsolate()), |
| WebFeature::kCookieStoreAPI); |
| |
| CookieInit* set_options = CookieInit::Create(); |
| set_options->setName(name); |
| set_options->setValue("deleted"); |
| set_options->setExpires(0); |
| return DoWrite(script_state, set_options, exception_state); |
| } |
| |
| ScriptPromise CookieStore::Delete(ScriptState* script_state, |
| const CookieStoreDeleteOptions* options, |
| ExceptionState& exception_state) { |
| CookieInit* set_options = CookieInit::Create(); |
| set_options->setName(options->name()); |
| set_options->setValue("deleted"); |
| set_options->setExpires(0); |
| set_options->setDomain(options->domain()); |
| set_options->setPath(options->path()); |
| set_options->setSameSite("strict"); |
| return DoWrite(script_state, set_options, exception_state); |
| } |
| |
| void CookieStore::Trace(Visitor* visitor) const { |
| visitor->Trace(change_listener_receiver_); |
| EventTargetWithInlineData::Trace(visitor); |
| ExecutionContextLifecycleObserver::Trace(visitor); |
| } |
| |
| void CookieStore::ContextDestroyed() { |
| backend_.reset(); |
| } |
| |
| const AtomicString& CookieStore::InterfaceName() const { |
| return event_target_names::kCookieStore; |
| } |
| |
| ExecutionContext* CookieStore::GetExecutionContext() const { |
| return ExecutionContextLifecycleObserver::GetExecutionContext(); |
| } |
| |
| void CookieStore::RemoveAllEventListeners() { |
| EventTargetWithInlineData::RemoveAllEventListeners(); |
| DCHECK(!HasEventListeners()); |
| StopObserving(); |
| } |
| |
| void CookieStore::OnCookieChange( |
| network::mojom::blink::CookieChangeInfoPtr change) { |
| HeapVector<Member<CookieListItem>> changed, deleted; |
| CookieChangeEvent::ToEventInfo(change, changed, deleted); |
| if (changed.IsEmpty() && deleted.IsEmpty()) { |
| // The backend only reported OVERWRITE events, which are dropped. |
| return; |
| } |
| DispatchEvent(*CookieChangeEvent::Create( |
| event_type_names::kChange, std::move(changed), std::move(deleted))); |
| } |
| |
| void CookieStore::AddedEventListener( |
| const AtomicString& event_type, |
| RegisteredEventListener& registered_listener) { |
| EventTargetWithInlineData::AddedEventListener(event_type, |
| registered_listener); |
| StartObserving(); |
| } |
| |
| void CookieStore::RemovedEventListener( |
| const AtomicString& event_type, |
| const RegisteredEventListener& registered_listener) { |
| EventTargetWithInlineData::RemovedEventListener(event_type, |
| registered_listener); |
| if (!HasEventListeners()) |
| StopObserving(); |
| } |
| |
| ScriptPromise CookieStore::DoRead( |
| ScriptState* script_state, |
| const CookieStoreGetOptions* options, |
| DoReadBackendResultConverter backend_result_converter, |
| ExceptionState& exception_state) { |
| ExecutionContext* context = ExecutionContext::From(script_state); |
| if (!context->GetSecurityOrigin()->CanAccessCookies()) { |
| exception_state.ThrowSecurityError( |
| "Access to the CookieStore API is denied in this context."); |
| return ScriptPromise(); |
| } |
| |
| network::mojom::blink::CookieManagerGetOptionsPtr backend_options = |
| ToBackendOptions(options, exception_state); |
| KURL cookie_url = CookieUrlForRead(options, default_cookie_url_, script_state, |
| exception_state); |
| if (backend_options.is_null() || cookie_url.IsNull()) { |
| DCHECK(exception_state.HadException()); |
| return ScriptPromise(); |
| } |
| |
| if (!backend_) { |
| exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError, |
| "CookieStore backend went away"); |
| return ScriptPromise(); |
| } |
| |
| auto* resolver = MakeGarbageCollected<ScriptPromiseResolver>(script_state); |
| backend_->GetAllForUrl( |
| cookie_url, default_site_for_cookies_, default_top_frame_origin_, |
| std::move(backend_options), |
| WTF::Bind(backend_result_converter, WrapPersistent(resolver))); |
| return resolver->Promise(); |
| } |
| |
| // static |
| void CookieStore::GetAllForUrlToGetAllResult( |
| ScriptPromiseResolver* resolver, |
| const Vector<network::mojom::blink::CookieWithAccessResultPtr> |
| backend_cookies) { |
| ScriptState* script_state = resolver->GetScriptState(); |
| if (!script_state->ContextIsValid()) |
| return; |
| ScriptState::Scope scope(script_state); |
| |
| HeapVector<Member<CookieListItem>> cookies; |
| cookies.ReserveInitialCapacity(backend_cookies.size()); |
| for (const auto& backend_cookie : backend_cookies) { |
| cookies.push_back(CookieChangeEvent::ToCookieListItem( |
| backend_cookie->cookie, |
| backend_cookie->access_result->effective_same_site, |
| false /* is_deleted */)); |
| } |
| |
| resolver->Resolve(std::move(cookies)); |
| } |
| |
| // static |
| void CookieStore::GetAllForUrlToGetResult( |
| ScriptPromiseResolver* resolver, |
| const Vector<network::mojom::blink::CookieWithAccessResultPtr> |
| backend_cookies) { |
| ScriptState* script_state = resolver->GetScriptState(); |
| if (!script_state->ContextIsValid()) |
| return; |
| ScriptState::Scope scope(script_state); |
| |
| if (backend_cookies.IsEmpty()) { |
| resolver->Resolve(v8::Null(script_state->GetIsolate())); |
| return; |
| } |
| |
| const auto& backend_cookie = backend_cookies.front(); |
| CookieListItem* cookie = CookieChangeEvent::ToCookieListItem( |
| backend_cookie->cookie, |
| backend_cookie->access_result->effective_same_site, |
| false /* is_deleted */); |
| resolver->Resolve(cookie); |
| } |
| |
| ScriptPromise CookieStore::DoWrite(ScriptState* script_state, |
| const CookieInit* options, |
| ExceptionState& exception_state) { |
| ExecutionContext* context = ExecutionContext::From(script_state); |
| if (!context->GetSecurityOrigin()->CanAccessCookies()) { |
| exception_state.ThrowSecurityError( |
| "Access to the CookieStore API is denied in this context."); |
| return ScriptPromise(); |
| } |
| |
| std::unique_ptr<net::CanonicalCookie> canonical_cookie = |
| ToCanonicalCookie(default_cookie_url_, options, exception_state); |
| if (!canonical_cookie) { |
| DCHECK(exception_state.HadException()); |
| return ScriptPromise(); |
| } |
| |
| if (!backend_) { |
| exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError, |
| "CookieStore backend went away"); |
| return ScriptPromise(); |
| } |
| |
| auto* resolver = MakeGarbageCollected<ScriptPromiseResolver>(script_state); |
| backend_->SetCanonicalCookie( |
| *std::move(canonical_cookie), default_cookie_url_, |
| default_site_for_cookies_, default_top_frame_origin_, |
| WTF::Bind(&CookieStore::OnSetCanonicalCookieResult, |
| WrapPersistent(resolver))); |
| return resolver->Promise(); |
| } |
| |
| // static |
| void CookieStore::OnSetCanonicalCookieResult(ScriptPromiseResolver* resolver, |
| bool backend_success) { |
| ScriptState* script_state = resolver->GetScriptState(); |
| if (!script_state->ContextIsValid()) |
| return; |
| ScriptState::Scope scope(script_state); |
| |
| if (!backend_success) { |
| resolver->Reject(V8ThrowDOMException::CreateOrEmpty( |
| script_state->GetIsolate(), DOMExceptionCode::kUnknownError, |
| "An unknown error occured while writing the cookie.")); |
| return; |
| } |
| resolver->Resolve(); |
| } |
| |
| void CookieStore::StartObserving() { |
| if (change_listener_receiver_.is_bound() || !backend_) |
| return; |
| |
| // See https://bit.ly/2S0zRAS for task types. |
| auto task_runner = |
| GetExecutionContext()->GetTaskRunner(TaskType::kDOMManipulation); |
| backend_->AddChangeListener( |
| default_cookie_url_, default_site_for_cookies_, default_top_frame_origin_, |
| change_listener_receiver_.BindNewPipeAndPassRemote(task_runner), {}); |
| } |
| |
| void CookieStore::StopObserving() { |
| if (!change_listener_receiver_.is_bound()) |
| return; |
| change_listener_receiver_.reset(); |
| } |
| |
| } // namespace blink |