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 network_state(method: &str, params: &Value) -> bool {
117    method == "Network.emulateNetworkConditions"
118        && params.as_object().is_some_and(|object| {
119            object.len() == 4
120                && object.get("offline").is_some_and(Value::is_boolean)
121                && object.get("latency").and_then(Value::as_i64) == Some(0)
122                && object.get("downloadThroughput").and_then(Value::as_i64) == Some(-1)
123                && object.get("uploadThroughput").and_then(Value::as_i64) == Some(-1)
124        })
125}
126
127pub fn persistent_capability(method: &str) -> bool {
128    match method.split_once('.') {
129        Some(("Emulation", command)) => !matches!(command, "canEmulate" | "getScreenInfos"),
130        Some(("Network", command)) => {
131            command.starts_with("set") && !matches!(command, "setCookie" | "setCookies")
132                || command.starts_with("emulate")
133                || matches!(command, "overrideNetworkState" | "clearAcceptedEncodings")
134        }
135        Some(("Page", command)) => {
136            command.starts_with("addScriptToEvaluateOn")
137                || command.starts_with("set") && !matches!(command, "setDocumentContent" | "setLifecycleEventsEnabled")
138                || command == "startScreencast"
139        }
140        Some(("Runtime", command)) => command == "addBinding" || command.starts_with("set"),
141        Some(("Debugger", command)) => command.starts_with("set"),
142        Some(("Input" | "Security", command)) => command.starts_with("set"),
143        Some(("Overlay", command)) => !matches!(command, "enable" | "disable" | "hideHighlight" | "getHighlightObjectForTest" | "getGridHighlightObjectsForTest" | "getSourceOrderHighlightObjectForTest"),
144        Some(("Browser", command)) => matches!(command, "setDownloadBehavior" | "setWindowBounds" | "setContentsSize"),
145        Some(("Profiler", command)) => matches!(command, "start" | "startPreciseCoverage"),
146        Some(("HeapProfiler", command)) => matches!(command, "startSampling" | "startTrackingHeapObjects" | "takeHeapSnapshot"),
147        _ => false,
148    }
149}
150
151pub fn body_method(method: &str) -> bool {
152    BODY_METHODS.contains(&method)
153}
154
155pub fn interception_method(method: &str) -> bool {
156    INTERCEPTION_METHODS.contains(&method)
157}
158
159pub fn answers_interception(method: &str) -> bool {
160    interception_method(method) && !method.contains("ResponseBody")
161}
162
163pub fn cookie_method(method: &str) -> bool {
164    COOKIE_SETTERS.contains(&method)
165}
166
167pub fn cookie_reader(method: &str) -> bool {
168    COOKIE_READERS.contains(&method)
169}
170
171pub fn request_key(params: &Value) -> Option<String> {
172    params
173        .get("requestId")
174        .or_else(|| params.get("interceptionId"))
175        .and_then(|v| v.as_str())
176        .map(str::to_string)
177}
178
179pub fn request_url(params: &Value) -> Option<String> {
180    params
181        .pointer("/request/url")
182        .or_else(|| params.pointer("/response/url"))
183        .or_else(|| params.get("url"))
184        .and_then(|v| v.as_str())
185        .map(str::to_string)
186}
187
188pub fn web_socket_as_http(url: &str) -> String {
189    let lower = url.to_ascii_lowercase();
190    if lower.starts_with("wss:") {
191        format!("https:{}", &url[4..])
192    } else if lower.starts_with("ws:") {
193        format!("http:{}", &url[3..])
194    } else {
195        url.to_string()
196    }
197}
198
199fn url_protected(guard: &Protected, text: &str) -> bool {
200    guard.url_read_only(text) || guard.url_read_only(&web_socket_as_http(text))
201}
202
203pub fn mentions(guard: &Protected, value: &Value) -> bool {
204    match value {
205        Value::String(text) => text.contains(':') && url_protected(guard, text),
206        Value::Array(items) => items.iter().any(|item| mentions(guard, item)),
207        Value::Object(map) => map.values().any(|item| mentions(guard, item)),
208        _ => false,
209    }
210}
211
212pub(crate) fn decode_percent(text: &str) -> String {
213    let mut out = Vec::new();
214    let bytes = text.as_bytes();
215    let mut index = 0;
216    while index < bytes.len() {
217        if bytes[index] == b'%' && index + 2 < bytes.len() {
218            if let Ok(hex) = std::str::from_utf8(&bytes[index + 1..index + 3]) {
219            if let Ok(byte) = u8::from_str_radix(hex, 16) {
220                out.push(byte);
221                index += 3;
222                continue;
223            }
224            }
225        }
226        out.push(bytes[index]);
227        index += 1;
228    }
229    String::from_utf8(out).unwrap_or_else(|_| text.to_string())
230}
231
232fn encode_origin(text: &str) -> String {
233    text.bytes().map(|byte| match byte {
234        b':' | b'/' => format!("%{byte:02X}"),
235        _ => char::from(byte).to_string(),
236    }).collect()
237}
238
239pub fn redact_text(guard: &Protected, text: &str) -> (String, bool) {
240    redact_text_at(guard, text, 0)
241}
242
243fn redact_text_at(guard: &Protected, text: &str, depth: usize) -> (String, bool) {
244    let bytes = text.as_bytes();
245    let mut normalized = String::new();
246    let mut from = 0;
247    let mut index = 0;
248    let mut encoded_protected = false;
249    while index + 2 < bytes.len() {
250        if bytes[index] != b'%' || !bytes[index + 1].is_ascii_hexdigit() || !bytes[index + 2].is_ascii_hexdigit() {
251            index += 1;
252            continue;
253        }
254        let start = index;
255        while index + 2 < bytes.len() && bytes[index] == b'%' && bytes[index + 1].is_ascii_hexdigit() && bytes[index + 2].is_ascii_hexdigit() {
256            index += 3;
257        }
258        let decoded = decode_percent(&text[start..index]);
259        let (redacted, protected) = if depth < 8 { redact_text_at(guard, &decoded, depth + 1) } else { ("about:blank".to_string(), true) };
260        if protected {
261            normalized.push_str(&text[from..start]);
262            normalized.extend(redacted.bytes().map(|byte| format!("%{byte:02X}")));
263            from = index;
264            encoded_protected = true;
265        }
266    }
267    normalized.push_str(&text[from..]);
268    let text = normalized.as_str();
269    let lower = text.to_ascii_lowercase();
270    let mut prefixes = Vec::new();
271    for scheme in guard.redaction_schemes().chain(["ws", "wss", "about"]) {
272        prefixes.push(format!("{scheme}:"));
273        prefixes.push(format!("{scheme}%"));
274    }
275    let mut out = String::new();
276    let mut from = 0;
277    let mut protected = encoded_protected;
278    while from < text.len() {
279        let next = prefixes.iter().flat_map(|prefix| {
280            lower[from..].match_indices(prefix).filter_map(|(at, _)| {
281                let start = from + at;
282                let after = start + prefix.len();
283                if prefix.ends_with(':') && text[after..].chars().next().is_none_or(char::is_whitespace) {
284                    return None;
285                }
286                let web = ["http:", "https:", "ws:", "wss:", "http%", "https%", "ws%", "wss%"].contains(&prefix.as_str());
287                if !web && text[..start].chars().next_back().is_some_and(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '+' | '-' | '.')) {
288                    return None;
289                }
290                Some((start, prefix.as_str()))
291            })
292        }).min_by_key(|(at, _)| *at);
293        let Some((start, prefix)) = next else { break };
294        out.push_str(&text[from..start]);
295        let encoded = prefix.contains('%');
296        let end = text[start..].char_indices().find_map(|(at, ch)| {
297            (ch.is_whitespace() || matches!(ch, '<' | '>' | '"' | '\'') || encoded && ch == '&').then_some(start + at)
298        }).unwrap_or(text.len());
299        let raw = &text[start..end];
300        let mut levels = vec![raw.replace("\\/", "/")];
301        for _ in 0..8 {
302            let next = decode_percent(&levels[levels.len() - 1]);
303            if next == levels[levels.len() - 1] { break; }
304            levels.push(next);
305        }
306        let decoded = levels.pop().unwrap_or_default();
307        let found = levels.into_iter().find(|level| url_protected(guard, level)).or_else(|| (url_protected(guard, &decoded) || guard.unparseable(&decoded) && guard.url(&decoded)).then(|| decoded.clone()));
308        let overflow = encoded && decode_percent(&decoded) != decoded;
309        if overflow || found.is_some() {
310            protected = true;
311            let decoded = found.unwrap_or(decoded);
312            let origin = if overflow { "about:blank".to_string() } else {
313                url::Url::parse(&web_socket_as_http(&decoded)).ok().and_then(|url| {
314                    let origin = url.origin().ascii_serialization();
315                    if origin != "null" { Some(origin) } else { url.host_str().map(|host| format!("{}://{host}", url.scheme())) }
316                }).unwrap_or_else(|| "about:blank".into())
317            };
318            out.push_str(&if encoded { encode_origin(&origin) } else { origin });
319            from = end;
320        } else {
321            out.push_str(&text[start..start + prefix.len()]);
322            from = start + prefix.len();
323        }
324    }
325    out.push_str(&text[from..]);
326    (out, protected)
327}
328
329fn redact_link(guard: &Protected, text: &str) -> (String, bool) {
330    const RELATION: &str = "https://www.sliccy.ai/rel/upskill";
331    let bytes = text.as_bytes();
332    let mut spans = Vec::new();
333    let mut index = 0;
334    let mut target = false;
335    let mut quote = false;
336    while index < bytes.len() {
337        match bytes[index] {
338            b'<' if !quote => target = true,
339            b'>' if !quote => target = false,
340            b'"' if !target => quote = !quote,
341            b';' if !target && !quote => {
342                let mut at = index + 1;
343                while at < bytes.len() && bytes[at].is_ascii_whitespace() { at += 1; }
344                let name = at;
345                while at < bytes.len() && bytes[at].is_ascii_alphabetic() { at += 1; }
346                let relation = text[name..at].eq_ignore_ascii_case("rel") || text[name..at].eq_ignore_ascii_case("rev");
347                while at < bytes.len() && bytes[at].is_ascii_whitespace() { at += 1; }
348                if relation && bytes.get(at) == Some(&b'=') {
349                    at += 1;
350                    while at < bytes.len() && bytes[at].is_ascii_whitespace() { at += 1; }
351                    let quoted = bytes.get(at) == Some(&b'"');
352                    if quoted { at += 1; }
353                    let start = at;
354                    while at < bytes.len() && if quoted { bytes[at] != b'"' } else { !bytes[at].is_ascii_whitespace() && !matches!(bytes[at], b';' | b',') } { at += 1; }
355                    if &text[start..at] == RELATION { spans.push((start, at)); }
356                }
357            }
358            _ => {}
359        }
360        index += 1;
361    }
362    let mut from = 0;
363    let mut out = String::new();
364    let mut protected = false;
365    for (start, end) in spans {
366        let (redacted, found) = redact_text(guard, &text[from..start]);
367        out.push_str(&redacted);
368        out.push_str(&text[start..end]);
369        protected |= found;
370        from = end;
371    }
372    let (redacted, found) = redact_text(guard, &text[from..]);
373    out.push_str(&redacted);
374    (out, protected || found)
375}
376
377fn redact_header_block(guard: &Protected, text: &str) -> (String, bool) {
378    let mut out = String::new();
379    let mut protected = false;
380    for line in text.split_inclusive('\n') {
381        let (redacted, found) = if let Some((name, value)) = line.split_once(':').filter(|(name, _)| name.eq_ignore_ascii_case("link")) {
382            let (value, found) = redact_link(guard, value);
383            (format!("{name}:{value}"), found)
384        } else { redact_text(guard, line) };
385        out.push_str(&redacted);
386        protected |= found;
387    }
388    (out, protected)
389}
390
391pub fn scheme_less_protected(guard: &Protected, value: &Value) -> bool {
392    let authority = |name: &str, value: &str| (name.eq_ignore_ascii_case("host") || name == ":authority") && ["http", "https"].iter().any(|scheme| guard.url_read_only(&format!("{scheme}://{}/", value.trim())));
393    match value {
394        Value::Array(items) => items.iter().any(|item| scheme_less_protected(guard, item)),
395        Value::Object(map) => {
396            map.get("name").and_then(Value::as_str).zip(map.get("value").and_then(Value::as_str)).is_some_and(|(name, value)| authority(name, value))
397                || map.iter().any(|(key, item)| {
398                    item.as_str().is_some_and(|text| authority(key, text) || matches!(key.as_str(), "headersText" | "requestHeadersText") && text.lines().any(|line| line.split_once(':').is_some_and(|(name, value)| authority(name.trim(), value))))
399                        || scheme_less_protected(guard, item)
400                })
401        }
402        _ => false,
403    }
404}
405
406pub fn redact_strings(guard: &Protected, value: &mut Value) -> bool {
407    match value {
408        Value::String(text) => {
409            let (redacted, protected) = redact_text(guard, text);
410            *text = redacted;
411            protected
412        }
413        Value::Array(items) => items.iter_mut().fold(false, |found, item| redact_strings(guard, item) || found),
414        Value::Object(map) => {
415            let protected_url = |item: &Value| item.as_str().is_some_and(|text| redact_text(guard, text).1 || url_protected(guard, text));
416            let named_protected = map.iter().any(|(key, item)| {
417                key.to_ascii_lowercase().ends_with("url") && protected_url(item)
418                    || matches!(key.as_str(), "request" | "response" | "redirectResponse") && item.get("url").is_some_and(protected_url)
419            });
420            if named_protected {
421                map.retain(|key, _| !key.to_ascii_lowercase().ends_with("fragment") && key != "documentURL");
422            }
423            let link_value = map.get("name").and_then(Value::as_str).is_some_and(|name| name.eq_ignore_ascii_case("link"));
424            let mut found = named_protected;
425            for (key, mut item) in std::mem::take(map) {
426                let (key, protected) = redact_text(guard, &key);
427                found |= protected;
428                if let Some(text) = item.as_str().filter(|_| key.eq_ignore_ascii_case("link") || link_value && key == "value" || matches!(key.as_str(), "headersText" | "requestHeadersText")) {
429                    let (redacted, protected) = if matches!(key.as_str(), "headersText" | "requestHeadersText") { redact_header_block(guard, text) } else { redact_link(guard, text) };
430                    item = Value::String(redacted);
431                    found |= protected;
432                } else { found |= redact_strings(guard, &mut item); }
433                map.insert(key, item);
434            }
435            found
436        }
437        _ => false,
438    }
439}
440
441pub fn names_protected_storage(guard: &Protected, value: &Value) -> bool {
442    match value {
443        Value::Array(items) => items
444            .iter()
445            .any(|item| names_protected_storage(guard, item)),
446        Value::Object(map) => map.iter().any(|(key, item)| {
447            if FREE_FORM.contains(&key.as_str()) {
448                return false;
449            }
450            if let Some(text) = item.as_str() {
451                if ORIGIN_KEYS.contains(&key.as_str()) && url_protected(guard, text) {
452                    return true;
453                }
454                if key == "storageKey" && guard.storage_key(text) {
455                    return true;
456                }
457                if key == "cacheId" && guard.storage_key(text.split('|').next().unwrap_or("")) {
458                    return true;
459                }
460                return false;
461            }
462            names_protected_storage(guard, item)
463        }),
464        _ => false,
465    }
466}
467
468fn host_of(text: &str) -> Option<String> {
469    url::Url::parse(text)
470        .ok()
471        .and_then(|url| url.host_str().map(str::to_string))
472}
473
474fn site_protected(guard: &Protected, site: &str) -> bool {
475    url_protected(guard, site) || host_of(site).is_some_and(|host| guard.cookie_domain(&host))
476}
477
478pub fn cookie_protected(guard: &Protected, cookie: &Value) -> bool {
479    if let Some(domain) = cookie.get("domain").and_then(|v| v.as_str()) {
480        if guard.cookie_scope(domain) {
481            return true;
482        }
483    }
484    if let Some(url) = cookie.get("url").and_then(|v| v.as_str()) {
485        if url_protected(guard, url) || host_of(url).is_some_and(|host| guard.cookie_domain(&host))
486        {
487            return true;
488        }
489    }
490    match cookie.get("partitionKey") {
491        Some(Value::String(site)) => site_protected(guard, site),
492        Some(key) => key
493            .get("topLevelSite")
494            .and_then(|v| v.as_str())
495            .is_some_and(|site| site_protected(guard, site)),
496        None => false,
497    }
498}
499
500pub fn command_url_refusal<'a>(guard: &'a Protected, params: &Value, method: &str) -> Option<&'a str> {
501    let filtered = cookie_reader(method);
502    let protected = |url: &str| {
503        if method == "Slicc.action" { guard.url_read_only(url) } else { guard.url(url) }
504    };
505    let urls = params.get("url").and_then(Value::as_str).into_iter().chain(
506        params.get("urls").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str)
507    );
508    for url in urls {
509        if protected(url) && (!filtered || guard.unparseable(url)) {
510            return Some(guard.refusal_for_url(url));
511        }
512        let mapped = web_socket_as_http(url);
513        if protected(&mapped) && (!filtered || guard.unparseable(&mapped)) {
514            return Some(guard.refusal_for_url(&mapped));
515        }
516    }
517    params.get("cookies").and_then(Value::as_array).into_iter().flatten()
518        .find_map(|cookie| command_url_refusal(guard, cookie, method))
519}
520
521pub fn loaded_urls_protected(guard: &Protected, value: &Value) -> bool {
522    match value {
523        Value::Array(items) => items.iter().any(|item| loaded_urls_protected(guard, item)),
524        Value::Object(items) => items.iter().any(|(key, item)| {
525            if FREE_FORM.contains(&key.as_str()) {
526                return false;
527            }
528            if matches!(key.as_str(), "url" | "scriptURL" | "scopeURL" | "origin" | "securityOrigin") {
529                if let Some(url) = item.as_str() {
530                    return guard.url(url) || guard.url(&web_socket_as_http(url));
531                }
532            }
533            loaded_urls_protected(guard, item)
534        }),
535        _ => false,
536    }
537}
538
539pub fn cookie_refused(guard: &Protected, params: &Value) -> bool {
540    match params.get("cookies").and_then(|v| v.as_array()) {
541        Some(cookies) => cookies.iter().any(|cookie| cookie_protected(guard, cookie)),
542        None => cookie_protected(guard, params),
543    }
544}
545
546pub fn drop_protected_cookies(guard: &Protected, result: &mut Value) {
547    if let Some(cookies) = result.get_mut("cookies").and_then(|v| v.as_array_mut()) {
548        cookies.retain(|cookie| !cookie_protected(guard, cookie));
549    }
550}
551
552pub fn set_cookie_hidden(guard: &Protected, value: &str, base: &str) -> bool {
553    if guard.url(base) {
554        return true;
555    }
556    value.lines().any(|line| {
557        let domains: Vec<&str> = line.split(';').skip(1).filter_map(|attribute| {
558            let (name, value) = attribute.trim().split_once('=')?;
559            name.trim().eq_ignore_ascii_case("domain").then_some(value.trim())
560        }).collect();
561        if domains.is_empty() {
562            host_of(base).is_none_or(|host| guard.cookie_scope(&host))
563        } else {
564            domains.iter().any(|domain| guard.cookie_scope(domain))
565        }
566    })
567}
568
569fn contains_cookie_value(value: &Value, protected_values: &[String]) -> bool {
570    match value {
571        Value::String(text) => protected_values.iter().any(|value| text.contains(value)),
572        Value::Array(items) => items.iter().any(|item| contains_cookie_value(item, protected_values)),
573        Value::Object(items) => items.values().any(|item| contains_cookie_value(item, protected_values)),
574        _ => false,
575    }
576}
577
578fn cookie_event_base(event: &Value, urls: &std::collections::BTreeMap<String, String>) -> String {
579    event.pointer("/params/request/url").or_else(|| event.pointer("/params/response/url"))
580        .and_then(Value::as_str).map(str::to_string).or_else(|| {
581            urls.get(event.pointer("/params/requestId")?.as_str()?).cloned()
582        }).unwrap_or_default()
583}
584
585pub fn redact_cookie_events(guard: &Protected, events: &mut Vec<Value>, jar: &Value) {
586    let cookies: Vec<&Value> = jar.get("cookies").and_then(Value::as_array).into_iter().flatten().collect();
587    let mut protected_values: Vec<String> = cookies.iter().filter(|cookie| cookie_protected(guard, cookie))
588        .filter_map(|cookie| cookie.get("value").and_then(Value::as_str)).filter(|value| !value.is_empty()).map(str::to_string).collect();
589    let urls: std::collections::BTreeMap<String, String> = events.iter().filter_map(|event| {
590        Some((event.pointer("/params/requestId")?.as_str()?.to_string(), event.pointer("/params/request/url")?.as_str()?.to_string()))
591    }).collect();
592    for event in events.iter() {
593        let base = cookie_event_base(event, &urls);
594        for pointer in ["/params/headers", "/params/response/headers"] {
595            if let Some(headers) = event.pointer(pointer).and_then(Value::as_object) {
596                for (_, value) in headers.iter().filter(|(name, _)| name.eq_ignore_ascii_case("set-cookie")) {
597                    for line in value.as_str().unwrap_or_default().lines() {
598                        if set_cookie_hidden(guard, line, &base) {
599                            if let Some((_, value)) = line.split(';').next().unwrap_or_default().split_once('=') {
600                                let value = value.trim();
601                                if !value.is_empty() {
602                                    protected_values.push(value.to_string());
603                                }
604                            }
605                        }
606                    }
607                }
608            }
609        }
610    }
611    events.retain_mut(|event| {
612        if event.get("method").and_then(Value::as_str) == Some("Runtime.consoleAPICalled") {
613            if event.pointer("/params/args").is_some_and(|args| contains_cookie_value(args, &protected_values)) {
614                return false;
615            }
616        }
617        let base = cookie_event_base(event, &urls);
618        for pointer in ["/params/headers", "/params/request/headers", "/params/response/headers"] {
619            let Some(headers) = event.pointer_mut(pointer).and_then(Value::as_object_mut) else { continue; };
620            for (name, value) in headers {
621                let Some(text) = value.as_str() else { continue; };
622                let hidden = if name.eq_ignore_ascii_case("set-cookie") {
623                    set_cookie_hidden(guard, text, &base)
624                } else if name.eq_ignore_ascii_case("cookie") {
625                    text.split(';').any(|pair| {
626                        let Some((name, value)) = pair.trim().split_once('=') else { return true; };
627                        let matching: Vec<&&Value> = cookies.iter().filter(|cookie| {
628                            cookie.get("name").and_then(Value::as_str) == Some(name.trim())
629                                && cookie.get("value").and_then(Value::as_str) == Some(value.trim())
630                        }).collect();
631                        if matching.is_empty() { guard.cookie_url(&base) } else {
632                            matching.iter().any(|cookie| cookie_protected(guard, cookie))
633                        }
634                    })
635                } else { false };
636                if hidden {
637                    *value = Value::String("[redacted]".to_string());
638                }
639            }
640        }
641        true
642    });
643}
644
645pub fn strip_protected_associated(guard: &Protected, event: &mut Value) {
646    if let Some(cookies) = event
647        .pointer_mut("/params/associatedCookies")
648        .and_then(|v| v.as_array_mut())
649    {
650        cookies.retain(|item| {
651            !item
652                .get("cookie")
653                .is_some_and(|cookie| cookie_protected(guard, cookie))
654        });
655    }
656}
657
658pub fn redacted(info: &Value) -> Value {
659    let mut out = info.clone();
660    let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("");
661    let origin = url::Url::parse(url)
662        .ok()
663        .map(|parsed| parsed.origin().ascii_serialization())
664        .filter(|origin| origin != "null")
665        .unwrap_or_else(|| "about:blank".to_string());
666    out["url"] = Value::String(origin.clone());
667    out["title"] = Value::String(origin);
668    out
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use serde_json::json;
675
676    #[test]
677    fn origins_and_storage_keys_are_found_outside_free_form_fields() {
678        let guard = Protected::default();
679        assert!(names_protected_storage(
680            &guard,
681            &json!({ "origin": "https://seven.sliccy.ai" })
682        ));
683        assert!(names_protected_storage(
684            &guard,
685            &json!({ "securityOrigin": "https://vault.sliccy.ai" })
686        ));
687        assert!(names_protected_storage(
688            &guard,
689            &json!({ "storageKey": "https://seven.sliccy.ai/" })
690        ));
691        assert!(names_protected_storage(
692            &guard,
693            &json!({ "cacheId": "https://seven.sliccy.ai/|c1" })
694        ));
695        assert!(names_protected_storage(
696            &guard,
697            &json!({ "bucketFileSystemLocator": { "storageKey": "https://sliccy.ai/", "pathComponents": [] } })
698        ));
699        assert!(names_protected_storage(
700            &guard,
701            &json!({ "scopeURL": "https://seven.sliccy.ai/" })
702        ));
703        assert!(!names_protected_storage(
704            &guard,
705            &json!({ "origin": "https://example.com" })
706        ));
707        assert!(!names_protected_storage(
708            &guard,
709            &json!({ "expression": "'https://seven.sliccy.ai'" })
710        ));
711        assert!(!names_protected_storage(
712            &guard,
713            &json!({ "value": { "origin": "https://seven.sliccy.ai" } })
714        ));
715    }
716
717    #[test]
718    fn cookies_on_seven_are_refused_and_filtered() {
719        let guard = Protected::default();
720        for params in [
721            json!({ "name": "x", "value": "1", "url": "https://seven.sliccy.ai/" }),
722            json!({ "name": "x", "value": "1", "domain": ".sliccy.ai", "path": "/" }),
723            json!({ "name": "x", "value": "1", "url": "http://seven.sliccy.ai:1/" }),
724            json!({ "name": "x", "value": "1", "url": "https://example.com/", "partitionKey": { "topLevelSite": "https://sliccy.ai" } }),
725            json!({ "cookies": [{ "name": "y", "url": "https://example.com/" }, { "name": "x", "domain": "vault.sliccy.ai" }] }),
726        ] {
727            assert!(cookie_refused(&guard, &params), "{params}");
728        }
729        assert!(!cookie_refused(
730            &guard,
731            &json!({ "name": "a", "url": "https://example.com/" })
732        ));
733        let mut result = json!({ "cookies": [
734            { "name": "seven", "domain": "seven.sliccy.ai" },
735            { "name": "apex", "domain": ".sliccy.ai" },
736            { "name": "chips", "domain": "example.com", "partitionKey": { "topLevelSite": "https://sliccy.ai" } },
737            { "name": "ok", "domain": "example.com" }
738        ] });
739        drop_protected_cookies(&guard, &mut result);
740        assert_eq!(
741            result,
742            json!({ "cookies": [{ "name": "ok", "domain": "example.com" }] })
743        );
744    }
745
746    #[test]
747    fn browser_wide_commands_are_refused_unless_in_an_own_context() {
748        let none = BTreeSet::new();
749        for (method, params, session) in [
750            (
751                "Target.exposeDevToolsProtocol",
752                json!({ "targetId": "T" }),
753                None,
754            ),
755            ("Target.sendMessageToTarget", json!({}), None),
756            ("Tracing.start", json!({}), None),
757            ("Extensions.loadUnpacked", json!({ "path": "/x" }), None),
758            (
759                "Browser.grantPermissions",
760                json!({ "permissions": ["geolocation"] }),
761                None,
762            ),
763            ("Browser.resetPermissions", json!({}), None),
764            ("Storage.clearCookies", json!({}), None),
765            ("Network.clearBrowserCookies", json!({}), Some("S")),
766            ("Fetch.enable", json!({}), None),
767        ] {
768            let refused = browser_wide(method, &params, session, &none).unwrap();
769            assert!(refused.starts_with("slicc: refused: "), "{method}");
770        }
771        assert!(browser_wide("Fetch.enable", &json!({}), Some("S"), &none).is_none());
772        assert!(browser_wide(
773            "Browser.grantPermissions",
774            &json!({ "origin": "https://example.com" }),
775            None,
776            &none
777        )
778        .is_none());
779        let own: BTreeSet<String> = ["C1".to_string()].into();
780        assert!(browser_wide(
781            "Storage.clearCookies",
782            &json!({ "browserContextId": "C1" }),
783            None,
784            &own
785        )
786        .is_none());
787        assert!(browser_wide(
788            "Browser.resetPermissions",
789            &json!({ "browserContextId": "C1" }),
790            None,
791            &own
792        )
793        .is_none());
794        assert!(browser_wide(
795            "Browser.resetPermissions",
796            &json!({ "browserContextId": "C2" }),
797            None,
798            &own
799        )
800        .is_some());
801    }
802
803    #[test]
804    fn listing_keeps_only_the_origin() {
805        let info = json!({ "targetId": "T", "type": "page", "url": "https://seven.sliccy.ai/os/?k=secret", "title": "SECRET" });
806        let out = redacted(&info);
807        assert_eq!(out["url"], "https://seven.sliccy.ai");
808        assert_eq!(out["title"], "https://seven.sliccy.ai");
809        assert_eq!(
810            redacted(&json!({ "url": "about:blank", "title": "x" }))["url"],
811            "about:blank"
812        );
813        assert_eq!(
814            web_socket_as_http("WSS://seven.sliccy.ai/ws"),
815            "https://seven.sliccy.ai/ws"
816        );
817        assert!(mentions(
818            &Protected::default(),
819            &json!({ "request": { "url": "wss://seven.sliccy.ai/ws" } })
820        ));
821        assert!(!mentions(
822            &Protected::default(),
823            &json!({ "url": "https://example.com/?next=https://seven.sliccy.ai/" })
824        ));
825    }
826
827    use crate::cdp::{self as client, Cdp, COOKIES, STREAM, UNSEEN};
828    use crate::fetch::{Decision, Fulfill, Interception, Pattern};
829    use crate::raw::Raw;
830    use std::time::{Duration, Instant};
831
832    const REFUSED: &str = "slicc: refused: target is on seven's origin\n";
833
834    fn live(name: &str) -> String {
835        std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set"))
836    }
837
838    fn refused(result: Result<Value, String>, what: &str) -> String {
839        match result {
840            Ok(value) => panic!("{what} was allowed: {value}"),
841            Err(message) => {
842                assert!(message.starts_with("slicc: refused: "), "{what}: {message}");
843                message
844            }
845        }
846    }
847
848    fn until_allowed_error(cdp: &mut Cdp, raw: &mut Raw, target: &str, session: &str, failed_url: &str) {
849        let deadline = Instant::now() + Duration::from_secs(15);
850        while !cdp.paused_trail.iter().any(|(_, url, reason)| url == failed_url && matches!(*reason, "guard session" | "reaches a protected origin")) {
851            assert!(Instant::now() < deadline, "protected document request was not intercepted: {failed_url}");
852            let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
853        }
854        let observer = raw.session(target);
855        let deadline = Instant::now() + Duration::from_secs(15);
856        let mut last_ready = None;
857        loop {
858            let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
859            let tree = raw.call("Page.getFrameTree", json!({}), Some(&observer));
860            let info = raw.call("Target.getTargetInfo", json!({ "targetId": target }), None);
861            let qualifies = tree.as_ref().ok().and_then(|tree| tree.pointer("/frameTree/frame")).is_some_and(|frame| {
862                cdp.guard().error_document(
863                    frame["url"].as_str().unwrap_or(""),
864                    frame["securityOrigin"].as_str().unwrap_or(""),
865                ) && frame["unreachableUrl"].as_str() == Some(failed_url)
866                    && frame.get("parentId").is_none()
867            }) && info.as_ref().ok().and_then(|info| info.pointer("/targetInfo/url")).and_then(Value::as_str) == Some(failed_url);
868            if qualifies {
869                let ready = cdp.call("Runtime.evaluate", json!({ "expression": "document.readyState", "returnByValue": true }), Some(session));
870                if ready.as_ref().ok().and_then(|out| out.pointer("/result/value")).and_then(Value::as_str) == Some("complete") {
871                    assert!(!cdp.session_blocked(session));
872                    assert!(!cdp.target_protected(target, false).unwrap());
873                    break;
874                }
875                last_ready = Some(ready);
876            }
877            assert!(Instant::now() < deadline, "allowed error page never settled: tree={tree:?} info={info:?} last_ready={last_ready:?} subject_blocked={} target_protected={} content_blocked={} guard_document={}", cdp.session_blocked(session), cdp.is_protected_target(target), cdp.content_blocked(session), cdp.document_evidence(target));
878        }
879        raw.call("Target.detachFromTarget", json!({ "sessionId": observer }), None).unwrap();
880    }
881
882    fn page_ids(raw: &mut Raw) -> std::collections::BTreeSet<String> {
883        raw.call("Target.getTargets", json!({}), None).unwrap()["targetInfos"]
884            .as_array()
885            .unwrap()
886            .iter()
887            .filter(|info| info["type"] == "page")
888            .filter_map(|info| info["targetId"].as_str().map(str::to_string))
889            .collect()
890    }
891
892    fn nothing_mentions(cdp: &Cdp, needle: &str) {
893        for event in cdp.events() {
894            let text = event.to_string();
895            assert!(!text.contains(needle), "an event leaked {needle}: {text}");
896        }
897    }
898
899    fn page_on(cdp: &mut Cdp, url: &str) -> (String, String) {
900        let target = cdp
901            .call("Target.createTarget", json!({ "url": url }), None)
902            .unwrap()["targetId"]
903            .as_str()
904            .unwrap()
905            .to_string();
906        let session = client::attach(cdp, &target).unwrap();
907        cdp.call("Page.enable", json!({}), Some(&session)).unwrap();
908        let deadline = Instant::now() + Duration::from_secs(15);
909        loop {
910            let href = cdp
911                .call(
912                    "Runtime.evaluate",
913                    json!({ "expression": "document.readyState === 'complete' ? location.href : ''", "returnByValue": true }),
914                    Some(&session),
915                )
916                .unwrap()
917                .pointer("/result/value")
918                .cloned()
919                .unwrap_or(Value::Null);
920            if href
921                .as_str()
922                .is_some_and(|href| href.starts_with(url.split('?').next().unwrap()))
923            {
924                return (target, session);
925            }
926            assert!(Instant::now() < deadline, "{url} never loaded: {href}");
927            std::thread::sleep(Duration::from_millis(100));
928        }
929    }
930
931    #[test]
932    #[ignore = "needs SLICC_FETCH_LIVE_WS from test/integration/fetch.test.mjs"]
933    fn live_protected_targets() {
934        let ws = live("SLICC_FETCH_LIVE_WS");
935        let app = live("SLICC_FETCH_LIVE_APP");
936        let seven = live("SLICC_FETCH_LIVE_SEVEN");
937        let vault = live("SLICC_FETCH_LIVE_VAULT");
938        let seven_origin = seven.trim_end_matches('/').to_string();
939        let mut raw = Raw::open(&ws);
940        let mut cdp = Cdp::connect(&ws).unwrap();
941
942        let before = page_ids(&mut raw);
943        for url in [
944            format!("{seven}/"),
945            format!("{seven}/nowhere"),
946            "HTTP://SEVEN.SLICCY.AI/x".to_string(),
947            format!("blob:{seven}/0b9e"),
948            "https://seven.sliccy.ai/".to_string(),
949            "https://www.sliccy.ai/".to_string(),
950            "https://vault.sliccy.ai/".to_string(),
951            format!("{vault}/"),
952            "view-source:https://seven.sliccy.ai/".to_string(),
953        ] {
954            let message = refused(
955                cdp.call("Target.createTarget", json!({ "url": url }), None),
956                &url,
957            );
958            assert_eq!(message, REFUSED, "{url}");
959        }
960        let made: Vec<String> = page_ids(&mut raw).difference(&before).cloned().collect();
961        assert!(made.is_empty(), "a refused createTarget made a page: {made:?}");
962        let allowed = cdp
963            .call(
964                "Target.createTarget",
965                json!({ "url": format!("{app}/?n=create") }),
966                None,
967            )
968            .unwrap()["targetId"]
969            .as_str()
970            .unwrap()
971            .to_string();
972        cdp.call("Target.closeTarget", json!({ "targetId": allowed }), None)
973            .unwrap();
974
975        {
976            let (target, session) = page_on(&mut cdp, "about:blank");
977            let message = refused(
978                cdp.call(
979                    "Page.navigate",
980                    json!({ "url": format!("{seven}/nowhere") }),
981                    Some(&session),
982                ),
983                "navigate to seven",
984            );
985            assert_eq!(message, REFUSED);
986            refused(
987                cdp.call(
988                    "Page.navigate",
989                    json!({ "url": format!("view-source:{seven}/") }),
990                    Some(&session),
991                ),
992                "navigate to view-source of seven",
993            );
994            let href = cdp
995                .call(
996                    "Runtime.evaluate",
997                    json!({ "expression": "location.href", "returnByValue": true }),
998                    Some(&session),
999                )
1000                .unwrap()["result"]["value"]
1001                .clone();
1002            assert_eq!(href, "about:blank");
1003            client::detach(&mut cdp, &session);
1004            cdp.call("Target.closeTarget", json!({ "targetId": target }), None)
1005                .unwrap();
1006        }
1007
1008        let seven_tab = raw.create(&format!("{seven}/landing?k=SECRETKEY"));
1009        let seven_session = raw.session(&seven_tab);
1010        raw.wait_href(&seven_session, &seven);
1011        let vault_tab = raw.create(&format!("{vault}/landing?k=SECRETKEY"));
1012        let vault_session = raw.session(&vault_tab);
1013        raw.wait_href(&vault_session, &vault);
1014        for (tab, origin) in [
1015            (&seven_tab, seven_origin.clone()),
1016            (&vault_tab, vault.trim_end_matches('/').to_string()),
1017        ] {
1018            let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
1019            let info = listed["targetInfos"]
1020                .as_array()
1021                .unwrap()
1022                .iter()
1023                .find(|info| info["targetId"] == json!(tab))
1024                .cloned()
1025                .expect("the protected tab is still listed");
1026            assert_eq!(info["url"], json!(origin));
1027            assert_eq!(info["title"], json!(origin));
1028            assert!(cdp.is_protected_target(tab));
1029            for (method, params) in [
1030                (
1031                    "Target.attachToTarget",
1032                    json!({ "targetId": tab, "flatten": true }),
1033                ),
1034                ("Target.activateTarget", json!({ "targetId": tab })),
1035                ("Target.closeTarget", json!({ "targetId": tab })),
1036                ("Target.exposeDevToolsProtocol", json!({ "targetId": tab })),
1037                (
1038                    "Target.sendMessageToTarget",
1039                    json!({ "targetId": tab, "message": "{}" }),
1040                ),
1041                ("Browser.getWindowForTarget", json!({ "targetId": tab })),
1042            ] {
1043                refused(cdp.call(method, params, None), method);
1044            }
1045            let info = cdp
1046                .call("Target.getTargetInfo", json!({ "targetId": tab }), None)
1047                .unwrap();
1048            assert_eq!(info["targetInfo"]["url"], json!(origin));
1049            assert_eq!(info["targetInfo"]["title"], json!(origin));
1050        }
1051
1052        raw.eval(
1053            &seven_session,
1054            "Boolean(window.open('about:blank', '_blank'))",
1055        );
1056        let deadline = Instant::now() + Duration::from_secs(10);
1057        let popup = loop {
1058            let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
1059            if let Some(found) = listed["targetInfos"]
1060                .as_array()
1061                .unwrap()
1062                .iter()
1063                .find(|info| info["openerId"] == json!(seven_tab))
1064                .cloned()
1065            {
1066                break found;
1067            }
1068            assert!(Instant::now() < deadline, "no popup");
1069            std::thread::sleep(Duration::from_millis(100));
1070        };
1071        assert_eq!(popup["url"], "about:blank");
1072        refused(
1073            cdp.call(
1074                "Target.attachToTarget",
1075                json!({ "targetId": popup["targetId"], "flatten": true }),
1076                None,
1077            ),
1078            "attach to seven's blank popup",
1079        );
1080        raw.close(popup["targetId"].as_str().unwrap());
1081
1082        {
1083            let (target, session) = page_on(&mut cdp, &format!("{app}/?n=script"));
1084            cdp.call("Runtime.enable", json!({}), Some(&session))
1085                .unwrap();
1086            cdp.call(
1087                "Runtime.evaluate",
1088                json!({ "expression": format!("setTimeout(() => {{ location.href = {}; }}, 50); true", json!(format!("{seven}/nowhere?secret=SCRIPTSECRET"))) }),
1089                Some(&session),
1090            )
1091            .unwrap();
1092            until_allowed_error(&mut cdp, &mut raw, &target, &session, &format!("{seven}/nowhere?secret=SCRIPTSECRET"));
1093            refused(cdp.call("Runtime.evaluate", json!({ "expression": "document.cookie" }), Some(&session)), "content of a protected error page");
1094            let reattached = client::attach(&mut cdp, &target).unwrap();
1095            client::detach(&mut cdp, &reattached);
1096            let info = cdp
1097                .call("Target.getTargetInfo", json!({ "targetId": target }), None)
1098                .unwrap();
1099            assert_eq!(info["targetInfo"]["url"], json!(seven_origin));
1100            assert_eq!(info["targetInfo"]["title"], json!(seven_origin));
1101            nothing_mentions(&cdp, "SCRIPTSECRET");
1102            raw.close(&target);
1103        }
1104
1105        {
1106            let (target, session) = page_on(&mut cdp, "about:blank");
1107            let landing = format!("{vault}/landing?n=refresh");
1108            {
1109                let mut seen = Interception::on_session(
1110                    &mut cdp,
1111                    &target,
1112                    &session,
1113                    &[Pattern::request(format!("{app}/refresh*"))],
1114                )
1115                .unwrap();
1116                let _ = seen.notify(
1117                    "Page.navigate",
1118                    json!({ "url": format!("{app}/refresh") }),
1119                    Some(&session),
1120                );
1121                let paused = seen
1122                    .next(Duration::from_secs(10))
1123                    .unwrap()
1124                    .expect("the page paused");
1125                let body = format!("<!doctype html><meta http-equiv=\"refresh\" content=\"0;url={landing}\"><title>refresh</title>");
1126                seen.decide(&paused, &Decision::Fulfill(Fulfill::html(200, body)))
1127                    .unwrap();
1128                until_allowed_error(&mut seen, &mut raw, &target, &session, &landing);
1129            }
1130            let reattached = client::attach(&mut cdp, &target).unwrap();
1131            client::detach(&mut cdp, &reattached);
1132            refused(cdp.call("Page.navigate", json!({ "url": landing }), Some(&session)), "a protected destination is still refused from an error page");
1133            let url = raw
1134                .call("Target.getTargetInfo", json!({ "targetId": target }), None)
1135                .unwrap()["targetInfo"]["url"]
1136                .as_str()
1137                .unwrap_or("")
1138                .to_string();
1139            assert!(url.starts_with(&vault), "the refresh left the tab on {url}");
1140            nothing_mentions(&cdp, "n=refresh");
1141            raw.close(&target);
1142        }
1143
1144        {
1145            let history = raw.create(&format!("{seven}/nowhere?n=history"));
1146            let session = raw.session(&history);
1147            raw.wait_href(&session, &seven);
1148            raw.call(
1149                "Page.navigate",
1150                json!({ "url": format!("{app}/?n=history") }),
1151                Some(&session),
1152            )
1153            .unwrap();
1154            raw.wait_href(&session, &app);
1155            let _ = raw.call(
1156                "Target.detachFromTarget",
1157                json!({ "sessionId": session }),
1158                None,
1159            );
1160            let guarded = client::attach(&mut cdp, &history).unwrap();
1161            let entries = cdp
1162                .call("Page.getNavigationHistory", json!({}), Some(&guarded))
1163                .unwrap()["entries"]
1164                .as_array()
1165                .unwrap()
1166                .clone();
1167            let on_seven = entries
1168                .iter()
1169                .find(|entry| entry["url"].as_str().unwrap().starts_with(&seven))
1170                .unwrap();
1171            let plain = entries
1172                .iter()
1173                .find(|entry| {
1174                    !entry["url"].as_str().unwrap().starts_with(&seven)
1175                        && entry["url"] != "about:blank"
1176                })
1177                .unwrap();
1178            let message = refused(
1179                cdp.call(
1180                    "Page.navigateToHistoryEntry",
1181                    json!({ "entryId": on_seven["id"] }),
1182                    Some(&guarded),
1183                ),
1184                "history entry on seven",
1185            );
1186            assert_eq!(message, REFUSED);
1187            cdp.call(
1188                "Page.navigateToHistoryEntry",
1189                json!({ "entryId": plain["id"] }),
1190                Some(&guarded),
1191            )
1192            .unwrap();
1193            client::detach(&mut cdp, &guarded);
1194            raw.close(&history);
1195        }
1196
1197        {
1198            raw.call(
1199                "Storage.setCookies",
1200                json!({ "cookies": [
1201                    { "name": "seven", "value": "COOKIESECRET", "domain": "seven.sliccy.ai", "path": "/" },
1202                    { "name": "chips", "value": "COOKIESECRET", "domain": "localhost", "path": "/", "secure": true, "partitionKey": { "topLevelSite": "https://sliccy.ai", "hasCrossSiteAncestor": false } }
1203                ] }),
1204                None,
1205            )
1206            .unwrap();
1207            let (target, session) = page_on(&mut cdp, "about:blank");
1208            let s = Some(session.as_str());
1209            cdp.call(
1210                "Network.setCookie",
1211                json!({ "name": "allowed", "value": "1", "url": format!("{app}/") }),
1212                s,
1213            )
1214            .unwrap();
1215            for (method, params, on_page) in [
1216                (
1217                    "Network.setCookie",
1218                    json!({ "name": "x", "value": "1", "url": format!("{seven}/") }),
1219                    true,
1220                ),
1221                (
1222                    "Network.setCookie",
1223                    json!({ "name": "x", "value": "1", "domain": "seven.sliccy.ai", "path": "/" }),
1224                    true,
1225                ),
1226                (
1227                    "Network.setCookie",
1228                    json!({ "name": "x", "value": "1", "url": "http://seven.sliccy.ai:1/" }),
1229                    true,
1230                ),
1231                (
1232                    "Network.setCookie",
1233                    json!({ "name": "x", "value": "1", "url": format!("{app}/"), "secure": true, "partitionKey": { "topLevelSite": "http://sliccy.ai", "hasCrossSiteAncestor": false } }),
1234                    true,
1235                ),
1236                (
1237                    "Network.setCookies",
1238                    json!({ "cookies": [{ "name": "y", "value": "1", "url": format!("{app}/") }, { "name": "x", "value": "1", "domain": "seven.sliccy.ai" }] }),
1239                    true,
1240                ),
1241                (
1242                    "Storage.setCookies",
1243                    json!({ "cookies": [{ "name": "x", "value": "1", "domain": "vault.sliccy.ai", "path": "/" }] }),
1244                    false,
1245                ),
1246                (
1247                    "Network.deleteCookies",
1248                    json!({ "name": "seven", "url": format!("{seven}/") }),
1249                    true,
1250                ),
1251            ] {
1252                let message = refused(
1253                    cdp.call(method, params, if on_page { s } else { None }),
1254                    method,
1255                );
1256                assert_eq!(message, COOKIES, "{method}");
1257            }
1258            refused(
1259                cdp.call("Network.clearBrowserCookies", json!({}), s),
1260                "clearBrowserCookies",
1261            );
1262            refused(
1263                cdp.call("Storage.clearCookies", json!({}), None),
1264                "Storage.clearCookies",
1265            );
1266            for (method, params, on_page) in [
1267                ("Storage.getCookies", json!({}), false),
1268                ("Network.getAllCookies", json!({}), true),
1269                (
1270                    "Network.getCookies",
1271                    json!({ "urls": [format!("{seven}/"), format!("{app}/")] }),
1272                    true,
1273                ),
1274            ] {
1275                let got = cdp
1276                    .call(method, params, if on_page { s } else { None })
1277                    .unwrap();
1278                let text = got.to_string();
1279                assert!(!text.contains("COOKIESECRET"), "{method}: {text}");
1280                assert!(text.contains("\"allowed\""), "{method}: {text}");
1281            }
1282            client::detach(&mut cdp, &session);
1283            cdp.call("Target.closeTarget", json!({ "targetId": target }), None)
1284                .unwrap();
1285        }
1286
1287        {
1288            let (target, session) = page_on(&mut cdp, "about:blank");
1289            let s = Some(session.as_str());
1290            let perm = json!({ "name": "geolocation" });
1291            cdp.call(
1292                "Browser.setPermission",
1293                json!({ "permission": perm, "setting": "granted", "origin": app }),
1294                None,
1295            )
1296            .unwrap();
1297            for (method, params, on_page) in [
1298                (
1299                    "Browser.setPermission",
1300                    json!({ "permission": perm, "setting": "granted", "origin": seven_origin }),
1301                    false,
1302                ),
1303                (
1304                    "Browser.grantPermissions",
1305                    json!({ "permissions": ["geolocation"] }),
1306                    false,
1307                ),
1308                (
1309                    "Browser.grantPermissions",
1310                    json!({ "permissions": ["geolocation"], "origin": seven_origin }),
1311                    false,
1312                ),
1313                ("Browser.resetPermissions", json!({}), false),
1314                (
1315                    "Storage.clearDataForOrigin",
1316                    json!({ "origin": seven_origin, "storageTypes": "all" }),
1317                    false,
1318                ),
1319                (
1320                    "Storage.clearDataForStorageKey",
1321                    json!({ "storageKey": format!("{seven_origin}/"), "storageTypes": "all" }),
1322                    false,
1323                ),
1324                (
1325                    "Storage.getUsageAndQuota",
1326                    json!({ "origin": seven_origin }),
1327                    false,
1328                ),
1329                (
1330                    "CacheStorage.requestCacheNames",
1331                    json!({ "securityOrigin": seven_origin }),
1332                    true,
1333                ),
1334                (
1335                    "CacheStorage.requestEntries",
1336                    json!({ "cacheId": format!("{seven_origin}/|c1") }),
1337                    true,
1338                ),
1339                (
1340                    "IndexedDB.requestDatabaseNames",
1341                    json!({ "securityOrigin": seven_origin }),
1342                    true,
1343                ),
1344                (
1345                    "FileSystem.getDirectory",
1346                    json!({ "bucketFileSystemLocator": { "storageKey": format!("{seven_origin}/"), "pathComponents": [] } }),
1347                    true,
1348                ),
1349                (
1350                    "ServiceWorker.unregister",
1351                    json!({ "scopeURL": format!("{seven_origin}/") }),
1352                    true,
1353                ),
1354                (
1355                    "Target.exposeDevToolsProtocol",
1356                    json!({ "targetId": target }),
1357                    false,
1358                ),
1359                ("Tracing.start", json!({}), false),
1360                ("Fetch.enable", json!({}), false),
1361                (
1362                    "Network.loadNetworkResource",
1363                    json!({ "url": format!("{seven}/index.html"), "options": { "disableCache": true, "includeCredentials": true } }),
1364                    true,
1365                ),
1366                (
1367                    "Target.setAutoAttach",
1368                    json!({ "autoAttach": true, "flatten": true, "waitForDebuggerOnStart": false }),
1369                    false,
1370                ),
1371                ("IO.read", json!({ "handle": "not-ours" }), false),
1372            ] {
1373                refused(
1374                    cdp.call(method, params, if on_page { s } else { None }),
1375                    method,
1376                );
1377            }
1378            assert_eq!(
1379                cdp.call(
1380                    "Network.getResponseBody",
1381                    json!({ "requestId": "never-seen" }),
1382                    s
1383                )
1384                .unwrap_err(),
1385                UNSEEN
1386            );
1387            assert_eq!(
1388                cdp.call("IO.read", json!({ "handle": "not-ours" }), None)
1389                    .unwrap_err(),
1390                STREAM
1391            );
1392            cdp.call("Fetch.enable", json!({}), s).unwrap();
1393            cdp.call("Fetch.disable", json!({}), s).unwrap();
1394            client::detach(&mut cdp, &session);
1395            cdp.call("Target.closeTarget", json!({ "targetId": target }), None)
1396                .unwrap();
1397        }
1398
1399        {
1400            let context = cdp
1401                .call("Target.createBrowserContext", json!({}), None)
1402                .unwrap()["browserContextId"]
1403                .as_str()
1404                .unwrap()
1405                .to_string();
1406            cdp.call(
1407                "Storage.setCookies",
1408                json!({ "cookies": [{ "name": "own", "value": "1", "domain": "example.test", "path": "/" }], "browserContextId": context }),
1409                None,
1410            )
1411            .unwrap();
1412            cdp.call(
1413                "Storage.clearCookies",
1414                json!({ "browserContextId": context }),
1415                None,
1416            )
1417            .unwrap();
1418            cdp.call(
1419                "Browser.resetPermissions",
1420                json!({ "browserContextId": context }),
1421                None,
1422            )
1423            .unwrap();
1424            cdp.call(
1425                "Target.disposeBrowserContext",
1426                json!({ "browserContextId": context }),
1427                None,
1428            )
1429            .unwrap();
1430        }
1431
1432        {
1433            let (target, session) = page_on(&mut cdp, &format!("{app}/?n=fetch"));
1434            cdp.call("Network.enable", json!({}), Some(&session))
1435                .unwrap();
1436            let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
1437            let record = seen.clone();
1438            let forged = "<!doctype html><title>forged</title>";
1439            crate::fetch::enable(
1440                &mut cdp,
1441                &session,
1442                &[Pattern::request("*"), Pattern::response("*")],
1443            )
1444            .unwrap();
1445            cdp.set_decider(
1446                &session,
1447                Some(Box::new(move |paused: &crate::fetch::Paused| {
1448                    record.borrow_mut().push(paused.url.clone());
1449                    if paused.url.contains("sliccy") {
1450                        return Decision::Fulfill(Fulfill::html(200, forged));
1451                    }
1452                    Decision::default()
1453                })),
1454            );
1455            let navigation = cdp.events().len();
1456            cdp.call(
1457                "Page.navigate",
1458                json!({ "url": format!("{app}/?n=next") }),
1459                Some(&session),
1460            )
1461            .unwrap();
1462            cdp.wait_load_since(&session, navigation).unwrap();
1463            cdp.call(
1464                "Runtime.evaluate",
1465                json!({ "expression": format!("setTimeout(() => {{ location.href = {}; }}, 50); true", json!(format!("{seven}/nowhere?n=away"))) }),
1466                Some(&session),
1467            )
1468            .unwrap();
1469            until_allowed_error(&mut cdp, &mut raw, &target, &session, &format!("{seven}/nowhere?n=away"));
1470            assert!(
1471                seen.borrow().iter().all(|url| !url.contains("sliccy")),
1472                "{:?}",
1473                seen.borrow()
1474            );
1475            assert!(seen.borrow().iter().any(|url| url.contains("n=next")));
1476            nothing_mentions(&cdp, "n=away");
1477            let url = raw
1478                .call("Target.getTargetInfo", json!({ "targetId": target }), None)
1479                .unwrap()["targetInfo"]["url"]
1480                .as_str()
1481                .unwrap_or("")
1482                .to_string();
1483            assert!(!url.contains("forged"));
1484            let probe = raw.session(&target);
1485            let title = raw.eval(&probe, "document.title");
1486            assert_ne!(title, "forged");
1487            raw.close(&target);
1488        }
1489
1490        let hits = || -> u64 {
1491            let url = format!("{app}/hits?path=/api/leak");
1492            let response = crate::net::http_probe(&url, "text/plain")
1493                .unwrap_or_else(|error| panic!("{url} failed: {}", error.trim()));
1494            let text = String::from_utf8_lossy(&response.body).into_owned();
1495            text.trim()
1496                .parse()
1497                .unwrap_or_else(|_| panic!("{url} answered {text:?}"))
1498        };
1499
1500        {
1501            let leak = format!("{app}/api/leak");
1502            let before = hits();
1503            let (target, session) = page_on(&mut cdp, "about:blank");
1504            let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
1505            let record = seen.clone();
1506            let refresh = format!("{app}/refresh-leak");
1507            let landing = format!(
1508                "{vault}/landing?leak={}",
1509                url::form_urlencoded::byte_serialize(leak.as_bytes()).collect::<String>()
1510            );
1511            let failed_url = landing.clone();
1512            crate::fetch::enable(&mut cdp, &session, &[Pattern::request("*")]).unwrap();
1513            cdp.set_decider(
1514                &session,
1515                Some(Box::new(move |paused: &crate::fetch::Paused| {
1516                    record.borrow_mut().push(paused.url.clone());
1517                    if paused.url.contains("refresh-leak") {
1518                        let body = format!("<!doctype html><meta http-equiv=\"refresh\" content=\"0;url={landing}\">");
1519                        return Decision::Fulfill(Fulfill::html(200, body));
1520                    }
1521                    Decision::default()
1522                })),
1523            );
1524            let _ = cdp.notify("Page.navigate", json!({ "url": refresh }), Some(&session));
1525            until_allowed_error(&mut cdp, &mut raw, &target, &session, &failed_url);
1526            let deadline = Instant::now() + Duration::from_secs(2);
1527            while Instant::now() < deadline {
1528                let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1529            }
1530            assert!(seen.borrow().iter().any(|url| url.contains("refresh-leak")));
1531            assert!(
1532                seen.borrow()
1533                    .iter()
1534                    .all(|url| !url.contains("leak") || url.contains("refresh-leak")),
1535                "{:?}",
1536                seen.borrow()
1537            );
1538            assert_eq!(hits(), before, "the vault page ran and sent its request");
1539            nothing_mentions(&cdp, "LEAKKEY");
1540            raw.close(&target);
1541        }
1542
1543        {
1544            let idp = live("SLICC_FETCH_LIVE_IDP");
1545            let leak = format!("{app}/api/leak");
1546            let before = hits();
1547            let mut slipped = Cdp::connect(&ws).unwrap();
1548            let (target, session) = page_on(&mut slipped, &format!("{idp}/landing"));
1549            let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
1550            let record = seen.clone();
1551            crate::fetch::enable(&mut slipped, &session, &[Pattern::request("*")]).unwrap();
1552            slipped.set_decider(
1553                &session,
1554                Some(Box::new(move |paused: &crate::fetch::Paused| {
1555                    record.borrow_mut().push(paused.url.clone());
1556                    Decision::default()
1557                })),
1558            );
1559            let origin = idp.trim_end_matches('/').to_string();
1560            slipped.protect_origins(&[origin.as_str()]);
1561            let page = raw.session(&target);
1562            raw.eval(&page, &format!("fetch({}, {{ headers: {{ Authorization: 'Bearer LEAKKEY' }} }}).catch(() => {{}}), true", json!(leak)));
1563            let deadline = Instant::now() + Duration::from_secs(2);
1564            while Instant::now() < deadline {
1565                let _ = slipped.pump(Instant::now() + Duration::from_millis(100));
1566            }
1567            assert!(
1568                seen.borrow().iter().all(|url| !url.contains("api/leak")),
1569                "{:?}",
1570                seen.borrow()
1571            );
1572            assert_eq!(
1573                hits(),
1574                before + 1,
1575                "the request was not continued unchanged"
1576            );
1577            nothing_mentions(&slipped, "LEAKKEY");
1578            nothing_mentions(&slipped, "api/leak");
1579            raw.close(&target);
1580        }
1581
1582        nothing_mentions(&cdp, "SECRETKEY");
1583        nothing_mentions(&cdp, "COOKIESECRET");
1584        raw.close(&seven_tab);
1585        raw.close(&vault_tab);
1586    }
1587
1588    fn fixture_hits(app: &str, path: &str) -> String {
1589        crate::net::http_probe(&format!("{app}/hits?path={path}"), "text/plain")
1590            .map(|response| String::from_utf8_lossy(&response.body).into_owned())
1591            .unwrap_or_else(|error| format!("unknown ({})", error.trim()))
1592    }
1593
1594    fn guard_failed(cdp: &mut Cdp, url: &str) -> bool {
1595        let deadline = Instant::now() + Duration::from_secs(10);
1596        loop {
1597            if cdp
1598                .paused_trail
1599                .iter()
1600                .any(|(_, seen, reason)| seen.starts_with(url) && *reason == "guard session")
1601            {
1602                return true;
1603            }
1604            if Instant::now() >= deadline {
1605                return false;
1606            }
1607            let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1608        }
1609    }
1610
1611    fn guard_evidence(
1612        cdp: &Cdp,
1613        raw: &mut Raw,
1614        app: &str,
1615        session: &str,
1616        path: &str,
1617        trigger: &Result<Value, String>,
1618    ) -> String {
1619        let hits = fixture_hits(app, path);
1620        let window = raw.call(
1621            "Runtime.evaluate",
1622            json!({ "expression": "({ href: location.href, readyState: document.readyState, trigger: globalThis.guardWindowTrigger })", "returnByValue": true }),
1623            Some(session),
1624        );
1625        let history = raw.call("Page.getNavigationHistory", json!({}), Some(session));
1626        let timer = window
1627            .as_ref()
1628            .ok()
1629            .and_then(|result| result.pointer("/result/value/trigger/fired"))
1630            .and_then(Value::as_bool);
1631        let classification = match hits.parse::<u64>() {
1632            Ok(count) if count > 0 => "(a) protected fixture hit: guard bypass",
1633            Ok(0) if timer == Some(false) => "(b) navigation timer never fired",
1634            _ => "unclassified: inspect browser events, window and guard trail",
1635        };
1636        format!(
1637            "{classification}; pending_before_attach=true; fixture_path={path}; seven_hits={hits}; trigger={trigger:?}; window={window:?}; history={history:?}; client_events={:?}; guard_trail={:?}",
1638            cdp.events(), cdp.paused_trail
1639        )
1640    }
1641
1642    enum GuardWindow<T> {
1643        Produced(T),
1644        Miss,
1645        Bypass,
1646    }
1647
1648    fn produce_guard_window<T>(
1649        mut attempt: impl FnMut(usize) -> (GuardWindow<T>, String),
1650    ) -> Result<T, String> {
1651        let mut evidence = Vec::new();
1652        for number in 1..=3 {
1653            let (window, detail) = attempt(number);
1654            let detail = format!("attempt {number}: {detail}");
1655            eprintln!("{detail}");
1656            evidence.push(detail);
1657            match window {
1658                GuardWindow::Produced(value) => return Ok(value),
1659                GuardWindow::Miss => {}
1660                GuardWindow::Bypass => {
1661                    return Err(format!(
1662                        "(a) protected fixture hit or commit: guard bypass\n{}",
1663                        evidence.join("\n")
1664                    ));
1665                }
1666            }
1667        }
1668        Err(format!(
1669            "pending-commit window not produced after 3 attempts\n{}",
1670            evidence.join("\n")
1671        ))
1672    }
1673
1674    #[test]
1675    fn guard_window_retries_only_precondition_misses() {
1676        let mut attempts = Vec::new();
1677        let result = produce_guard_window(|number| {
1678            attempts.push(number);
1679            (
1680                if number == 3 {
1681                    GuardWindow::Produced("ready")
1682                } else {
1683                    GuardWindow::Miss
1684                },
1685                format!("stub {number}"),
1686            )
1687        });
1688        assert_eq!(result.unwrap(), "ready");
1689        assert_eq!(attempts, [1, 2, 3]);
1690    }
1691
1692    #[test]
1693    fn guard_window_exhaustion_keeps_every_attempt() {
1694        let mut attempts = Vec::new();
1695        let result = produce_guard_window::<()>(|number| {
1696            attempts.push(number);
1697            (GuardWindow::Miss, format!("stub {number}"))
1698        });
1699        assert_eq!(attempts, [1, 2, 3]);
1700        assert_eq!(
1701            result.unwrap_err(),
1702            "pending-commit window not produced after 3 attempts\nattempt 1: stub 1\nattempt 2: stub 2\nattempt 3: stub 3"
1703        );
1704    }
1705
1706    #[test]
1707    fn guard_window_bypass_is_never_retried() {
1708        let mut attempts = Vec::new();
1709        let result = produce_guard_window::<()>(|number| {
1710            attempts.push(number);
1711            (GuardWindow::Bypass, "seven hit".to_string())
1712        });
1713        assert_eq!(attempts, [1]);
1714        assert_eq!(
1715            result.unwrap_err(),
1716            "(a) protected fixture hit or commit: guard bypass\nattempt 1: seven hit"
1717        );
1718    }
1719
1720    fn until_history(
1721        raw: &mut Raw,
1722        session: &str,
1723        history_replies: &mut Vec<Result<Value, String>>,
1724        seven: &str,
1725        done: impl Fn(Option<&str>) -> bool,
1726    ) -> bool {
1727        let deadline = Instant::now() + Duration::from_secs(30);
1728        loop {
1729            let reply = raw.call("Page.getNavigationHistory", json!({}), Some(session));
1730            if history_replies.last() != Some(&reply) {
1731                history_replies.push(reply.clone());
1732            }
1733            if reply.as_ref().is_ok_and(|history| {
1734                history["entries"].as_array().is_some_and(|entries| {
1735                    entries.iter().any(|entry| {
1736                        entry["url"]
1737                            .as_str()
1738                            .is_some_and(|url| url.starts_with(seven))
1739                    })
1740                })
1741            }) {
1742                return false;
1743            }
1744            let current = match reply {
1745                Ok(history) => {
1746                    let index = history["currentIndex"].as_u64().unwrap_or(0) as usize;
1747                    Ok(history["entries"][index]["url"]
1748                        .as_str()
1749                        .unwrap_or("")
1750                        .to_string())
1751                }
1752                Err(error) if error.contains("Not attached to an active page") => Err(error),
1753                Err(error) => panic!("Page.getNavigationHistory failed: {error}"),
1754            };
1755            if done(current.as_deref().ok()) {
1756                return true;
1757            }
1758            if Instant::now() >= deadline {
1759                return false;
1760            }
1761        }
1762    }
1763
1764    #[test]
1765    #[ignore = "needs SLICC_FETCH_LIVE_WS from test/integration/fetch.test.mjs"]
1766    fn live_guard_armed_while_a_navigation_commits() {
1767        let ws = live("SLICC_FETCH_LIVE_WS");
1768        let app = live("SLICC_FETCH_LIVE_APP");
1769        let idp = live("SLICC_FETCH_LIVE_IDP");
1770        let seven = live("SLICC_FETCH_LIVE_SEVEN");
1771        let evidence_url = live("SLICC_FETCH_LIVE_GUARD_EVIDENCE");
1772        let mut window_reply_count = 0;
1773        let (mut raw, mut cdp, tab, slow, landing, session) = produce_guard_window(|_| {
1774            let mut raw = Raw::open(&ws);
1775            let mut cdp = Cdp::connect(&ws).unwrap();
1776            let tab = raw.create("about:blank");
1777            let slow = raw.session(&tab);
1778            raw.call("Page.enable", json!({}), Some(&slow)).unwrap();
1779            raw.call(
1780                "Emulation.setCPUThrottlingRate",
1781                json!({ "rate": 200 }),
1782                Some(&slow),
1783            )
1784            .unwrap();
1785            let landing = format!("{app}/landing?n=window");
1786            let to: String = url::form_urlencoded::byte_serialize(landing.as_bytes()).collect();
1787            let trigger = raw.call(
1788                "Page.navigate",
1789                json!({ "url": format!("{idp}/js-redirect?to={to}") }),
1790                Some(&slow),
1791            );
1792            trigger.as_ref().unwrap();
1793            let mut history_replies = Vec::new();
1794            let committed = until_history(
1795                &mut raw,
1796                &slow,
1797                &mut history_replies,
1798                &seven,
1799                |current| current.is_some_and(|url| url.contains("/js-redirect")),
1800            );
1801            let pending = committed
1802                && until_history(
1803                    &mut raw,
1804                    &slow,
1805                    &mut history_replies,
1806                    &seven,
1807                    |current| current.is_none(),
1808                );
1809            let session = pending.then(|| client::attach(&mut cdp, &tab).unwrap());
1810            let proxy_evidence: Value = serde_json::from_slice(
1811                &crate::net::http_probe(&evidence_url, "application/json")
1812                    .unwrap_or_else(|error| panic!("guard evidence unavailable: {error}"))
1813                    .body,
1814            )
1815            .unwrap();
1816            let replies = proxy_evidence["window_replies"].as_array().unwrap();
1817            let produced = pending
1818                && replies.len() == window_reply_count + 1
1819                && replies.last().is_some_and(|reply| {
1820                    reply["error"]["message"]
1821                        .as_str()
1822                        .is_some_and(|error| error.contains("Not attached to an active page"))
1823                });
1824            window_reply_count = replies.len();
1825            let hits = [fixture_hits(&app, "/code"), fixture_hits(&app, "/dump")];
1826            let seven_commits: Vec<_> = history_replies
1827                .iter()
1828                .filter_map(|reply| reply.as_ref().ok()?.get("entries")?.as_array())
1829                .flatten()
1830                .filter(|entry| {
1831                    entry["url"]
1832                        .as_str()
1833                        .is_some_and(|url| url.starts_with(&seven))
1834                })
1835                .collect();
1836            let bypass = hits
1837                .iter()
1838                .any(|hits| hits.parse::<u64>().is_ok_and(|count| count > 0))
1839                || !seven_commits.is_empty()
1840                || !proxy_evidence["seven_commits"].as_array().unwrap().is_empty();
1841            let evidence = format!(
1842                "pending_window_produced={produced}; pending_before_attach={pending}; js_redirect_committed={committed}; tab={tab}; trigger={trigger:?}; fixture_hits(/code,/dump)={hits:?}; seven_commits={seven_commits:?}; history_replies={history_replies:?}; proxy_evidence={proxy_evidence}; client_events={:?}; guard_trail={:?}",
1843                cdp.events(), cdp.paused_trail
1844            );
1845            if bypass || !produced {
1846                raw.close(&tab);
1847                assert!(
1848                    hits.iter().all(|hits| hits.parse::<u64>().is_ok()),
1849                    "fixture hits unavailable: {evidence}"
1850                );
1851                return (
1852                    if bypass {
1853                        GuardWindow::Bypass
1854                    } else {
1855                        GuardWindow::Miss
1856                    },
1857                    evidence,
1858                );
1859            }
1860            assert!(
1861                hits.iter().all(|hits| hits.parse::<u64>().is_ok()),
1862                "fixture hits unavailable: {evidence}"
1863            );
1864            (
1865                GuardWindow::Produced((raw, cdp, tab, slow, landing, session.unwrap())),
1866                evidence,
1867            )
1868        })
1869        .unwrap_or_else(|error| panic!("{error}"));
1870        raw.call(
1871            "Emulation.setCPUThrottlingRate",
1872            json!({ "rate": 1 }),
1873            Some(&slow),
1874        )
1875        .unwrap();
1876        let deadline = Instant::now() + Duration::from_secs(30);
1877        loop {
1878            let href = cdp
1879                .call(
1880                    "Runtime.evaluate",
1881                    json!({ "expression": "document.readyState === 'complete' ? location.href : ''", "returnByValue": true }),
1882                    Some(&session),
1883                )
1884                .map(|result| result["result"]["value"].clone());
1885            if href.as_ref().is_ok_and(|href| href == &json!(landing)) {
1886                break;
1887            }
1888            assert!(
1889                Instant::now() < deadline,
1890                "the landing never loaded: {href:?}"
1891            );
1892            let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1893        }
1894
1895        let frame = format!("{seven}/code?n=frame");
1896        let frame_trigger = cdp.call(
1897            "Runtime.evaluate",
1898            json!({ "expression": format!("const f = document.createElement('iframe'); f.id = 'guard-window-frame'; f.src = {}; document.body.appendChild(f); true", json!(frame)) }),
1899            Some(&session),
1900        );
1901        let frame_seen = guard_failed(&mut cdp, &frame);
1902        assert!(
1903            frame_seen,
1904            "seven iframe not intercepted: {}",
1905            guard_evidence(&cdp, &mut raw, &app, &slow, "/code", &frame_trigger)
1906        );
1907        assert!(frame_trigger.as_ref().is_err_and(|error| error.starts_with("slicc: refused:")));
1908        assert_eq!(
1909            fixture_hits(&app, "/code"),
1910            "0",
1911            "seven iframe reached the network: {}",
1912            guard_evidence(&cdp, &mut raw, &app, &slow, "/code", &frame_trigger)
1913        );
1914
1915        assert_eq!(raw.eval(&slow, "document.getElementById('guard-window-frame').remove(); true"), json!(true));
1916        let main = format!("{seven}/dump?n=main");
1917        let main_trigger = cdp.call(
1918            "Runtime.evaluate",
1919            json!({ "expression": format!("globalThis.guardWindowTrigger = {{ scheduled: true, fired: false }}; setTimeout(() => {{ globalThis.guardWindowTrigger.fired = true; location.href = {}; }}, 50); true", json!(main)) }),
1920            Some(&session),
1921        );
1922        let main_seen = guard_failed(&mut cdp, &main);
1923        assert!(
1924            main_seen,
1925            "seven navigation not intercepted: {}",
1926            guard_evidence(&cdp, &mut raw, &app, &slow, "/dump", &main_trigger)
1927        );
1928        main_trigger.as_ref().unwrap();
1929        until_allowed_error(&mut cdp, &mut raw, &tab, &session, &main);
1930        assert_eq!(
1931            fixture_hits(&app, "/dump"),
1932            "0",
1933            "seven navigation reached the network: {}",
1934            guard_evidence(&cdp, &mut raw, &app, &slow, "/dump", &main_trigger)
1935        );
1936        nothing_mentions(&cdp, "n=main");
1937        nothing_mentions(&cdp, "n=frame");
1938        raw.close(&tab);
1939    }
1940}