Skip to main content

cdp_client/
shield.rs

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, &params), "{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, &params, 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}