1use crate::protected::Protected;
2use serde_json::Value;
3use std::collections::BTreeSet;
4
5const ORIGIN_KEYS: &[&str] = &[
6 "origin",
7 "securityOrigin",
8 "ownerOrigin",
9 "issuerOrigin",
10 "topLevelOrigin",
11 "topLevelSite",
12 "embeddedOrigin",
13 "requestingOrigin",
14 "scopeURL",
15 "manifestId",
16];
17
18const FREE_FORM: &[&str] = &[
19 "expression",
20 "arguments",
21 "value",
22 "headers",
23 "postData",
24 "functionDeclaration",
25 "source",
26 "text",
27 "body",
28 "data",
29 "message",
30];
31
32const BODY_METHODS: &[&str] = &[
33 "Network.getResponseBody",
34 "Network.searchInResponseBody",
35 "Network.streamResourceContent",
36 "Network.getResponseBodyForInterception",
37 "Network.takeResponseBodyForInterceptionAsStream",
38 "Network.getRequestPostData",
39 "Network.replayXHR",
40 "Fetch.getResponseBody",
41 "Fetch.takeResponseBodyAsStream",
42 "Audits.getEncodedResponse",
43];
44
45const INTERCEPTION_METHODS: &[&str] = &[
46 "Fetch.fulfillRequest",
47 "Fetch.failRequest",
48 "Fetch.continueRequest",
49 "Fetch.continueWithAuth",
50 "Fetch.continueResponse",
51 "Fetch.getResponseBody",
52 "Fetch.takeResponseBodyAsStream",
53 "Network.continueInterceptedRequest",
54 "Network.getResponseBodyForInterception",
55 "Network.takeResponseBodyForInterceptionAsStream",
56];
57
58const COOKIE_SETTERS: &[&str] = &[
59 "Network.setCookie",
60 "Network.setCookies",
61 "Network.deleteCookies",
62 "Storage.setCookies",
63 "Page.deleteCookie",
64];
65
66const COOKIE_READERS: &[&str] = &[
67 "Network.getCookies",
68 "Network.getAllCookies",
69 "Storage.getCookies",
70 "Page.getCookies",
71];
72
73pub fn browser_wide(
74 method: &str,
75 params: &Value,
76 session_id: Option<&str>,
77 contexts: &BTreeSet<String>,
78) -> Option<String> {
79 let own = params
80 .get("browserContextId")
81 .and_then(|v| v.as_str())
82 .is_some_and(|id| contexts.contains(id));
83 let reason = match method {
84 "Target.exposeDevToolsProtocol" => {
85 "Target.exposeDevToolsProtocol would give a page the whole browser"
86 }
87 "Target.sendMessageToTarget" => {
88 "Target.sendMessageToTarget is refused: attach with flatten: true instead"
89 }
90 "Target.attachToBrowserTarget" => {
91 "Target.attachToBrowserTarget would give a page the whole browser"
92 }
93 "Tracing.start" => "Tracing.start records every target, seven's included",
94 "Extensions.loadUnpacked" => "Extensions.loadUnpacked can reach seven's origin",
95 "Network.clearBrowserCookies" => "Network.clearBrowserCookies would clear seven's cookies",
96 "Storage.clearCookies" if !own => "Storage.clearCookies would clear seven's cookies",
97 "Browser.resetPermissions" if !own => {
98 "Browser.resetPermissions would reset seven's permissions"
99 }
100 "Browser.setPermission" | "Browser.grantPermissions"
101 if !own && params.get("origin").and_then(|v| v.as_str()).is_none() =>
102 {
103 "browser-wide permissions would reach seven's origin"
104 }
105 "Network.setRequestInterception" => {
106 "Network.setRequestInterception would hand out seven's requests; use Fetch on a page session"
107 }
108 _ if method.starts_with("Fetch.") && session_id.is_none() => {
109 "Fetch on the browser itself would intercept seven's requests; use a page session"
110 }
111 _ => return None,
112 };
113 Some(format!("slicc: refused: {reason}\n"))
114}
115
116pub fn body_method(method: &str) -> bool {
117 BODY_METHODS.contains(&method)
118}
119
120pub fn interception_method(method: &str) -> bool {
121 INTERCEPTION_METHODS.contains(&method)
122}
123
124pub fn answers_interception(method: &str) -> bool {
125 interception_method(method) && !method.contains("ResponseBody")
126}
127
128pub fn cookie_method(method: &str) -> bool {
129 COOKIE_SETTERS.contains(&method)
130}
131
132pub fn cookie_reader(method: &str) -> bool {
133 COOKIE_READERS.contains(&method)
134}
135
136pub fn request_key(params: &Value) -> Option<String> {
137 params
138 .get("requestId")
139 .or_else(|| params.get("interceptionId"))
140 .and_then(|v| v.as_str())
141 .map(str::to_string)
142}
143
144pub fn request_url(params: &Value) -> Option<String> {
145 params
146 .pointer("/request/url")
147 .or_else(|| params.pointer("/response/url"))
148 .or_else(|| params.get("url"))
149 .and_then(|v| v.as_str())
150 .map(str::to_string)
151}
152
153pub fn web_socket_as_http(url: &str) -> String {
154 let lower = url.to_ascii_lowercase();
155 if lower.starts_with("wss:") {
156 format!("https:{}", &url[4..])
157 } else if lower.starts_with("ws:") {
158 format!("http:{}", &url[3..])
159 } else {
160 url.to_string()
161 }
162}
163
164fn url_protected(guard: &Protected, text: &str) -> bool {
165 guard.url(text) || guard.url(&web_socket_as_http(text))
166}
167
168pub fn mentions(guard: &Protected, value: &Value) -> bool {
169 match value {
170 Value::String(text) => text.contains(':') && url_protected(guard, text),
171 Value::Array(items) => items.iter().any(|item| mentions(guard, item)),
172 Value::Object(map) => map.values().any(|item| mentions(guard, item)),
173 _ => false,
174 }
175}
176
177pub fn names_protected_storage(guard: &Protected, value: &Value) -> bool {
178 match value {
179 Value::Array(items) => items
180 .iter()
181 .any(|item| names_protected_storage(guard, item)),
182 Value::Object(map) => map.iter().any(|(key, item)| {
183 if FREE_FORM.contains(&key.as_str()) {
184 return false;
185 }
186 if let Some(text) = item.as_str() {
187 if ORIGIN_KEYS.contains(&key.as_str()) && url_protected(guard, text) {
188 return true;
189 }
190 if key == "storageKey" && guard.storage_key(text) {
191 return true;
192 }
193 if key == "cacheId" && guard.storage_key(text.split('|').next().unwrap_or("")) {
194 return true;
195 }
196 return false;
197 }
198 names_protected_storage(guard, item)
199 }),
200 _ => false,
201 }
202}
203
204fn host_of(text: &str) -> Option<String> {
205 url::Url::parse(text)
206 .ok()
207 .and_then(|url| url.host_str().map(str::to_string))
208}
209
210fn site_protected(guard: &Protected, site: &str) -> bool {
211 url_protected(guard, site) || host_of(site).is_some_and(|host| guard.cookie_domain(&host))
212}
213
214fn cookie_protected(guard: &Protected, cookie: &Value) -> bool {
215 if let Some(domain) = cookie.get("domain").and_then(|v| v.as_str()) {
216 if guard.cookie_domain(domain) {
217 return true;
218 }
219 }
220 if let Some(url) = cookie.get("url").and_then(|v| v.as_str()) {
221 if url_protected(guard, url) || host_of(url).is_some_and(|host| guard.cookie_domain(&host))
222 {
223 return true;
224 }
225 }
226 match cookie.get("partitionKey") {
227 Some(Value::String(site)) => site_protected(guard, site),
228 Some(key) => key
229 .get("topLevelSite")
230 .and_then(|v| v.as_str())
231 .is_some_and(|site| site_protected(guard, site)),
232 None => false,
233 }
234}
235
236pub fn cookie_refused(guard: &Protected, params: &Value) -> bool {
237 match params.get("cookies").and_then(|v| v.as_array()) {
238 Some(cookies) => cookies.iter().any(|cookie| cookie_protected(guard, cookie)),
239 None => cookie_protected(guard, params),
240 }
241}
242
243pub fn drop_protected_cookies(guard: &Protected, result: &mut Value) {
244 if let Some(cookies) = result.get_mut("cookies").and_then(|v| v.as_array_mut()) {
245 cookies.retain(|cookie| !cookie_protected(guard, cookie));
246 }
247}
248
249pub fn strip_protected_associated(guard: &Protected, event: &mut Value) {
250 if let Some(cookies) = event
251 .pointer_mut("/params/associatedCookies")
252 .and_then(|v| v.as_array_mut())
253 {
254 cookies.retain(|item| {
255 !item
256 .get("cookie")
257 .is_some_and(|cookie| cookie_protected(guard, cookie))
258 });
259 }
260}
261
262pub fn redacted(info: &Value) -> Value {
263 let mut out = info.clone();
264 let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("");
265 let origin = url::Url::parse(url)
266 .ok()
267 .map(|parsed| parsed.origin().ascii_serialization())
268 .filter(|origin| origin != "null")
269 .unwrap_or_else(|| "about:blank".to_string());
270 out["url"] = Value::String(origin.clone());
271 out["title"] = Value::String(origin);
272 out
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use serde_json::json;
279
280 #[test]
281 fn origins_and_storage_keys_are_found_outside_free_form_fields() {
282 let guard = Protected::default();
283 assert!(names_protected_storage(
284 &guard,
285 &json!({ "origin": "https://seven.sliccy.ai" })
286 ));
287 assert!(names_protected_storage(
288 &guard,
289 &json!({ "securityOrigin": "https://vault.sliccy.ai" })
290 ));
291 assert!(names_protected_storage(
292 &guard,
293 &json!({ "storageKey": "https://seven.sliccy.ai/" })
294 ));
295 assert!(names_protected_storage(
296 &guard,
297 &json!({ "cacheId": "https://seven.sliccy.ai/|c1" })
298 ));
299 assert!(names_protected_storage(
300 &guard,
301 &json!({ "bucketFileSystemLocator": { "storageKey": "https://sliccy.ai/", "pathComponents": [] } })
302 ));
303 assert!(names_protected_storage(
304 &guard,
305 &json!({ "scopeURL": "https://seven.sliccy.ai/" })
306 ));
307 assert!(!names_protected_storage(
308 &guard,
309 &json!({ "origin": "https://example.com" })
310 ));
311 assert!(!names_protected_storage(
312 &guard,
313 &json!({ "expression": "'https://seven.sliccy.ai'" })
314 ));
315 assert!(!names_protected_storage(
316 &guard,
317 &json!({ "value": { "origin": "https://seven.sliccy.ai" } })
318 ));
319 }
320
321 #[test]
322 fn cookies_on_seven_are_refused_and_filtered() {
323 let guard = Protected::default();
324 for params in [
325 json!({ "name": "x", "value": "1", "url": "https://seven.sliccy.ai/" }),
326 json!({ "name": "x", "value": "1", "domain": ".sliccy.ai", "path": "/" }),
327 json!({ "name": "x", "value": "1", "url": "http://seven.sliccy.ai:1/" }),
328 json!({ "name": "x", "value": "1", "url": "https://example.com/", "partitionKey": { "topLevelSite": "https://sliccy.ai" } }),
329 json!({ "cookies": [{ "name": "y", "url": "https://example.com/" }, { "name": "x", "domain": "vault.sliccy.ai" }] }),
330 ] {
331 assert!(cookie_refused(&guard, ¶ms), "{params}");
332 }
333 assert!(!cookie_refused(
334 &guard,
335 &json!({ "name": "a", "url": "https://example.com/" })
336 ));
337 let mut result = json!({ "cookies": [
338 { "name": "seven", "domain": "seven.sliccy.ai" },
339 { "name": "apex", "domain": ".sliccy.ai" },
340 { "name": "chips", "domain": "example.com", "partitionKey": { "topLevelSite": "https://sliccy.ai" } },
341 { "name": "ok", "domain": "example.com" }
342 ] });
343 drop_protected_cookies(&guard, &mut result);
344 assert_eq!(
345 result,
346 json!({ "cookies": [{ "name": "ok", "domain": "example.com" }] })
347 );
348 }
349
350 #[test]
351 fn browser_wide_commands_are_refused_unless_in_an_own_context() {
352 let none = BTreeSet::new();
353 for (method, params, session) in [
354 (
355 "Target.exposeDevToolsProtocol",
356 json!({ "targetId": "T" }),
357 None,
358 ),
359 ("Target.sendMessageToTarget", json!({}), None),
360 ("Tracing.start", json!({}), None),
361 ("Extensions.loadUnpacked", json!({ "path": "/x" }), None),
362 (
363 "Browser.grantPermissions",
364 json!({ "permissions": ["geolocation"] }),
365 None,
366 ),
367 ("Browser.resetPermissions", json!({}), None),
368 ("Storage.clearCookies", json!({}), None),
369 ("Network.clearBrowserCookies", json!({}), Some("S")),
370 ("Fetch.enable", json!({}), None),
371 ] {
372 let refused = browser_wide(method, ¶ms, session, &none).unwrap();
373 assert!(refused.starts_with("slicc: refused: "), "{method}");
374 }
375 assert!(browser_wide("Fetch.enable", &json!({}), Some("S"), &none).is_none());
376 assert!(browser_wide(
377 "Browser.grantPermissions",
378 &json!({ "origin": "https://example.com" }),
379 None,
380 &none
381 )
382 .is_none());
383 let own: BTreeSet<String> = ["C1".to_string()].into();
384 assert!(browser_wide(
385 "Storage.clearCookies",
386 &json!({ "browserContextId": "C1" }),
387 None,
388 &own
389 )
390 .is_none());
391 assert!(browser_wide(
392 "Browser.resetPermissions",
393 &json!({ "browserContextId": "C1" }),
394 None,
395 &own
396 )
397 .is_none());
398 assert!(browser_wide(
399 "Browser.resetPermissions",
400 &json!({ "browserContextId": "C2" }),
401 None,
402 &own
403 )
404 .is_some());
405 }
406
407 #[test]
408 fn listing_keeps_only_the_origin() {
409 let info = json!({ "targetId": "T", "type": "page", "url": "https://seven.sliccy.ai/os/?k=secret", "title": "SECRET" });
410 let out = redacted(&info);
411 assert_eq!(out["url"], "https://seven.sliccy.ai");
412 assert_eq!(out["title"], "https://seven.sliccy.ai");
413 assert_eq!(
414 redacted(&json!({ "url": "about:blank", "title": "x" }))["url"],
415 "about:blank"
416 );
417 assert_eq!(
418 web_socket_as_http("WSS://seven.sliccy.ai/ws"),
419 "https://seven.sliccy.ai/ws"
420 );
421 assert!(mentions(
422 &Protected::default(),
423 &json!({ "request": { "url": "wss://seven.sliccy.ai/ws" } })
424 ));
425 assert!(!mentions(
426 &Protected::default(),
427 &json!({ "url": "https://example.com/?next=https://seven.sliccy.ai/" })
428 ));
429 }
430
431 use crate::cdp::{self as client, Cdp, COOKIES, STREAM, UNSEEN};
432 use crate::fetch::{Decision, Fulfill, Interception, Pattern};
433 use crate::raw::Raw;
434 use std::time::{Duration, Instant};
435
436 const REFUSED: &str = "slicc: refused: target is on seven's origin\n";
437
438 fn live(name: &str) -> String {
439 std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set"))
440 }
441
442 fn refused(result: Result<Value, String>, what: &str) -> String {
443 match result {
444 Ok(value) => panic!("{what} was allowed: {value}"),
445 Err(message) => {
446 assert!(message.starts_with("slicc: refused: "), "{what}: {message}");
447 message
448 }
449 }
450 }
451
452 fn until_blocked(cdp: &mut Cdp, session: &str) {
453 let deadline = Instant::now() + Duration::from_secs(15);
454 while !cdp.session_blocked(session) {
455 assert!(
456 Instant::now() < deadline,
457 "session {session} was never blocked"
458 );
459 let _ = cdp.pump(Instant::now() + Duration::from_millis(200));
460 }
461 }
462
463 fn page_ids(raw: &mut Raw) -> std::collections::BTreeSet<String> {
464 raw.call("Target.getTargets", json!({}), None).unwrap()["targetInfos"]
465 .as_array()
466 .unwrap()
467 .iter()
468 .filter(|info| info["type"] == "page")
469 .filter_map(|info| info["targetId"].as_str().map(str::to_string))
470 .collect()
471 }
472
473 fn nothing_mentions(cdp: &Cdp, needle: &str) {
474 for event in cdp.events() {
475 let text = event.to_string();
476 assert!(!text.contains(needle), "an event leaked {needle}: {text}");
477 }
478 }
479
480 fn page_on(cdp: &mut Cdp, url: &str) -> (String, String) {
481 let target = cdp
482 .call("Target.createTarget", json!({ "url": url }), None)
483 .unwrap()["targetId"]
484 .as_str()
485 .unwrap()
486 .to_string();
487 let session = client::attach(cdp, &target).unwrap();
488 cdp.call("Page.enable", json!({}), Some(&session)).unwrap();
489 let deadline = Instant::now() + Duration::from_secs(15);
490 loop {
491 let href = cdp
492 .call(
493 "Runtime.evaluate",
494 json!({ "expression": "document.readyState === 'complete' ? location.href : ''", "returnByValue": true }),
495 Some(&session),
496 )
497 .unwrap()
498 .pointer("/result/value")
499 .cloned()
500 .unwrap_or(Value::Null);
501 if href
502 .as_str()
503 .is_some_and(|href| href.starts_with(url.split('?').next().unwrap()))
504 {
505 return (target, session);
506 }
507 assert!(Instant::now() < deadline, "{url} never loaded: {href}");
508 std::thread::sleep(Duration::from_millis(100));
509 }
510 }
511
512 #[test]
513 #[ignore = "needs SLICC_FETCH_LIVE_WS from test/integration/fetch.test.mjs"]
514 fn live_protected_targets() {
515 let ws = live("SLICC_FETCH_LIVE_WS");
516 let app = live("SLICC_FETCH_LIVE_APP");
517 let seven = live("SLICC_FETCH_LIVE_SEVEN");
518 let vault = live("SLICC_FETCH_LIVE_VAULT");
519 let seven_origin = seven.trim_end_matches('/').to_string();
520 let mut raw = Raw::open(&ws);
521 let mut cdp = Cdp::connect(&ws).unwrap();
522
523 let before = page_ids(&mut raw);
524 for url in [
525 format!("{seven}/"),
526 format!("{seven}/nowhere"),
527 "HTTP://SEVEN.SLICCY.AI/x".to_string(),
528 format!("blob:{seven}/0b9e"),
529 "https://seven.sliccy.ai/".to_string(),
530 "https://www.sliccy.ai/".to_string(),
531 "https://vault.sliccy.ai/".to_string(),
532 format!("{vault}/"),
533 "view-source:https://seven.sliccy.ai/".to_string(),
534 ] {
535 let message = refused(
536 cdp.call("Target.createTarget", json!({ "url": url }), None),
537 &url,
538 );
539 assert_eq!(message, REFUSED, "{url}");
540 }
541 let made: Vec<String> = page_ids(&mut raw).difference(&before).cloned().collect();
542 assert!(made.is_empty(), "a refused createTarget made a page: {made:?}");
543 let allowed = cdp
544 .call(
545 "Target.createTarget",
546 json!({ "url": format!("{app}/?n=create") }),
547 None,
548 )
549 .unwrap()["targetId"]
550 .as_str()
551 .unwrap()
552 .to_string();
553 cdp.call("Target.closeTarget", json!({ "targetId": allowed }), None)
554 .unwrap();
555
556 {
557 let (target, session) = page_on(&mut cdp, "about:blank");
558 let message = refused(
559 cdp.call(
560 "Page.navigate",
561 json!({ "url": format!("{seven}/nowhere") }),
562 Some(&session),
563 ),
564 "navigate to seven",
565 );
566 assert_eq!(message, REFUSED);
567 refused(
568 cdp.call(
569 "Page.navigate",
570 json!({ "url": format!("view-source:{seven}/") }),
571 Some(&session),
572 ),
573 "navigate to view-source of seven",
574 );
575 let href = cdp
576 .call(
577 "Runtime.evaluate",
578 json!({ "expression": "location.href", "returnByValue": true }),
579 Some(&session),
580 )
581 .unwrap()["result"]["value"]
582 .clone();
583 assert_eq!(href, "about:blank");
584 client::detach(&mut cdp, &session);
585 cdp.call("Target.closeTarget", json!({ "targetId": target }), None)
586 .unwrap();
587 }
588
589 let seven_tab = raw.create(&format!("{seven}/landing?k=SECRETKEY"));
590 let seven_session = raw.session(&seven_tab);
591 raw.wait_href(&seven_session, &seven);
592 let vault_tab = raw.create(&format!("{vault}/landing?k=SECRETKEY"));
593 let vault_session = raw.session(&vault_tab);
594 raw.wait_href(&vault_session, &vault);
595 for (tab, origin) in [
596 (&seven_tab, seven_origin.clone()),
597 (&vault_tab, vault.trim_end_matches('/').to_string()),
598 ] {
599 let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
600 let info = listed["targetInfos"]
601 .as_array()
602 .unwrap()
603 .iter()
604 .find(|info| info["targetId"] == json!(tab))
605 .cloned()
606 .expect("the protected tab is still listed");
607 assert_eq!(info["url"], json!(origin));
608 assert_eq!(info["title"], json!(origin));
609 assert!(cdp.is_protected_target(tab));
610 for (method, params) in [
611 (
612 "Target.attachToTarget",
613 json!({ "targetId": tab, "flatten": true }),
614 ),
615 ("Target.activateTarget", json!({ "targetId": tab })),
616 ("Target.closeTarget", json!({ "targetId": tab })),
617 ("Target.exposeDevToolsProtocol", json!({ "targetId": tab })),
618 (
619 "Target.sendMessageToTarget",
620 json!({ "targetId": tab, "message": "{}" }),
621 ),
622 ("Browser.getWindowForTarget", json!({ "targetId": tab })),
623 ] {
624 refused(cdp.call(method, params, None), method);
625 }
626 let info = cdp
627 .call("Target.getTargetInfo", json!({ "targetId": tab }), None)
628 .unwrap();
629 assert_eq!(info["targetInfo"]["url"], json!(origin));
630 assert_eq!(info["targetInfo"]["title"], json!(origin));
631 }
632
633 raw.eval(
634 &seven_session,
635 "Boolean(window.open('about:blank', '_blank'))",
636 );
637 let deadline = Instant::now() + Duration::from_secs(10);
638 let popup = loop {
639 let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
640 if let Some(found) = listed["targetInfos"]
641 .as_array()
642 .unwrap()
643 .iter()
644 .find(|info| info["openerId"] == json!(seven_tab))
645 .cloned()
646 {
647 break found;
648 }
649 assert!(Instant::now() < deadline, "no popup");
650 std::thread::sleep(Duration::from_millis(100));
651 };
652 assert_eq!(popup["url"], "about:blank");
653 refused(
654 cdp.call(
655 "Target.attachToTarget",
656 json!({ "targetId": popup["targetId"], "flatten": true }),
657 None,
658 ),
659 "attach to seven's blank popup",
660 );
661 raw.close(popup["targetId"].as_str().unwrap());
662
663 {
664 let (target, session) = page_on(&mut cdp, &format!("{app}/?n=script"));
665 cdp.call("Runtime.enable", json!({}), Some(&session))
666 .unwrap();
667 cdp.call(
668 "Runtime.evaluate",
669 json!({ "expression": format!("setTimeout(() => {{ location.href = {}; }}, 50); true", json!(format!("{seven}/nowhere?secret=SCRIPTSECRET"))) }),
670 Some(&session),
671 )
672 .unwrap();
673 until_blocked(&mut cdp, &session);
674 refused(
675 cdp.call(
676 "Runtime.evaluate",
677 json!({ "expression": "document.cookie" }),
678 Some(&session),
679 ),
680 "evaluate after a script navigation to seven",
681 );
682 refused(
683 cdp.call(
684 "Target.attachToTarget",
685 json!({ "targetId": target, "flatten": true }),
686 None,
687 ),
688 "reattach after a script navigation to seven",
689 );
690 let info = cdp
691 .call("Target.getTargetInfo", json!({ "targetId": target }), None)
692 .unwrap();
693 assert_eq!(info["targetInfo"]["url"], json!(seven_origin));
694 assert_eq!(info["targetInfo"]["title"], json!(seven_origin));
695 nothing_mentions(&cdp, "SCRIPTSECRET");
696 raw.close(&target);
697 }
698
699 {
700 let (target, session) = page_on(&mut cdp, "about:blank");
701 let landing = format!("{vault}/landing?n=refresh");
702 {
703 let mut seen = Interception::on_session(
704 &mut cdp,
705 &target,
706 &session,
707 &[Pattern::request(format!("{app}/refresh*"))],
708 )
709 .unwrap();
710 let _ = seen.notify(
711 "Page.navigate",
712 json!({ "url": format!("{app}/refresh") }),
713 Some(&session),
714 );
715 let paused = seen
716 .next(Duration::from_secs(10))
717 .unwrap()
718 .expect("the page paused");
719 let body = format!("<!doctype html><meta http-equiv=\"refresh\" content=\"0;url={landing}\"><title>refresh</title>");
720 seen.decide(&paused, &Decision::Fulfill(Fulfill::html(200, body)))
721 .unwrap();
722 until_blocked(&mut seen, &session);
723 }
724 for (method, params) in [
725 (
726 "Runtime.evaluate",
727 json!({ "expression": "document.cookie" }),
728 ),
729 ("Page.captureScreenshot", json!({})),
730 (
731 "DOMSnapshot.captureSnapshot",
732 json!({ "computedStyles": [] }),
733 ),
734 (
735 "Input.dispatchKeyEvent",
736 json!({ "type": "keyDown", "key": "a" }),
737 ),
738 ("DOM.getDocument", json!({})),
739 ("Network.getCookies", json!({})),
740 ("Page.navigate", json!({ "url": format!("{app}/") })),
741 ("Fetch.enable", json!({})),
742 ] {
743 refused(cdp.call(method, params, Some(&session)), method);
744 }
745 refused(
746 cdp.call(
747 "Target.attachToTarget",
748 json!({ "targetId": target, "flatten": true }),
749 None,
750 ),
751 "reattach after the meta refresh",
752 );
753 let url = raw
754 .call("Target.getTargetInfo", json!({ "targetId": target }), None)
755 .unwrap()["targetInfo"]["url"]
756 .as_str()
757 .unwrap_or("")
758 .to_string();
759 assert!(url.starts_with(&vault), "the refresh left the tab on {url}");
760 nothing_mentions(&cdp, "n=refresh");
761 raw.close(&target);
762 }
763
764 {
765 let history = raw.create(&format!("{seven}/nowhere?n=history"));
766 let session = raw.session(&history);
767 raw.wait_href(&session, &seven);
768 raw.call(
769 "Page.navigate",
770 json!({ "url": format!("{app}/?n=history") }),
771 Some(&session),
772 )
773 .unwrap();
774 raw.wait_href(&session, &app);
775 let _ = raw.call(
776 "Target.detachFromTarget",
777 json!({ "sessionId": session }),
778 None,
779 );
780 let guarded = client::attach(&mut cdp, &history).unwrap();
781 let entries = cdp
782 .call("Page.getNavigationHistory", json!({}), Some(&guarded))
783 .unwrap()["entries"]
784 .as_array()
785 .unwrap()
786 .clone();
787 let on_seven = entries
788 .iter()
789 .find(|entry| entry["url"].as_str().unwrap().starts_with(&seven))
790 .unwrap();
791 let plain = entries
792 .iter()
793 .find(|entry| {
794 !entry["url"].as_str().unwrap().starts_with(&seven)
795 && entry["url"] != "about:blank"
796 })
797 .unwrap();
798 let message = refused(
799 cdp.call(
800 "Page.navigateToHistoryEntry",
801 json!({ "entryId": on_seven["id"] }),
802 Some(&guarded),
803 ),
804 "history entry on seven",
805 );
806 assert_eq!(message, REFUSED);
807 cdp.call(
808 "Page.navigateToHistoryEntry",
809 json!({ "entryId": plain["id"] }),
810 Some(&guarded),
811 )
812 .unwrap();
813 client::detach(&mut cdp, &guarded);
814 raw.close(&history);
815 }
816
817 {
818 raw.call(
819 "Storage.setCookies",
820 json!({ "cookies": [
821 { "name": "seven", "value": "COOKIESECRET", "domain": "seven.sliccy.ai", "path": "/" },
822 { "name": "chips", "value": "COOKIESECRET", "domain": "localhost", "path": "/", "secure": true, "partitionKey": { "topLevelSite": "https://sliccy.ai", "hasCrossSiteAncestor": false } }
823 ] }),
824 None,
825 )
826 .unwrap();
827 let (target, session) = page_on(&mut cdp, "about:blank");
828 let s = Some(session.as_str());
829 cdp.call(
830 "Network.setCookie",
831 json!({ "name": "allowed", "value": "1", "url": format!("{app}/") }),
832 s,
833 )
834 .unwrap();
835 for (method, params, on_page) in [
836 (
837 "Network.setCookie",
838 json!({ "name": "x", "value": "1", "url": format!("{seven}/") }),
839 true,
840 ),
841 (
842 "Network.setCookie",
843 json!({ "name": "x", "value": "1", "domain": "seven.sliccy.ai", "path": "/" }),
844 true,
845 ),
846 (
847 "Network.setCookie",
848 json!({ "name": "x", "value": "1", "url": "http://seven.sliccy.ai:1/" }),
849 true,
850 ),
851 (
852 "Network.setCookie",
853 json!({ "name": "x", "value": "1", "url": format!("{app}/"), "secure": true, "partitionKey": { "topLevelSite": "http://sliccy.ai", "hasCrossSiteAncestor": false } }),
854 true,
855 ),
856 (
857 "Network.setCookies",
858 json!({ "cookies": [{ "name": "y", "value": "1", "url": format!("{app}/") }, { "name": "x", "value": "1", "domain": "seven.sliccy.ai" }] }),
859 true,
860 ),
861 (
862 "Storage.setCookies",
863 json!({ "cookies": [{ "name": "x", "value": "1", "domain": "vault.sliccy.ai", "path": "/" }] }),
864 false,
865 ),
866 (
867 "Network.deleteCookies",
868 json!({ "name": "seven", "url": format!("{seven}/") }),
869 true,
870 ),
871 ] {
872 let message = refused(
873 cdp.call(method, params, if on_page { s } else { None }),
874 method,
875 );
876 assert_eq!(message, COOKIES, "{method}");
877 }
878 refused(
879 cdp.call("Network.clearBrowserCookies", json!({}), s),
880 "clearBrowserCookies",
881 );
882 refused(
883 cdp.call("Storage.clearCookies", json!({}), None),
884 "Storage.clearCookies",
885 );
886 for (method, params, on_page) in [
887 ("Storage.getCookies", json!({}), false),
888 ("Network.getAllCookies", json!({}), true),
889 (
890 "Network.getCookies",
891 json!({ "urls": [format!("{seven}/"), format!("{app}/")] }),
892 true,
893 ),
894 ] {
895 let got = cdp
896 .call(method, params, if on_page { s } else { None })
897 .unwrap();
898 let text = got.to_string();
899 assert!(!text.contains("COOKIESECRET"), "{method}: {text}");
900 assert!(text.contains("\"allowed\""), "{method}: {text}");
901 }
902 client::detach(&mut cdp, &session);
903 cdp.call("Target.closeTarget", json!({ "targetId": target }), None)
904 .unwrap();
905 }
906
907 {
908 let (target, session) = page_on(&mut cdp, "about:blank");
909 let s = Some(session.as_str());
910 let perm = json!({ "name": "geolocation" });
911 cdp.call(
912 "Browser.setPermission",
913 json!({ "permission": perm, "setting": "granted", "origin": app }),
914 None,
915 )
916 .unwrap();
917 for (method, params, on_page) in [
918 (
919 "Browser.setPermission",
920 json!({ "permission": perm, "setting": "granted", "origin": seven_origin }),
921 false,
922 ),
923 (
924 "Browser.grantPermissions",
925 json!({ "permissions": ["geolocation"] }),
926 false,
927 ),
928 (
929 "Browser.grantPermissions",
930 json!({ "permissions": ["geolocation"], "origin": seven_origin }),
931 false,
932 ),
933 ("Browser.resetPermissions", json!({}), false),
934 (
935 "Storage.clearDataForOrigin",
936 json!({ "origin": seven_origin, "storageTypes": "all" }),
937 false,
938 ),
939 (
940 "Storage.clearDataForStorageKey",
941 json!({ "storageKey": format!("{seven_origin}/"), "storageTypes": "all" }),
942 false,
943 ),
944 (
945 "Storage.getUsageAndQuota",
946 json!({ "origin": seven_origin }),
947 false,
948 ),
949 (
950 "CacheStorage.requestCacheNames",
951 json!({ "securityOrigin": seven_origin }),
952 true,
953 ),
954 (
955 "CacheStorage.requestEntries",
956 json!({ "cacheId": format!("{seven_origin}/|c1") }),
957 true,
958 ),
959 (
960 "IndexedDB.requestDatabaseNames",
961 json!({ "securityOrigin": seven_origin }),
962 true,
963 ),
964 (
965 "FileSystem.getDirectory",
966 json!({ "bucketFileSystemLocator": { "storageKey": format!("{seven_origin}/"), "pathComponents": [] } }),
967 true,
968 ),
969 (
970 "ServiceWorker.unregister",
971 json!({ "scopeURL": format!("{seven_origin}/") }),
972 true,
973 ),
974 (
975 "Target.exposeDevToolsProtocol",
976 json!({ "targetId": target }),
977 false,
978 ),
979 ("Tracing.start", json!({}), false),
980 ("Fetch.enable", json!({}), false),
981 (
982 "Network.loadNetworkResource",
983 json!({ "url": format!("{seven}/index.html"), "options": { "disableCache": true, "includeCredentials": true } }),
984 true,
985 ),
986 (
987 "Target.setAutoAttach",
988 json!({ "autoAttach": true, "flatten": true, "waitForDebuggerOnStart": false }),
989 false,
990 ),
991 ("IO.read", json!({ "handle": "not-ours" }), false),
992 ] {
993 refused(
994 cdp.call(method, params, if on_page { s } else { None }),
995 method,
996 );
997 }
998 assert_eq!(
999 cdp.call(
1000 "Network.getResponseBody",
1001 json!({ "requestId": "never-seen" }),
1002 s
1003 )
1004 .unwrap_err(),
1005 UNSEEN
1006 );
1007 assert_eq!(
1008 cdp.call("IO.read", json!({ "handle": "not-ours" }), None)
1009 .unwrap_err(),
1010 STREAM
1011 );
1012 cdp.call("Fetch.enable", json!({}), s).unwrap();
1013 cdp.call("Fetch.disable", json!({}), s).unwrap();
1014 client::detach(&mut cdp, &session);
1015 cdp.call("Target.closeTarget", json!({ "targetId": target }), None)
1016 .unwrap();
1017 }
1018
1019 {
1020 let context = cdp
1021 .call("Target.createBrowserContext", json!({}), None)
1022 .unwrap()["browserContextId"]
1023 .as_str()
1024 .unwrap()
1025 .to_string();
1026 cdp.call(
1027 "Storage.setCookies",
1028 json!({ "cookies": [{ "name": "own", "value": "1", "domain": "example.test", "path": "/" }], "browserContextId": context }),
1029 None,
1030 )
1031 .unwrap();
1032 cdp.call(
1033 "Storage.clearCookies",
1034 json!({ "browserContextId": context }),
1035 None,
1036 )
1037 .unwrap();
1038 cdp.call(
1039 "Browser.resetPermissions",
1040 json!({ "browserContextId": context }),
1041 None,
1042 )
1043 .unwrap();
1044 cdp.call(
1045 "Target.disposeBrowserContext",
1046 json!({ "browserContextId": context }),
1047 None,
1048 )
1049 .unwrap();
1050 }
1051
1052 {
1053 let (target, session) = page_on(&mut cdp, &format!("{app}/?n=fetch"));
1054 cdp.call("Network.enable", json!({}), Some(&session))
1055 .unwrap();
1056 let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
1057 let record = seen.clone();
1058 let forged = "<!doctype html><title>forged</title>";
1059 crate::fetch::enable(
1060 &mut cdp,
1061 &session,
1062 &[Pattern::request("*"), Pattern::response("*")],
1063 )
1064 .unwrap();
1065 cdp.set_decider(
1066 &session,
1067 Some(Box::new(move |paused: &crate::fetch::Paused| {
1068 record.borrow_mut().push(paused.url.clone());
1069 if paused.url.contains("sliccy") {
1070 return Decision::Fulfill(Fulfill::html(200, forged));
1071 }
1072 Decision::default()
1073 })),
1074 );
1075 cdp.call(
1076 "Page.navigate",
1077 json!({ "url": format!("{app}/?n=next") }),
1078 Some(&session),
1079 )
1080 .unwrap();
1081 cdp.wait_load(&session).unwrap();
1082 cdp.call(
1083 "Runtime.evaluate",
1084 json!({ "expression": format!("setTimeout(() => {{ location.href = {}; }}, 50); true", json!(format!("{seven}/nowhere?n=away"))) }),
1085 Some(&session),
1086 )
1087 .unwrap();
1088 until_blocked(&mut cdp, &session);
1089 assert!(
1090 seen.borrow().iter().all(|url| !url.contains("sliccy")),
1091 "{:?}",
1092 seen.borrow()
1093 );
1094 assert!(seen.borrow().iter().any(|url| url.contains("n=next")));
1095 nothing_mentions(&cdp, "n=away");
1096 let url = raw
1097 .call("Target.getTargetInfo", json!({ "targetId": target }), None)
1098 .unwrap()["targetInfo"]["url"]
1099 .as_str()
1100 .unwrap_or("")
1101 .to_string();
1102 assert!(!url.contains("forged"));
1103 let probe = raw.session(&target);
1104 let title = raw.eval(&probe, "document.title");
1105 assert_ne!(title, "forged");
1106 raw.close(&target);
1107 }
1108
1109 let hits = |raw: &mut Raw, scratch: &str| -> u64 {
1110 let probe = raw.session(scratch);
1111 raw.eval(
1112 &probe,
1113 &format!(
1114 "fetch({}).then((r) => r.text())",
1115 json!(format!("{app}/hits?path=/api/leak"))
1116 ),
1117 )
1118 .as_str()
1119 .unwrap_or("0")
1120 .parse()
1121 .unwrap_or(0)
1122 };
1123 let scratch = raw.create(&format!("{app}/one"));
1124 std::thread::sleep(Duration::from_millis(500));
1125
1126 {
1127 let leak = format!("{app}/api/leak");
1128 let before = hits(&mut raw, &scratch);
1129 let (target, session) = page_on(&mut cdp, "about:blank");
1130 let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
1131 let record = seen.clone();
1132 let refresh = format!("{app}/refresh-leak");
1133 let landing = format!(
1134 "{vault}/landing?leak={}",
1135 url::form_urlencoded::byte_serialize(leak.as_bytes()).collect::<String>()
1136 );
1137 crate::fetch::enable(&mut cdp, &session, &[Pattern::request("*")]).unwrap();
1138 cdp.set_decider(
1139 &session,
1140 Some(Box::new(move |paused: &crate::fetch::Paused| {
1141 record.borrow_mut().push(paused.url.clone());
1142 if paused.url.contains("refresh-leak") {
1143 let body = format!("<!doctype html><meta http-equiv=\"refresh\" content=\"0;url={landing}\">");
1144 return Decision::Fulfill(Fulfill::html(200, body));
1145 }
1146 Decision::default()
1147 })),
1148 );
1149 let _ = cdp.notify("Page.navigate", json!({ "url": refresh }), Some(&session));
1150 until_blocked(&mut cdp, &session);
1151 let deadline = Instant::now() + Duration::from_secs(2);
1152 while Instant::now() < deadline {
1153 let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1154 }
1155 assert!(seen.borrow().iter().any(|url| url.contains("refresh-leak")));
1156 assert!(
1157 seen.borrow()
1158 .iter()
1159 .all(|url| !url.contains("leak") || url.contains("refresh-leak")),
1160 "{:?}",
1161 seen.borrow()
1162 );
1163 assert_eq!(
1164 hits(&mut raw, &scratch),
1165 before,
1166 "the vault page ran and sent its request"
1167 );
1168 nothing_mentions(&cdp, "LEAKKEY");
1169 raw.close(&target);
1170 }
1171
1172 {
1173 let idp = live("SLICC_FETCH_LIVE_IDP");
1174 let leak = format!("{app}/api/leak");
1175 let before = hits(&mut raw, &scratch);
1176 let mut slipped = Cdp::connect(&ws).unwrap();
1177 let (target, session) = page_on(&mut slipped, &format!("{idp}/landing"));
1178 let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
1179 let record = seen.clone();
1180 crate::fetch::enable(&mut slipped, &session, &[Pattern::request("*")]).unwrap();
1181 slipped.set_decider(
1182 &session,
1183 Some(Box::new(move |paused: &crate::fetch::Paused| {
1184 record.borrow_mut().push(paused.url.clone());
1185 Decision::default()
1186 })),
1187 );
1188 let origin = idp.trim_end_matches('/').to_string();
1189 slipped.protect_origins(&[origin.as_str()]);
1190 let page = raw.session(&target);
1191 raw.eval(&page, &format!("fetch({}, {{ headers: {{ Authorization: 'Bearer LEAKKEY' }} }}).catch(() => {{}}), true", json!(leak)));
1192 let deadline = Instant::now() + Duration::from_secs(2);
1193 while Instant::now() < deadline {
1194 let _ = slipped.pump(Instant::now() + Duration::from_millis(100));
1195 }
1196 assert!(
1197 seen.borrow().iter().all(|url| !url.contains("api/leak")),
1198 "{:?}",
1199 seen.borrow()
1200 );
1201 assert_eq!(
1202 hits(&mut raw, &scratch),
1203 before + 1,
1204 "the request was not continued unchanged"
1205 );
1206 nothing_mentions(&slipped, "LEAKKEY");
1207 nothing_mentions(&slipped, "api/leak");
1208 raw.close(&target);
1209 }
1210 raw.close(&scratch);
1211
1212 nothing_mentions(&cdp, "SECRETKEY");
1213 nothing_mentions(&cdp, "COOKIESECRET");
1214 raw.close(&seven_tab);
1215 raw.close(&vault_tab);
1216 }
1217}