1use std::borrow::Cow;
6use std::collections::VecDeque;
7use std::rc::Rc;
8
9use cookie::{Cookie, SameSite};
10use dom_struct::dom_struct;
11use hyper_serde::Serde;
12use itertools::Itertools;
13use js::context::JSContext;
14use js::jsval::NullValue;
15use net_traits::CookieSource::NonHTTP;
16use net_traits::{CookieAsyncResponse, CookieData, CoreResourceMsg};
17use script_bindings::cell::DomRefCell;
18use script_bindings::codegen::GenericBindings::CookieStoreBinding::CookieSameSite;
19use script_bindings::reflector::reflect_dom_object;
20use script_bindings::script_runtime::CanGc;
21use servo_base::generic_channel::{GenericCallback, GenericSend, GenericSender};
22use servo_base::id::CookieStoreId;
23use servo_url::ServoUrl;
24use time::OffsetDateTime;
25
26use crate::dom::bindings::codegen::Bindings::CookieStoreBinding::{
27 CookieInit, CookieListItem, CookieStoreDeleteOptions, CookieStoreGetOptions, CookieStoreMethods,
28};
29use crate::dom::bindings::error::Error;
30use crate::dom::bindings::refcounted::Trusted;
31use crate::dom::bindings::reflector::DomGlobal;
32use crate::dom::bindings::root::DomRoot;
33use crate::dom::bindings::str::USVString;
34use crate::dom::document::get_registrable_domain_suffix_of_or_is_equal_to;
35use crate::dom::eventtarget::EventTarget;
36use crate::dom::globalscope::GlobalScope;
37use crate::dom::promise::Promise;
38use crate::dom::window::Window;
39use crate::task_source::SendableTaskSource;
40
41#[derive(JSTraceable, MallocSizeOf)]
42struct DroppableCookieStore {
43 #[no_trace]
45 store_id: CookieStoreId,
46 #[no_trace]
47 unregister_channel: GenericSender<CoreResourceMsg>,
48}
49
50impl Drop for DroppableCookieStore {
51 fn drop(&mut self) {
52 let res = self
53 .unregister_channel
54 .send(CoreResourceMsg::RemoveCookieListener(self.store_id));
55 if res.is_err() {
56 error!("Failed to send cookiestore message to resource threads");
57 }
58 }
59}
60
61#[dom_struct]
66pub(crate) struct CookieStore {
67 eventtarget: EventTarget,
68 #[conditional_malloc_size_of]
69 in_flight: DomRefCell<VecDeque<Rc<Promise>>>,
70 droppable: DroppableCookieStore,
71}
72
73struct CookieListener {
74 task_source: SendableTaskSource,
76 context: Trusted<CookieStore>,
77}
78
79impl CookieListener {
80 pub(crate) fn handle(&self, message: CookieAsyncResponse) {
81 let context = self.context.clone();
82 self.task_source.queue(task!(cookie_message: move |cx| {
83 let Some(promise) = context.root().in_flight.borrow_mut().pop_front() else {
84 warn!("No promise exists for cookie store response");
85 return;
86 };
87 match message.data {
88 CookieData::Get(cookie) => {
89 if let Some(cookie) = cookie {
92 promise.resolve_native(cx, &cookie_to_list_item(cookie.into_inner()));
94 } else {
95 promise.resolve_native(cx, &NullValue());
97 }
98 },
99 CookieData::GetAll(cookies) => {
100 promise.resolve_native(cx,
102 &cookies
103 .into_iter()
104 .map(|cookie| cookie_to_list_item(cookie.0))
105 .collect_vec(),);
106 },
107 CookieData::Delete(_) | CookieData::Change(_) | CookieData::Set(_) => {
108 promise.resolve_native(cx, &());
109 }
110 }
111 }));
112 }
113}
114
115impl CookieStore {
116 fn new_inherited(unregister_channel: GenericSender<CoreResourceMsg>) -> CookieStore {
117 CookieStore {
118 eventtarget: EventTarget::new_inherited(),
119 in_flight: Default::default(),
120 droppable: DroppableCookieStore {
121 store_id: CookieStoreId::new(),
122 unregister_channel,
123 },
124 }
125 }
126
127 pub(crate) fn new(global: &GlobalScope, can_gc: CanGc) -> DomRoot<CookieStore> {
128 let store = reflect_dom_object(
129 Box::new(CookieStore::new_inherited(
130 global.resource_threads().core_thread.clone(),
131 )),
132 global,
133 can_gc,
134 );
135 store.setup_route();
136 store
137 }
138
139 fn setup_route(&self) {
140 let context = Trusted::new(self);
141 let cs_listener = CookieListener {
142 task_source: self
143 .global()
144 .task_manager()
145 .dom_manipulation_task_source()
146 .to_sendable(),
147 context,
148 };
149
150 let callback = GenericCallback::new(move |message| match message {
151 Ok(msg) => cs_listener.handle(msg),
152 Err(err) => warn!("Error receiving a CookieStore message: {:?}", err),
153 })
154 .expect("Could not create cookie store callback");
155
156 let res = self
157 .global()
158 .resource_threads()
159 .send(CoreResourceMsg::NewCookieListener(
160 self.droppable.store_id,
161 callback,
162 self.global().creation_url(),
163 ));
164 if res.is_err() {
165 error!("Failed to send cookiestore message to resource threads");
166 }
167 }
168}
169
170fn cookie_to_list_item(cookie: Cookie) -> CookieListItem {
172 CookieListItem {
175 name: Some(cookie.name().to_string().into()),
177
178 value: Some(cookie.value().to_string().into()),
180 }
181}
182
183impl CookieStoreMethods<crate::DomTypeHolder> for CookieStore {
184 fn Get(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
186 let global = self.global();
188
189 let origin = global.origin();
191
192 let p = Promise::new(cx, &global);
194
195 if !origin.is_tuple() {
197 p.reject_error(cx, Error::Security(None));
198 return p;
199 }
200
201 let creation_url = global.creation_url();
203
204 let name = CookieStore::normalize(&name);
205
206 let res = self
208 .global()
209 .resource_threads()
210 .send(CoreResourceMsg::GetCookieDataForUrlAsync(
211 self.droppable.store_id,
212 creation_url,
213 Some(name),
214 ));
215 if res.is_err() {
216 error!("Failed to send cookiestore message to resource threads");
217 } else {
218 self.in_flight.borrow_mut().push_back(p.clone());
219 }
220
221 p
223 }
224
225 fn Get_(&self, cx: &mut JSContext, options: &CookieStoreGetOptions) -> Rc<Promise> {
227 let global = self.global();
229
230 let origin = global.origin();
232
233 let p = Promise::new(cx, &global);
235
236 if !origin.is_tuple() {
238 p.reject_error(cx, Error::Security(None));
239 return p;
240 }
241
242 let creation_url = global.creation_url();
244
245 if options.url.is_none() && options.name.is_none() {
248 p.reject_error(cx, Error::Type(c"Options cannot be empty".to_owned()));
249 return p;
250 }
251
252 let mut final_url = creation_url.clone();
253
254 if let Some(get_url) = &options.url {
256 let parsed_url = ServoUrl::parse_with_base(Some(&global.api_base_url()), get_url);
258
259 if let Some(_window) = DomRoot::downcast::<Window>(self.global()) &&
262 parsed_url
263 .as_ref()
264 .is_ok_and(|parsed| !parsed.is_equal_excluding_fragments(&creation_url))
265 {
266 p.reject_error(cx, Error::Type(c"URL does not match context".to_owned()));
267 return p;
268 }
269
270 if parsed_url
273 .as_ref()
274 .is_ok_and(|parsed| creation_url.origin() != parsed.origin())
275 {
276 p.reject_error(cx, Error::Type(c"Not same origin".to_owned()));
277 return p;
278 }
279
280 if let Ok(url) = parsed_url {
282 final_url = url;
283 }
284 }
285
286 let res = self
288 .global()
289 .resource_threads()
290 .send(CoreResourceMsg::GetCookieDataForUrlAsync(
291 self.droppable.store_id,
292 final_url,
293 options.name.clone().map(|val| CookieStore::normalize(&val)),
294 ));
295 if res.is_err() {
296 error!("Failed to send cookiestore message to resource threads");
297 } else {
298 self.in_flight.borrow_mut().push_back(p.clone());
299 }
300
301 p
302 }
303
304 fn GetAll(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
306 let global = self.global();
308
309 let origin = global.origin();
311
312 let p = Promise::new(cx, &global);
314
315 if !origin.is_tuple() {
317 p.reject_error(cx, Error::Security(None));
318 return p;
319 }
320 let creation_url = global.creation_url();
322
323 let name = CookieStore::normalize(&name);
325
326 let res =
328 self.global()
329 .resource_threads()
330 .send(CoreResourceMsg::GetAllCookieDataForUrlAsync(
331 self.droppable.store_id,
332 creation_url,
333 Some(name),
334 ));
335 if res.is_err() {
336 error!("Failed to send cookiestore message to resource threads");
337 } else {
338 self.in_flight.borrow_mut().push_back(p.clone());
339 }
340
341 p
343 }
344
345 fn GetAll_(&self, cx: &mut JSContext, options: &CookieStoreGetOptions) -> Rc<Promise> {
347 let global = self.global();
349
350 let origin = global.origin();
352
353 let p = Promise::new(cx, &global);
355
356 if !origin.is_tuple() {
358 p.reject_error(cx, Error::Security(None));
359 return p;
360 }
361
362 let creation_url = global.creation_url();
364
365 let mut final_url = creation_url.clone();
366
367 if let Some(get_url) = &options.url {
369 let parsed_url = ServoUrl::parse_with_base(Some(&global.api_base_url()), get_url);
371
372 if let Some(_window) = DomRoot::downcast::<Window>(self.global()) &&
375 parsed_url
376 .as_ref()
377 .is_ok_and(|parsed| !parsed.is_equal_excluding_fragments(&creation_url))
378 {
379 p.reject_error(cx, Error::Type(c"URL does not match context".to_owned()));
380 return p;
381 }
382
383 if parsed_url
386 .as_ref()
387 .is_ok_and(|parsed| creation_url.origin() != parsed.origin())
388 {
389 p.reject_error(cx, Error::Type(c"Not same origin".to_owned()));
390 return p;
391 }
392
393 if let Ok(url) = parsed_url {
395 final_url = url;
396 }
397 }
398
399 let res =
401 self.global()
402 .resource_threads()
403 .send(CoreResourceMsg::GetAllCookieDataForUrlAsync(
404 self.droppable.store_id,
405 final_url,
406 options.name.clone().map(|val| CookieStore::normalize(&val)),
407 ));
408 if res.is_err() {
409 error!("Failed to send cookiestore message to resource threads");
410 } else {
411 self.in_flight.borrow_mut().push_back(p.clone());
412 }
413
414 p
416 }
417
418 fn Set(&self, cx: &mut JSContext, name: USVString, value: USVString) -> Rc<Promise> {
420 let global = self.global();
422
423 let origin = global.origin();
425
426 let p = Promise::new(cx, &global);
428
429 if !origin.is_tuple() {
431 p.reject_error(cx, Error::Security(None));
432 return p;
433 }
434
435 let properties = CookieInit {
437 name,
438 value,
439 expires: None,
440 domain: None,
441 path: USVString(String::from("/")),
442 sameSite: CookieSameSite::Strict,
443 partitioned: false,
444 };
445 let creation_url = global.creation_url();
446 let Some(cookie) = CookieStore::set_a_cookie(&creation_url, &properties) else {
447 p.reject_error(cx, Error::Type(c"Invalid cookie".to_owned()));
449 return p;
450 };
451
452 let res = self
454 .global()
455 .resource_threads()
456 .send(CoreResourceMsg::SetCookieForUrlAsync(
457 self.droppable.store_id,
458 creation_url.clone(),
459 Serde(cookie.into_owned()),
460 NonHTTP,
461 ));
462 if res.is_err() {
463 error!("Failed to send cookiestore message to resource threads");
464 } else {
465 self.in_flight.borrow_mut().push_back(p.clone());
466 }
467
468 p
470 }
471
472 fn Set_(&self, cx: &mut JSContext, options: &CookieInit) -> Rc<Promise> {
474 let global = self.global();
476
477 let origin = global.origin();
479
480 let p = Promise::new(cx, &global);
482
483 if !origin.is_tuple() {
485 p.reject_error(cx, Error::Security(None));
486 return p;
487 }
488
489 let creation_url = global.creation_url();
491
492 let Some(cookie) = CookieStore::set_a_cookie(&creation_url, options) else {
495 p.reject_error(cx, Error::Type(c"Invalid cookie".to_owned()));
496 return p;
497 };
498
499 let res = self
501 .global()
502 .resource_threads()
503 .send(CoreResourceMsg::SetCookieForUrlAsync(
504 self.droppable.store_id,
505 creation_url.clone(),
506 Serde(cookie.into_owned()),
507 NonHTTP,
508 ));
509 if res.is_err() {
510 error!("Failed to send cookiestore message to resource threads");
511 } else {
512 self.in_flight.borrow_mut().push_back(p.clone());
513 }
514
515 p
517 }
518
519 fn Delete(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
521 let global = self.global();
523
524 let origin = global.origin();
526
527 let p = Promise::new(cx, &global);
529
530 if !origin.is_tuple() {
532 p.reject_error(cx, Error::Security(None));
533 return p;
534 }
535
536 let res = global
539 .resource_threads()
540 .send(CoreResourceMsg::DeleteCookieAsync(
541 self.droppable.store_id,
542 global.creation_url(),
543 name.0,
544 ));
545 if res.is_err() {
546 error!("Failed to send cookiestore message to resource threads");
547 } else {
548 self.in_flight.borrow_mut().push_back(p.clone());
549 }
550
551 p
553 }
554
555 fn Delete_(&self, cx: &mut JSContext, options: &CookieStoreDeleteOptions) -> Rc<Promise> {
557 let global = self.global();
559
560 let origin = global.origin();
562
563 let p = Promise::new(cx, &global);
565
566 if !origin.is_tuple() {
568 p.reject_error(cx, Error::Security(None));
569 return p;
570 }
571
572 let res = global
575 .resource_threads()
576 .send(CoreResourceMsg::DeleteCookieAsync(
577 self.droppable.store_id,
578 global.creation_url(),
579 options.name.to_string(),
580 ));
581 if res.is_err() {
582 error!("Failed to send cookiestore message to resource threads");
583 } else {
584 self.in_flight.borrow_mut().push_back(p.clone());
585 }
586
587 p
589 }
590}
591
592impl CookieStore {
593 fn normalize(value: &USVString) -> String {
595 value.trim_matches([' ', '\t']).into()
596 }
597
598 fn set_a_cookie<'a>(url: &'a ServoUrl, properties: &'a CookieInit) -> Option<Cookie<'a>> {
600 let name = CookieStore::normalize(&properties.name);
602 let value = CookieStore::normalize(&properties.value);
604
605 if CookieStore::contains_control_characters(&name) ||
607 CookieStore::contains_control_characters(&value)
608 {
609 return None;
610 }
611
612 if name.contains('=') {
614 return None;
615 }
616
617 if name.is_empty() {
619 if value.contains('=') || value.is_empty() {
622 return None;
623 }
624 let lowercased_value = value.to_ascii_lowercase();
626 if ["__host-", "__host-http-", "__http-", "__secure-"]
627 .iter()
628 .any(|prefix| lowercased_value.starts_with(prefix))
629 {
630 return None;
631 }
632 }
633
634 let lowercased_name = name.to_ascii_lowercase();
636 if lowercased_name.starts_with("__host-http-") || lowercased_name.starts_with("__http-") {
637 return None;
638 }
639
640 if name.len() + value.len() > 4096 {
642 return None;
643 }
644
645 let mut cookie = Cookie::build((Cow::Owned(name), Cow::Owned(value)))
646 .secure(true)
648 .partitioned(properties.partitioned)
650 .same_site(match properties.sameSite {
652 CookieSameSite::Lax => SameSite::Lax,
653 CookieSameSite::Strict => SameSite::Strict,
654 CookieSameSite::None => SameSite::None,
655 });
656
657 if let Some(domain) = &properties.domain {
659 let host = match url.host() {
661 Some(host) => host.to_owned(),
662 None => return None,
663 };
664 if domain.starts_with('.') {
666 return None;
667 }
668 if lowercased_name.starts_with("__host-") {
670 return None;
671 }
672
673 let domain = get_registrable_domain_suffix_of_or_is_equal_to(domain, host)?;
678
679 let domain = domain.to_string();
681 if domain.len() > 1024 {
683 return None;
684 }
685 cookie.inner_mut().set_domain(domain);
687 }
688
689 if let Some(expiry) = properties.expires {
691 cookie.inner_mut().set_expires(
694 OffsetDateTime::from_unix_timestamp((*expiry / 1000.0) as i64)
695 .expect("cookie expiry out of range"),
696 );
697 }
698
699 let path = if properties.path.is_empty() {
701 let mut cloned_url = url.clone();
703 {
704 let mut path_segments = cloned_url
707 .as_mut_url()
708 .path_segments_mut()
709 .expect("document creation url cannot be a base");
710 if url.path_segments().is_some_and(|ps| ps.count() > 1) {
712 path_segments.pop();
713 } else {
714 path_segments.clear();
716 }
717 }
718 cloned_url.path().to_owned()
719 } else {
720 properties.path.to_string()
721 };
722 if !path.starts_with('/') {
724 return None;
725 }
726 if path != "/" && lowercased_name.starts_with("__host-") {
728 return None;
729 }
730 if path.len() > 1024 {
732 return None;
733 }
734 cookie.inner_mut().set_path(path);
736
737 Some(cookie.build())
738 }
739
740 fn contains_control_characters(val: &str) -> bool {
742 val.contains(
744 |v| matches!(v, '\u{0000}'..='\u{0008}' | '\u{000a}'..='\u{001f}' | '\u{007f}' | ';'),
745 )
746 }
747}