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, ¶ms), "{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, ¶ms, 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}