1use crate::fetch::{self, Decision, Paused};
2use crate::net::Ws;
3use crate::protected::{Opener, Protected};
4use crate::shield;
5use crate::worlds::Worlds;
6use serde_json::{json, Value};
7use std::collections::{BTreeMap, BTreeSet, VecDeque};
8use std::time::{Duration, Instant};
9
10#[path = "webmcp_guard.rs"]
11mod webmcp_guard;
12#[path = "page_scope.rs"]
13mod page_scope;
14#[path = "document_order.rs"]
15mod document_order;
16#[path = "issued.rs"]
17mod issued;
18pub(crate) use issued::target_refusal;
19
20pub trait Transport {
21 fn send_text(&mut self, text: &str) -> Result<(), String>;
22 fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String>;
23}
24
25impl Transport for Ws {
26 fn send_text(&mut self, text: &str) -> Result<(), String> {
27 Ws::send_text(self, text)
28 }
29
30 fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
31 Ws::recv_text(self, deadline)
32 }
33}
34
35pub type Decider = Box<dyn FnMut(&Paused) -> Decision>;
36
37pub const UNSEEN: &str = "slicc: refused: slicc hasn't seen this request, so it could be seven's\n";
38pub const UNGUARDED: &str = "slicc: refused: slicc can't keep seven's origin out of this target\n";
39pub const RENDERER_CRASHED: &str = "slicc: refused: renderer crashed\n";
40pub const GUARD_SETUP: Duration = Duration::from_secs(30);
41const OPTIONAL_STEP: Duration = Duration::from_secs(10);
42const DOCUMENT_CHECK: Duration = Duration::from_secs(2);
43const WAITER_SLICE: Duration = Duration::from_millis(5);
44const AUTO_ATTACH: &str = "slicc: refused: auto-attach is not supported by slicc-cdp\n";
45const FLATTEN: &str = "slicc: refused: attach with flatten: true\n";
46const PERSISTENT: &str = "slicc: refused: persistent page capabilities are not supported by slicc-cdp\n";
47pub const COOKIES: &str = "slicc: refused: cookies on seven's origin\n";
48pub const STREAM: &str = "slicc: refused: slicc didn't hand out this stream, so it could be seven's\n";
49
50struct CarrierTarget {
51 origin: Option<String>,
52 protected: bool,
53 url: url::Url,
54}
55
56#[derive(Clone, Debug)]
57struct DocumentState {
58 frames: BTreeMap<String, Vec<String>>,
59 worker: Option<(String, String)>,
60 scope: Option<String>,
61 epoch: u64,
62 page_scope: Option<(String, u64, u64)>,
63 runtime_scope_epoch: Option<u64>,
64}
65
66impl PartialEq for DocumentState {
67 fn eq(&self, other: &Self) -> bool {
68 self.scope == other.scope && self.epoch == other.epoch && self.worker == other.worker && self.frames.len() == other.frames.len()
69 && self.frames.iter().all(|(id, frame)| other.frames.get(id).is_some_and(|current| {
70 frame[0] == current[0] && frame[2..] == current[2..] && (!frame[0].is_empty() || frame[1] == current[1])
71 }))
72 }
73}
74
75#[derive(Clone)]
76struct ContextProof {
77 id: i64,
78 frame: String,
79 origin: String,
80 name: String,
81 loader: Option<String>,
82 default: bool,
83}
84
85struct Navigation {
86 params: Value,
87 session: Option<String>,
88 reply: Option<Value>,
89 detached: bool,
90}
91
92struct MainDocument {
93 frame_id: String,
94 url: String,
95 origin: String,
96 unreachable_url: String,
97 navigating: bool,
98 navigation_url: String,
99 previous: bool,
100 probed: bool,
101}
102
103pub type Waiter = Box<dyn FnMut(&Cdp)>;
104
105pub struct Cdp {
106 ws: Box<dyn Transport>,
107 next_id: i64,
108 detaching: BTreeMap<i64, String>,
109 detached_ordinary: BTreeSet<String>,
110 navigations: BTreeMap<i64, Navigation>,
111 events: Vec<Value>,
112 dialog_sessions: BTreeSet<String>,
113 dialogs: BTreeMap<String, Dialog>,
114 dialog_documents: BTreeMap<String, DialogDocument>,
115 dialog_epoch: u64,
116 trace_cookies: Vec<Value>,
117 guard: Protected,
118 paused: VecDeque<Paused>,
119 deciders: BTreeMap<String, Decider>,
120 decided: BTreeMap<i64, (String, String)>,
121 fetch_errors: Vec<String>,
122 blocked: BTreeSet<String>,
123 crashed_sessions: BTreeSet<String>,
124 crashed_targets: BTreeSet<String>,
125 session_targets: BTreeMap<String, String>,
126 protected_targets: BTreeSet<String>,
127 guards: BTreeMap<String, String>,
128 page_scope_sessions: BTreeMap<String, String>,
129 checking_page_events: bool,
130 pending_page_events: VecDeque<Value>,
131 runtime_scope_epochs: BTreeMap<String, u64>,
132 page_scope_owners: BTreeMap<String, String>,
133 page_scope_epochs: BTreeMap<String, u64>,
134 page_scope_unknown: u64,
135 guard_sessions: BTreeSet<String>,
136 guarding: BTreeSet<String>,
137 probing: BTreeSet<String>,
138 abandoned: BTreeSet<String>,
139 unguarded_optional: BTreeMap<String, BTreeSet<String>>,
140 notices: Vec<String>,
141 discovering: bool,
142 requests: BTreeMap<String, bool>,
143 redirects: BTreeSet<String>,
144 protected_body_requests: BTreeSet<String>,
145 seen_network: BTreeMap<(String, String), String>,
146 terminal_network: BTreeSet<(String, String)>,
147 client_navigation_urls: BTreeMap<String, String>,
148 request_aliases: BTreeMap<String, BTreeSet<String>>,
149 request_resources: BTreeMap<String, BTreeSet<(String, String)>>,
150 protected_request_pages: BTreeSet<(String, String)>,
151 protected_resources: BTreeSet<(String, String)>,
152 protected_target_resources: BTreeSet<(String, String)>,
153 source_resources: BTreeMap<(String, String), String>,
154 handed: BTreeSet<(String, String)>,
155 answering: BTreeMap<i64, (String, String)>,
156 held: BTreeMap<String, Vec<Value>>,
157 streams: BTreeMap<String, (Option<String>, Option<DocumentState>)>,
158 document_epochs: BTreeMap<String, u64>,
159 unchecked_documents: BTreeMap<(String, String, String), document_order::ObservedDocument>,
160 removed_frames: BTreeMap<(String, String), (String, String)>,
161 document_versions: BTreeMap<(String, String), String>,
162 created_blanks: BTreeMap<String, (String, String, i64)>,
163 navigated_blanks: BTreeMap<String, (String, String, String)>,
164 context_frames: BTreeMap<(String, String), String>,
165 context_proofs: BTreeMap<(String, String), ContextProof>,
166 runtime_sessions: BTreeMap<String, String>,
167 created_contexts: BTreeSet<(String, i64)>,
168 cached_documents: BTreeMap<String, DocumentState>,
169 paused_documents: BTreeSet<(String, String)>,
170 executing_sessions: BTreeSet<String>,
171 contexts: BTreeSet<String>,
172 targets: BTreeMap<String, Value>,
173 worlds: Worlds,
174 frames: BTreeMap<String, Vec<String>>,
175 frame_parents: BTreeMap<String, String>,
176 frame_decisions: BTreeMap<String, bool>,
177 frame_sessions: BTreeMap<String, BTreeSet<String>>,
178 frame_epoch: u64,
179 frame_versions: BTreeMap<String, u64>,
180 documents: BTreeMap<String, MainDocument>,
181 unobserved: BTreeSet<String>,
182 webmcp: webmcp_guard::Webmcp,
183 waiting_calls: BTreeSet<i64>,
184 call_replies: BTreeMap<i64, Value>,
185 issued: issued::Issued,
186 waiter: Option<Waiter>,
187 waiter_due: Instant,
188 closed: Option<String>,
189 #[cfg(test)]
190 runtime_preparing: bool,
191 #[cfg(test)]
192 runtime_probe: Vec<Value>,
193 #[cfg(test)]
194 pub(crate) paused_trail: Vec<(String, String, &'static str)>,
195}
196
197impl Drop for Cdp {
198 fn drop(&mut self) {
199 self.retire_private_sessions(Instant::now() + DOCUMENT_CHECK);
200 }
201}
202
203impl Cdp {
204 fn retire_private_sessions(&mut self, deadline: Instant) {
205 self.waiter = None;
206 let sessions = self.guard_sessions.clone();
207 for session in sessions {
208 if !self.guard_sessions.contains(&session) { continue }
209 if self.guards.values().any(|guard| guard == &session) {
210 let left = deadline.saturating_duration_since(Instant::now());
211 if left.is_zero() { let _ = self.send("Fetch.disable", json!({}), Some(&session)); }
212 else { let _ = self.raw_call("Fetch.disable", json!({}), Some(&session), left); }
213 }
214 let left = deadline.saturating_duration_since(Instant::now());
215 if left.is_zero() { let _ = self.send("Target.detachFromTarget", json!({"sessionId":session}), None); }
216 else { let _ = self.raw_call("Target.detachFromTarget", json!({"sessionId":session}), None, left); }
217 }
218 }
219
220 pub fn connect(url: &str) -> Result<Self, String> {
221 Ok(Self::from_transport(Box::new(crate::net::ws_connect(url)?)))
222 }
223
224 pub fn from_transport(ws: Box<dyn Transport>) -> Self {
225 Self {
226 ws,
227 next_id: 0,
228 detaching: BTreeMap::new(),
229 detached_ordinary: BTreeSet::new(),
230 navigations: BTreeMap::new(),
231 events: Vec::new(),
232 dialog_sessions: BTreeSet::new(),
233 dialogs: BTreeMap::new(),
234 dialog_documents: BTreeMap::new(),
235 dialog_epoch: 0,
236 trace_cookies: Vec::new(),
237 guard: Protected::default(),
238 paused: VecDeque::new(),
239 deciders: BTreeMap::new(),
240 decided: BTreeMap::new(),
241 fetch_errors: Vec::new(),
242 blocked: BTreeSet::new(),
243 crashed_sessions: BTreeSet::new(),
244 crashed_targets: BTreeSet::new(),
245 session_targets: BTreeMap::new(),
246 protected_targets: BTreeSet::new(),
247 guards: BTreeMap::new(),
248 page_scope_sessions: BTreeMap::new(),
249 checking_page_events: false,
250 pending_page_events: VecDeque::new(),
251 runtime_scope_epochs: BTreeMap::new(),
252 page_scope_owners: BTreeMap::new(),
253 page_scope_epochs: BTreeMap::new(),
254 page_scope_unknown: 0,
255 guard_sessions: BTreeSet::new(),
256 guarding: BTreeSet::new(),
257 probing: BTreeSet::new(),
258 abandoned: BTreeSet::new(),
259 unguarded_optional: BTreeMap::new(),
260 notices: Vec::new(),
261 discovering: false,
262 requests: BTreeMap::new(),
263 redirects: BTreeSet::new(),
264 protected_body_requests: BTreeSet::new(),
265 seen_network: BTreeMap::new(),
266 terminal_network: BTreeSet::new(),
267 client_navigation_urls: BTreeMap::new(),
268 request_aliases: BTreeMap::new(),
269 request_resources: BTreeMap::new(),
270 protected_request_pages: BTreeSet::new(),
271 protected_resources: BTreeSet::new(),
272 protected_target_resources: BTreeSet::new(),
273 source_resources: BTreeMap::new(),
274 handed: BTreeSet::new(),
275 answering: BTreeMap::new(),
276 held: BTreeMap::new(),
277 streams: BTreeMap::new(),
278 document_epochs: BTreeMap::new(),
279 unchecked_documents: BTreeMap::new(),
280 removed_frames: BTreeMap::new(),
281 document_versions: BTreeMap::new(),
282 created_blanks: BTreeMap::new(),
283 navigated_blanks: BTreeMap::new(),
284 context_frames: BTreeMap::new(),
285 context_proofs: BTreeMap::new(),
286 runtime_sessions: BTreeMap::new(),
287 created_contexts: BTreeSet::new(),
288 cached_documents: BTreeMap::new(),
289 paused_documents: BTreeSet::new(),
290 executing_sessions: BTreeSet::new(),
291 contexts: BTreeSet::new(),
292 targets: BTreeMap::new(),
293 worlds: Worlds::default(),
294 frames: BTreeMap::new(),
295 frame_parents: BTreeMap::new(),
296 frame_decisions: BTreeMap::new(),
297 frame_sessions: BTreeMap::new(),
298 frame_epoch: 0,
299 frame_versions: BTreeMap::new(),
300 documents: BTreeMap::new(),
301 unobserved: BTreeSet::new(),
302 webmcp: webmcp_guard::Webmcp::default(),
303 waiting_calls: BTreeSet::new(),
304 call_replies: BTreeMap::new(),
305 issued: issued::Issued::default(),
306 waiter: None,
307 waiter_due: Instant::now(),
308 closed: None,
309 #[cfg(test)]
310 runtime_preparing: false,
311 #[cfg(test)]
312 runtime_probe: Vec::new(),
313 #[cfg(test)]
314 paused_trail: Vec::new(),
315 }
316 }
317
318 #[cfg(test)]
319 pub(crate) fn fixture_session(&mut self, session: &str) {
320 struct Fixture {
321 inner: Box<dyn Transport>,
322 inbox: VecDeque<String>,
323 worlds: BTreeMap<String, Vec<Value>>,
324 pending_worlds: BTreeMap<i64, (String, Value)>,
325 target_infos: Vec<Value>,
326 }
327 impl Transport for Fixture {
328 fn send_text(&mut self, text: &str) -> Result<(), String> {
329 let call: Value = serde_json::from_str(text).unwrap();
330 if call["method"] == "Target.getTargets" && call["params"]["filter"] == json!([{}]) {
331 self.inbox.push_back(json!({"id":call["id"],"result":{"targetInfos":self.target_infos}}).to_string());
332 Ok(())
333 } else if call["method"] == "Page.getFrameTree" && call["sessionId"].as_str().is_some_and(|session| session.starts_with("fixture-guard:") || session == "S" || session == "OTHER") {
334 self.inbox.push_back(json!({"id":call["id"],"result":{"frameTree":{"frame":{"id":"F","loaderId":"L","url":"https://example.test/","securityOrigin":"https://example.test"}}}}).to_string());
335 Ok(())
336 } else if call["sessionId"].as_str().is_some_and(|session| session.starts_with("fixture-guard:")) && matches!(call["method"].as_str(), Some("Runtime.disable" | "Runtime.enable")) {
337 if call["method"] == "Runtime.enable" {
338 self.inbox.push_back(json!({"method":"Runtime.executionContextCreated","sessionId":call["sessionId"],"params":{"context":{"id":1,"uniqueId":"fixture-context","origin":"https://example.test","name":"","auxData":{"frameId":"F","isDefault":true}}}}).to_string());
339 let owner = call["sessionId"].as_str().unwrap().strip_prefix("fixture-guard:").unwrap();
340 for context in self.worlds.get(owner).into_iter().flatten() {
341 self.inbox.push_back(json!({"method":"Runtime.executionContextCreated","sessionId":call["sessionId"],"params":{"context":context}}).to_string());
342 }
343 }
344 self.inbox.push_back(json!({"id":call["id"],"result":{}}).to_string());
345 Ok(())
346 } else {
347 if call["method"] == "Page.createIsolatedWorld" {
348 self.pending_worlds.insert(call["id"].as_i64().unwrap(), (call["sessionId"].as_str().unwrap().to_string(), call["params"].clone()));
349 }
350 self.inner.send_text(text)
351 }
352 }
353 fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
354 let text = match self.inbox.pop_front() { Some(text) => Some(text), None => self.inner.recv_text(deadline)? };
355 if let Some(text) = &text {
356 let reply: Value = serde_json::from_str(text).unwrap();
357 if let Some((owner, params)) = reply["id"].as_i64().and_then(|id| self.pending_worlds.remove(&id)) {
358 if let Some(id) = reply.pointer("/result/executionContextId").and_then(Value::as_i64) {
359 self.worlds.entry(owner).or_default().push(json!({"id":id,"uniqueId":format!("fixture-world:{id}"),"origin":"","name":params["worldName"],"auxData":{"frameId":params["frameId"],"isDefault":false}}));
360 }
361 }
362 }
363 Ok(text)
364 }
365 }
366 struct Empty;
367 impl Transport for Empty {
368 fn send_text(&mut self, _: &str) -> Result<(), String> { Ok(()) }
369 fn recv_text(&mut self, _: Instant) -> Result<Option<String>, String> { Ok(None) }
370 }
371 let target = format!("fixture:{session}");
372 let guard = format!("fixture-guard:{session}");
373 self.targets.insert(target.clone(), json!({"targetId":target,"type":"page","url":"https://example.test/"}));
374 self.session_targets.insert(session.into(), target.clone());
375 self.guards.insert(target.clone(), guard.clone());
376 self.guard_sessions.insert(guard);
377 self.unobserved.insert(target);
378 let inner = std::mem::replace(&mut self.ws, Box::new(Empty));
379 self.ws = Box::new(Fixture { inner, inbox:VecDeque::new(), worlds:BTreeMap::new(), pending_worlds:BTreeMap::new(), target_infos:self.targets.values().cloned().collect() });
380 }
381
382 pub fn events(&self) -> &[Value] {
383 &self.events
384 }
385
386 pub fn guard(&self) -> &Protected {
387 &self.guard
388 }
389
390 pub fn protect_origins(&mut self, extra_origins: &[&str]) {
391 self.guard = Protected::new(extra_origins);
392 }
393
394 pub fn take_notices(&mut self) -> Vec<String> {
395 std::mem::take(&mut self.notices)
396 }
397
398 pub fn unguarded_optional(&self, target_id: &str) -> Vec<String> {
399 self.unguarded_optional
400 .get(target_id)
401 .map(|steps| steps.iter().cloned().collect())
402 .unwrap_or_default()
403 }
404
405 pub fn session_blocked(&self, session_id: &str) -> bool {
406 self.blocked.contains(session_id)
407 }
408
409 pub fn content_blocked(&self, session_id: &str) -> bool {
410 self.blocked.contains(session_id) || self.guard_sessions.contains(session_id)
411 || self.session_target(session_id).and_then(|target| self.documents.get(&target)).is_some_and(|doc| self.sealed(doc))
412 }
413
414 #[cfg(test)]
415 pub(crate) fn document_evidence(&self, target_id: &str) -> String {
416 self.documents.get(target_id).map_or_else(|| "none".to_string(), |doc| {
417 format!("url={} unreachable={} navigating={} previous={} probed={}", doc.url, doc.unreachable_url, doc.navigating, doc.previous, doc.probed)
418 })
419 }
420
421 pub fn is_protected_target(&self, target_id: &str) -> bool {
422 self.protected_targets.contains(target_id)
423 || self
424 .session_targets
425 .iter()
426 .any(|(session, target)| target == target_id && self.blocked.contains(session))
427 }
428
429 pub fn set_decider(&mut self, session_id: &str, decider: Option<Decider>) {
430 match decider {
431 Some(decider) => {
432 self.deciders.insert(session_id.to_string(), decider);
433 let queued: Vec<Paused> = self.paused.drain(..).collect();
434 for paused in queued {
435 if paused.session_id == session_id {
436 self.decide_inline(paused);
437 } else {
438 self.paused.push_back(paused);
439 }
440 }
441 }
442 None => {
443 self.deciders.remove(session_id);
444 }
445 }
446 }
447
448 pub fn has_decider(&self, session_id: &str) -> bool {
449 self.deciders.contains_key(session_id)
450 }
451
452 #[cfg(test)]
453 fn runtime_stage(&mut self, method: &str, stage: &str) {
454 if method == "Runtime.evaluate" { self.runtime_probe.push(json!({"stage":stage})); }
455 }
456
457 #[cfg(test)]
458 fn runtime_contexts(&mut self, method: &str, stage: &str, guard: &str, target: &str, state: &DocumentState, selected: Option<&str>) {
459 if method != "Runtime.evaluate" { return }
460 let contexts: Vec<Value> = self.context_proofs.iter().filter(|((owner, _), _)| owner == guard).map(|((_, unique), proof)| {
461 let frame = state.frames.get(&proof.frame);
462 json!({"default":proof.default,"frame_known":frame.is_some(),
463 "main_frame":frame.is_some_and(|frame| frame[4].is_empty()),
464 "origin_known":url::Url::parse(&proof.origin).is_ok(),
465 "origin_protected":self.guard.url(&proof.origin),
466 "origin_opaque":matches!(proof.origin.as_str(), "" | "://" | "null"),
467 "origin_matches_frame":frame.is_some_and(|frame| frame[2] == proof.origin),
468 "loader_known":proof.loader.as_ref().is_some_and(|loader| !loader.is_empty()),
469 "loader_matches_frame":proof.loader.as_ref().zip(frame).is_some_and(|(loader, frame)| loader == &frame[0]),
470 "selected":selected == Some(unique.as_str())})
471 }).collect();
472 self.runtime_probe.push(json!({"stage":stage,"private_contexts":contexts,
473 "frame_count":state.frames.len(),"scope_known":state.scope.is_some(),
474 "scope_default":state.scope.as_deref() == Some(""),
475 "page_scope_known":state.page_scope.is_some(),"worker":state.worker.is_some(),
476 "owned_blank":state.frames.iter().any(|(id, frame)| frame[1] == "about:blank" && self.owns_blank(target, id, &frame[0]))}));
477 }
478
479 #[cfg(test)]
480 fn runtime_prepare_probe(&mut self, category: &str, details: Value) {
481 if self.runtime_preparing { self.runtime_probe.push(json!({"stage":"prepare-check","category":category,"details":details})); }
482 }
483
484 #[cfg(test)]
485 fn runtime_prepare_frames(&mut self, tree: &Value, frames: &BTreeMap<String, Vec<String>>) {
486 let mut pending = vec![(tree, None)];
487 while let Some((node, parent)) = pending.pop() {
488 let Some(frame) = node.get("frame") else {
489 self.runtime_prepare_probe("frame-placement", json!({"frame_present":false}));
490 return;
491 };
492 let id = frame.get("id").and_then(Value::as_str).unwrap_or("");
493 if !frames.contains_key(id) {
494 let url = frame.get("url").and_then(Value::as_str).unwrap_or("");
495 let origin = self.page_scope_origin(frame).or_else(|| frame.get("securityOrigin").and_then(Value::as_str)).unwrap_or("");
496 self.runtime_prepare_probe("frame-evidence", json!({"id_known":frame_text_known(id),"url_known":frame_text_known(url),
497 "parent_matches":frame.get("parentId").is_none_or(|id| id.as_str() == parent),
498 "loader_known":frame.get("loaderId").and_then(Value::as_str).is_some_and(|id| frame_text_known(id)),
499 "initial_blank":url == "about:blank" && frame.get("loaderId").and_then(Value::as_str) == Some(""),
500 "origin_empty":origin.is_empty(),"origin_null":origin == "null","origin_opaque":origin == "://",
501 "origin_protected":self.guard.url(origin),"url_protected":self.guard.url(url),
502 "error_unverified":self.unverified_error(url, frame.get("unreachableUrl").and_then(Value::as_str).unwrap_or(""))}));
503 return;
504 }
505 if let Some(children) = node.get("childFrames") {
506 let Some(children) = children.as_array() else {
507 self.runtime_prepare_probe("frame-placement", json!({"children_valid":false}));
508 return;
509 };
510 for child in children.iter().rev() { pending.push((child, Some(id))); }
511 }
512 }
513 self.runtime_prepare_probe("frame-placement", json!({"collection_complete":false}));
514 }
515
516 #[cfg(test)]
517 pub(crate) fn runtime_diagnostics(&self) -> &[Value] { &self.runtime_probe }
518
519 pub fn take_fetch_errors(&mut self) -> Vec<String> {
520 std::mem::take(&mut self.fetch_errors)
521 }
522
523 pub fn call(
524 &mut self,
525 method: &str,
526 params: Value,
527 session_id: Option<&str>,
528 ) -> Result<Value, String> {
529 let result = self.call_for(method, params, session_id, Duration::from_secs(30));
530 #[cfg(test)]
531 if result.is_err() && method == "Runtime.evaluate" && std::env::var_os("CI").is_some() {
532 eprintln!("document call refused: stages={:?}", self.runtime_diagnostics().iter().filter_map(|probe| probe.get("stage").and_then(Value::as_str)).collect::<Vec<_>>());
533 }
534 result
535 }
536
537 pub fn call_for(
538 &mut self,
539 method: &str,
540 params: Value,
541 session_id: Option<&str>,
542 timeout: Duration,
543 ) -> Result<Value, String> {
544 let session_id = session_id.filter(|id| !id.is_empty());
545 #[cfg(test)]
546 if method == "Runtime.evaluate" { self.runtime_probe.clear(); }
547 #[cfg(test)]
548 self.runtime_stage(method, "admit");
549 self.admit(method, ¶ms, session_id)?;
550 if method == "Page.navigate" { self.remember_navigation(¶ms, session_id); }
551 if method == "Target.createTarget" {
552 return self.create_target(params, timeout);
553 }
554 #[cfg(test)]
555 self.runtime_stage(method, "prepare");
556 let mut checks = self.prepare(method, ¶ms, session_id)?;
557 #[cfg(test)]
558 self.runtime_stage(method, "pin");
559 let pinned = self.pin_execution(method, ¶ms, session_id, &mut checks)?;
560 let object_pin = method == "Runtime.callFunctionOn" && params.get("objectId").is_some() && !checks.is_empty();
561 let executed = pinned.is_some() && method != "Runtime.compileScript" || object_pin;
562 let mut sent = params.clone();
563 if let Some(unique) = &pinned {
564 sent.as_object_mut().ok_or_else(|| self.refusal())?.remove("contextId");
565 sent.as_object_mut().unwrap().remove("executionContextId");
566 sent.as_object_mut().unwrap().remove("executionContextUniqueId");
567 sent.as_object_mut().unwrap().remove("executionContextName");
568 if method == "Runtime.compileScript" {
569 let target = session_id.and_then(|session| self.session_target(session)).ok_or_else(|| self.refusal())?;
570 let guard = self.guards.get(&target).or_else(|| self.runtime_sessions.get(&target)).ok_or_else(|| self.refusal())?;
571 let proof = self.context_proofs.get(&(guard.clone(), unique.clone())).ok_or_else(|| self.refusal())?;
572 sent["executionContextId"] = json!(proof.id);
573 } else { sent["uniqueContextId"] = json!(unique); }
574 }
575 let (forwarded, sent) = self.fetch_command(method, sent, session_id);
576 let compile_session = if method == "Runtime.compileScript" {
577 session_id.and_then(|session| self.session_target(session)).and_then(|target| self.guards.get(&target).or_else(|| self.runtime_sessions.get(&target))).cloned()
578 } else { None };
579 let frame_epoch = self.frame_epoch;
580 #[cfg(test)]
581 self.runtime_stage(method, "forward");
582 let result = match self.raw_call_execution(&forwarded, sent, compile_session.as_deref().or(session_id), timeout, executed) {
583 Ok(result) => result,
584 Err(message) => {
585 #[cfg(test)]
586 if method == "Runtime.evaluate" {
587 let text = message.to_lowercase();
588 self.runtime_probe.push(json!({"stage":"backend-error","refusal":message.starts_with("slicc: refused:"),
589 "context_missing":text.contains("cannot find context") || text.contains("cannot find execution context"),
590 "context_destroyed":text.contains("context was destroyed"),"transport":transport_error(&message)}));
591 }
592 if executed && !message.starts_with("slicc: refused:") && !transport_error(&message) {
593 #[cfg(test)]
594 self.runtime_stage(method, "error-page-proof");
595 self.verify_checks(&checks, false, true)?;
596 }
597 return Err(message);
598 }
599 };
600 #[cfg(test)]
601 self.runtime_stage(method, "reply-proof");
602 if content_command(method, ¶ms) == Some(true) && !executed {
603 self.verify_checks(&checks, matches!(method, "IO.read" | "IO.close"), !method.starts_with("Input."))?;
604 }
605 if executed { self.verify_execution(&checks)?; }
606 if let (true, Some(session), Some(id)) = (
607 shield::answers_interception(method),
608 session_id,
609 shield::request_key(¶ms),
610 ) {
611 self.settle(session, &id);
612 }
613 if let Some(session) = session_id {
614 if self.blocked.contains(session) && !harmless(method, ¶ms) && !executed {
615 return Err(self.refusal());
616 }
617 }
618 if let Some(frame) = params.get("frameId").and_then(Value::as_str) {
619 if self.frame_refused(frame, session_id)? {
620 return Err(self.refusal());
621 }
622 }
623 if crosses_frames(method, ¶ms) && self.frame_tree_refused(session_id)? {
624 return Err(self.refusal());
625 }
626 if (params.get("frameId").and_then(Value::as_str).is_some() || crosses_frames(method, ¶ms)) && frame_epoch != self.frame_epoch {
627 return Err(self.refusal());
628 }
629 #[cfg(test)]
630 self.runtime_stage(method, "reply-admission");
631 let result = self.after(method, ¶ms, session_id, result)?;
632 self.bind_streams(method, session_id, &result, &checks);
633 #[cfg(test)]
634 self.runtime_stage(method, "complete");
635 Ok(result)
636 }
637
638 pub fn start_navigation(&mut self, params: Value, session: Option<&str>) -> Result<i64, String> {
639 let session = session.filter(|session| !session.is_empty());
640 self.admit("Page.navigate", ¶ms, session)?;
641 self.remember_navigation(¶ms, session);
642 let id = self.send("Page.navigate", params.clone(), session)?;
643 self.navigations.insert(id, Navigation { params, session: session.map(str::to_string), reply: None, detached: false });
644 Ok(id)
645 }
646
647 pub fn poll_navigation(&mut self, id: i64) -> Option<Result<Value, String>> {
648 let navigation = self.navigations.get(&id)?;
649 if self.renderer_crashed(navigation.session.as_deref(), None) {
650 self.navigations.remove(&id);
651 return Some(Err(RENDERER_CRASHED.to_string()));
652 }
653 let refused = navigation.detached || navigation.session.as_deref().is_some_and(|session| self.blocked.contains(session) && !harmless("Page.navigate", &navigation.params));
654 if refused {
655 self.navigations.remove(&id);
656 return Some(Err(self.refusal()));
657 }
658 navigation.reply.as_ref()?;
659 let navigation = self.navigations.remove(&id).unwrap();
660 let reply = navigation.reply.unwrap();
661 if let Some(error) = reply.get("error") {
662 let (message, _) = shield::redact_text(&self.guard, error.get("message").and_then(Value::as_str).unwrap_or("CDP error"));
663 return Some(Err(format!("Page.navigate: {message}\n")));
664 }
665 Some(self.after("Page.navigate", &navigation.params, navigation.session.as_deref(), reply.get("result").cloned().unwrap_or(Value::Null)))
666 }
667
668 pub fn cancel_navigation(&mut self, id: i64) {
669 self.navigations.remove(&id);
670 }
671
672 pub fn notify(
673 &mut self,
674 method: &str,
675 params: Value,
676 session_id: Option<&str>,
677 ) -> Result<(), String> {
678 let session_id = session_id.filter(|id| !id.is_empty());
679 self.admit(method, ¶ms, session_id)?;
680 if matches!(method, "Target.createTarget" | "Target.attachToTarget") {
681 return Err(format!("{method} must be a call, not a notify\n"));
682 }
683 if content_command(method, ¶ms) == Some(true) {
684 self.call(method, params, session_id)?;
685 return Ok(());
686 }
687 self.prepare(method, ¶ms, session_id)?;
688 let answered = shield::answers_interception(method)
689 .then(|| shield::request_key(¶ms))
690 .flatten()
691 .zip(session_id.map(str::to_string));
692 let (forwarded, params) = self.fetch_command(method, params, session_id);
693 let sent = self.send(&forwarded, params, session_id)?;
694 if let Some((id, session)) = answered {
695 self.answering.insert(sent, (session, id));
696 }
697 Ok(())
698 }
699
700 fn refusal(&self) -> String {
701 self.closed.clone().unwrap_or_else(|| format!("{}\n", self.guard.refusal()))
702 }
703
704 fn closing(&mut self, message: String) -> String {
705 if crate::net::connection_closed(&message) { self.closed.get_or_insert(message).clone() } else { message }
706 }
707
708 fn connected(&self) -> Result<(), String> {
709 self.closed.clone().map_or(Ok(()), Err)
710 }
711
712 fn admit(&mut self, method: &str, params: &Value, session_id: Option<&str>) -> Result<(), String> {
713 if self.renderer_crashed(session_id, params.get("targetId").and_then(Value::as_str)) { return Err(RENDERER_CRASHED.to_string()) }
714 if method == "Page.handleJavaScriptDialog" {
715 if params.get("accept").and_then(Value::as_bool).is_none()
716 || params.get("promptText").is_some_and(|text| !text.is_string())
717 || !params.as_object().is_some_and(|object| object.keys().all(|key| matches!(key.as_str(), "accept" | "promptText"))) {
718 return Err(self.refusal());
719 }
720 return self.admit_dialog(session_id.ok_or_else(|| self.refusal())?);
721 }
722 if session_id.is_some_and(|session| self.dialog_sessions.contains(session)) { return Err(self.refusal()) }
723 if let Some(session) = session_id {
724 if self.blocked.contains(session) && !harmless(method, params) {
725 return Err(self.refusal());
726 }
727 if self.guard_sessions.contains(session) {
728 return Err(self.refusal());
729 }
730 if let Some(target) = self.session_target(session) {
731 if !self.refresh(&target)? { return Err(self.refusal()) }
732 }
733 if !recovery(method, params) && self.session_target(session).and_then(|target| self.documents.get(&target)).is_some_and(|doc| self.sealed(doc)) {
734 return Err(self.refusal());
735 }
736 if !harmless(method, params) {
737 if let Some(target) = self.session_target(session) {
738 if self.documents.get(&target).is_some_and(|doc| doc.navigating || self.guard.error_page_url(&doc.url) || !doc.unreachable_url.is_empty()) {
739 if self.target_protected(&target, false)? {
740 return Err(self.refusal());
741 }
742 } else if self.targets.get(&target).is_some_and(|info| self.info_protected(info, &[])) {
743 if self.target_protected(&target, false)? {
744 return Err(self.refusal());
745 }
746 }
747 }
748 }
749 }
750 if matches!(method, "Network.loadNetworkResource" | "Page.captureSnapshot") {
751 return Err(self.refusal());
752 }
753 if !shield::body_method(method) && self.content_refused(method, params, session_id.unwrap_or("")) {
754 return Err(self.refusal());
755 }
756 if crosses_frames(method, params) && self.frame_tree_refused(session_id)? {
757 return Err(self.refusal());
758 }
759 if Worlds::selectors(params) || Worlds::sensitive(method) {
760 let session = session_id.unwrap_or("");
761 if !self.worlds.enabled(session) && (Worlds::sensitive(method) || self.worlds.reason(session, method, params).is_some()) {
762 self.raw_call("Runtime.enable", json!({}), session_id, Duration::from_secs(10))?;
763 self.worlds.enable(session);
764 }
765 if let Some(reason) = self.worlds.reason(session, method, params) {
766 return Err(reason.into());
767 }
768 }
769 if params
770 .get("targetId")
771 .and_then(|v| v.as_str())
772 .is_some_and(|target| self.abandoned.contains(target))
773 {
774 return Err(UNGUARDED.to_string());
775 }
776 if let Some(reason) = shield::browser_wide(method, params, session_id, &self.contexts) {
777 return Err(reason);
778 }
779 if method == "Browser.setWindowBounds" {
780 self.admit_window(params, session_id)?;
781 } else if shield::persistent_capability(method) && !self.network_state_admitted(method, params, session_id) {
782 return Err(PERSISTENT.into());
783 }
784 if method == "Target.autoAttachRelated" || method == "Target.setAutoAttach"
785 && params.get("autoAttach").and_then(|v| v.as_bool()) == Some(true)
786 {
787 return Err(AUTO_ATTACH.into());
788 }
789 if matches!(method, "Target.attachToTarget" | "Target.setAutoAttach")
790 && params.get("flatten").and_then(|v| v.as_bool()) != Some(true)
791 {
792 return Err(FLATTEN.into());
793 }
794 if let Some(reason) = shield::command_url_refusal(&self.guard, params, method) {
795 if reason == self.guard.refusal() && shield::cookie_method(method) {
796 return Err(COOKIES.into());
797 }
798 return Err(format!("{reason}\n"));
799 }
800 if method.starts_with("PWA.") {
801 for key in ["manifestId", "installUrlOrBundleUrl"] {
802 if let Some(url) = params.get(key).and_then(Value::as_str) {
803 if self.guard.url(url) {
804 return Err(format!("{}\n", self.guard.refusal_for_url(url)));
805 }
806 }
807 }
808 }
809 if shield::cookie_method(method) && shield::cookie_refused(&self.guard, params) {
810 return Err(COOKIES.into());
811 }
812 if shield::names_protected_storage(&self.guard, params) {
813 return Err(self.refusal());
814 }
815 if method == "Page.navigateToHistoryEntry" {
816 let entry = params.get("entryId").and_then(|v| v.as_i64()).ok_or_else(|| self.refusal())?;
817 let history = self.raw_call(
818 "Page.getNavigationHistory",
819 json!({}),
820 session_id,
821 DOCUMENT_CHECK,
822 ).map_err(|_| self.refusal())?;
823 let url = history
824 .get("entries")
825 .and_then(|v| v.as_array())
826 .and_then(|entries| {
827 entries
828 .iter()
829 .find(|item| item.get("id").and_then(|v| v.as_i64()) == Some(entry))
830 })
831 .and_then(|item| item.get("url").and_then(|v| v.as_str()))
832 .filter(|url| !url.is_empty())
833 .ok_or_else(|| self.refusal())?
834 .to_string();
835 if self.guard.url(&url) {
836 return Err(format!("{}\n", self.guard.refusal_for_url(&url)));
837 }
838 }
839 if let Some(frame) = params.get("frameId").and_then(Value::as_str) {
840 if self.frame_refused(frame, session_id)? {
841 let url = self.frames.get(frame).and_then(|known| known.first()).map(String::as_str).unwrap_or("");
842 return Err(format!("{}\n", self.guard.refusal_for_url(url)));
843 }
844 }
845 if !matches!(method, "Target.getTargetInfo" | "Target.detachFromTarget") {
846 for key in ["targetId"] {
847 if let Some(target) = params.get(key).and_then(|v| v.as_str()) {
848 let target = target.to_string();
849 if self.target_protected(&target, false)? {
850 let url = self.targets.get(&target).and_then(|info| info.get("url")).and_then(Value::as_str).unwrap_or("");
851 return Err(format!("{}\n", self.guard.refusal_for_url(url)));
852 }
853 }
854 }
855 }
856 let issued = self.client_issued(method, params, session_id)?;
857 if shield::interception_method(method) {
858 let id = shield::request_key(params).unwrap_or_default();
859 let handed = session_id
860 .is_some_and(|session| self.handed.contains(&(session.to_string(), id.clone())));
861 if !handed || self.requests.get(&id) == Some(&true) {
862 return Err(UNSEEN.into());
863 }
864 }
865 if let Some(session) = session_id.filter(|_| issued) { self.issue(params, session)?; }
866 if shield::body_method(method) {
867 let id = shield::request_key(params);
868 match id.as_deref().and_then(|id| self.request_state(id)) {
869 Some(true) => return Err(self.refusal()),
870 Some(false) => {}
871 None => {
872 let deadline = Instant::now() + Duration::from_secs(2);
873 let mut known = None;
874 while known.is_none() && Instant::now() < deadline {
875 if !self.pump(deadline)? {
876 break;
877 }
878 known = id.as_deref().and_then(|id| self.request_state(id));
879 }
880 match known {
881 Some(false) => {}
882 Some(true) => return Err(self.refusal()),
883 None => return Err(UNSEEN.into()),
884 }
885 }
886 }
887 }
888 if method == "Network.replayXHR" { return Err(self.refusal()) }
889 if method == "IO.read" {
890 let handle = params.get("handle").and_then(|v| v.as_str()).unwrap_or("");
891 if !self.streams.contains_key(handle) {
892 return Err(STREAM.into());
893 }
894 }
895 Ok(())
896 }
897
898 pub fn settled_refusal(&self, method: &str, params: &Value, session_id: Option<&str>) -> Option<String> {
899 let session_id = session_id.filter(|id| !id.is_empty());
900 if self.renderer_crashed(session_id, params.get("targetId").and_then(Value::as_str)) { return Some(RENDERER_CRASHED.to_string()) }
901 if method == "Page.handleJavaScriptDialog" || harmless(method, params) { return None }
902 if session_id.is_some_and(|session| self.blocked.contains(session)) { return Some(self.refusal()) }
903 let reason = if matches!(method, "Network.loadNetworkResource" | "Page.captureSnapshot") {
904 self.refusal()
905 } else if shield::persistent_capability(method) && method != "Browser.setWindowBounds" && (session_id.is_none() || !shield::network_state(method, params)) {
906 PERSISTENT.to_string()
907 } else if method == "Target.autoAttachRelated" || method == "Target.setAutoAttach" && params.get("autoAttach").and_then(Value::as_bool) == Some(true) {
908 AUTO_ATTACH.to_string()
909 } else if matches!(method, "Target.attachToTarget" | "Target.setAutoAttach") && params.get("flatten").and_then(Value::as_bool) != Some(true) {
910 FLATTEN.to_string()
911 } else {
912 return None;
913 };
914 if session_id.is_some_and(|session| self.dialog_sessions.contains(session) || self.guard_sessions.contains(session))
915 || self.content_refused(method, params, session_id.unwrap_or("")) {
916 return Some(self.refusal());
917 }
918 if reason == self.refusal() { return Some(reason) }
919 if Worlds::selectors(params) || Worlds::sensitive(method) { return None }
920 if params.get("targetId").and_then(Value::as_str).is_some_and(|target| self.abandoned.contains(target)) { return Some(UNGUARDED.to_string()) }
921 Some(shield::browser_wide(method, params, session_id, &self.contexts).unwrap_or(reason))
922 }
923
924 fn network_state_admitted(&self, method: &str, params: &Value, session_id: Option<&str>) -> bool {
925 let Some(session) = session_id else { return false };
926 shield::network_state(method, params)
927 && !self.guard_sessions.contains(session)
928 && !self.dialog_sessions.contains(session)
929 && !self.blocked.contains(session)
930 && self.session_target(session).is_some_and(|target| {
931 !self.protected_targets.contains(&target)
932 && !self.abandoned.contains(&target)
933 && self.targets.get(&target).is_some_and(|info| {
934 info.get("type").and_then(Value::as_str) == Some("page") && !self.info_protected(info, &[])
935 })
936 })
937 }
938
939 fn prepare(&mut self, method: &str, params: &Value, session: Option<&str>) -> Result<Vec<(String, DocumentState)>, String> {
940 #[cfg(test)]
941 let previous = self.runtime_preparing;
942 #[cfg(test)]
943 { self.runtime_preparing = method == "Runtime.evaluate"; }
944 let result = self.prepare_state(method, params, session);
945 #[cfg(test)]
946 { self.runtime_preparing = previous; }
947 result
948 }
949
950 fn prepare_state(&mut self, method: &str, params: &Value, session: Option<&str>) -> Result<Vec<(String, DocumentState)>, String> {
951 #[cfg(test)]
952 self.runtime_prepare_probe("command-classification", json!({}));
953 let content = content_command(method, params).ok_or_else(|| self.refusal())?;
954 let mut checks = Vec::new();
955 if !content { return Ok(checks) }
956 let runtime = method.starts_with("Runtime.");
957 let page_scope = if page_scope::content(method) {
958 Some(if method == "Runtime.evaluate" && !Worlds::selectors(params) { self.verify_runtime_page_scope_state(session)? }
959 else { self.verify_page_scope_state(session)? })
960 } else { None };
961 if storage_content(method) && method != "Storage.getStorageKeyForFrame" {
962 if !self.storage_scope_verified(params) { return Err(self.refusal()) }
963 return Ok(checks);
964 }
965 if matches!(method, "Fetch.getResponseBody" | "Network.getResponseBody" | "Network.getRequestPostData")
966 && session.and_then(|session| self.session_target(session)).is_some_and(|target| self.document_paused(&target))
967 && shield::request_key(params).is_some_and(|id| self.request_state(&id) == Some(false)) { return Ok(checks) }
968 if matches!(method, "IO.read" | "IO.close") {
969 let saved = params.get("handle").and_then(Value::as_str).and_then(|handle| self.streams.get(handle)).cloned();
970 let Some((Some(target), Some(expected))) = saved else { return Err(self.refusal()) };
971 let mut current = self.document_state_for(&target, true, expected.scope.as_deref()).ok_or_else(|| self.refusal())?;
972 if let Some(proof) = &expected.page_scope {
973 let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
974 if &self.verify_page_scope(Some(&guard))? != proof { return Err(self.refusal()) }
975 current.page_scope = Some(proof.clone());
976 }
977 if current != expected { return Err(self.refusal()) }
978 checks.push((target, current));
979 }
980 if let Some(session) = session {
981 #[cfg(test)]
982 self.runtime_prepare_probe("selected-session", json!({"target_known":self.session_target(session).is_some(),"blocked":self.blocked.contains(session)}));
983 let target = self.session_target(session).ok_or_else(|| self.refusal())?;
984 if !checks.iter().any(|(known, _)| known == &target) {
985 let selected_frame = params.get("frameId").and_then(Value::as_str).map(str::to_string).or_else(|| {
986 ["contextId", "executionContextId", "uniqueContextId", "executionContextUniqueId", "executionContextName"].into_iter().find_map(|key| {
987 let value = params.get(key)?;
988 let label = if matches!(key, "contextId" | "executionContextId") { "id" } else if key == "executionContextName" { "name" } else { "unique" };
989 let value = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
990 self.context_frames.get(&(session.to_string(), format!("{label}:{value}"))).cloned()
991 })
992 });
993 let scope = if crosses_frames(method, params) || page_scope::content(method) && !runtime { None } else { Some(selected_frame.as_deref().unwrap_or("")) };
994 let mut state = if self.document_paused(&target) {
995 #[cfg(test)]
996 self.runtime_prepare_probe("paused-document-provenance", json!({"cached_state":self.cached_documents.contains_key(&target),
997 "guard_present":self.guards.contains_key(&target),"document_known":self.documents.contains_key(&target),
998 "previous_protected":self.documents.get(&target).is_some_and(|doc| doc.previous),"target_protected":self.protected_targets.contains(&target)}));
999 if !matches!(method, "Runtime.evaluate" | "Runtime.callFunctionOn") { return Err(self.refusal()) }
1000 self.cached_documents.get(&target).cloned().ok_or_else(|| self.refusal())?
1001 } else if let Some((_, state)) = page_scope.as_ref().filter(|(proof, state)| proof.0 == target && (!runtime || Self::runtime_page_reusable(state))) {
1002 #[cfg(test)]
1003 self.runtime_prepare_probe("selected-frame-placement", json!({"source":"fresh-page-proof","explicit_frame":selected_frame.is_some()}));
1004 if runtime { Self::scoped_page_state(state, scope.unwrap_or("")).ok_or_else(|| self.refusal())? } else { state.clone() }
1005 } else {
1006 #[cfg(test)]
1007 self.runtime_prepare_probe("selected-document-state", json!({"source":"private-document-proof","guard_present":self.guards.contains_key(&target)}));
1008 self.document_state_for(&target, true, scope).ok_or_else(|| self.refusal())?
1009 };
1010 #[cfg(test)]
1011 self.runtime_prepare_probe("blocking", json!({"blocked":self.blocked.contains(session)}));
1012 if self.blocked.contains(session) { return Err(self.refusal()) }
1013 for key in ["frameId", "contextId", "executionContextId", "uniqueContextId", "executionContextUniqueId", "executionContextName"] {
1014 if let Some(value) = params.get(key) {
1015 let frame = if key == "frameId" {
1016 value.as_str().map(str::to_string)
1017 } else {
1018 let label = if matches!(key, "contextId" | "executionContextId") { "id" } else if key == "executionContextName" { "name" } else { "unique" };
1019 let value = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
1020 self.context_frames.get(&(session.to_string(), format!("{label}:{value}"))).cloned()
1021 };
1022 #[cfg(test)]
1023 self.runtime_prepare_probe("selector-placement", json!({"selector_known":frame.is_some(),"frame_present":frame.as_ref().is_some_and(|frame| state.frames.contains_key(frame))}));
1024 if frame.is_none_or(|frame| !state.frames.contains_key(&frame)) { return Err(self.refusal()) }
1025 }
1026 }
1027 state.page_scope = page_scope.map(|(proof, _)| proof);
1028 state.runtime_scope_epoch = state.page_scope.as_ref().filter(|_| runtime).map(|proof| self.runtime_scope_epochs.get(&proof.0).copied().unwrap_or(0));
1029 checks.push((target, state));
1030 }
1031 }
1032 #[cfg(test)]
1033 self.runtime_prepare_probe("selected-state-complete", json!({"checks_present":!checks.is_empty()}));
1034 if checks.is_empty() { return Err(self.refusal()) }
1035 Ok(checks)
1036 }
1037
1038 fn scoped_page_state(state: &DocumentState, scope: &str) -> Option<DocumentState> {
1039 let mut current = if scope.is_empty() { state.frames.iter().find(|(_, frame)| frame[4].is_empty())?.0.as_str() } else { scope };
1040 let mut selected = state.clone();
1041 selected.frames.clear();
1042 loop {
1043 let frame = state.frames.get(current)?;
1044 if selected.frames.insert(current.to_string(), frame.clone()).is_some() { return None }
1045 if frame[4].is_empty() { break }
1046 current = &frame[4];
1047 }
1048 selected.scope = Some(scope.to_string());
1049 Some(selected)
1050 }
1051
1052 fn runtime_page_reusable(state: &DocumentState) -> bool {
1053 state.frames.values().find(|frame| frame[4].is_empty()).is_some_and(|frame| {
1054 url::Url::parse(&frame[1]).is_ok_and(|url| matches!(url.scheme(), "http" | "https"))
1055 })
1056 }
1057
1058 fn document_paused(&self, target: &str) -> bool {
1059 self.paused_documents.iter().any(|(session, _)| self.session_target(session).as_deref() == Some(target))
1060 }
1061
1062 fn pin_execution(&mut self, method: &str, params: &Value, session: Option<&str>, checks: &mut Vec<(String, DocumentState)>) -> Result<Option<String>, String> {
1063 if !matches!(method, "Runtime.evaluate" | "Runtime.callFunctionOn" | "Runtime.compileScript") || checks.is_empty() { return Ok(None) }
1064 if method == "Runtime.callFunctionOn" && params.get("objectId").is_some() { return Ok(None) }
1065 let session = session.ok_or_else(|| self.refusal())?;
1066 let target = self.session_target(session).ok_or_else(|| self.refusal())?;
1067 let mut state = checks.iter().find(|(id, _)| id == &target).map(|(_, state)| state.clone()).ok_or_else(|| self.refusal())?;
1068 let guard = if let Some(guard) = self.guards.get(&target).or_else(|| self.runtime_sessions.get(&target)) {
1069 guard.clone()
1070 } else {
1071 if state.worker.is_none() { return Err(self.refusal()) }
1072 let result = self.raw_call("Target.attachToTarget", json!({"targetId":target,"flatten":true}), None, DOCUMENT_CHECK)?;
1073 let guard = result.get("sessionId").and_then(Value::as_str).ok_or_else(|| self.refusal())?.to_string();
1074 self.guard_sessions.insert(guard.clone());
1075 self.session_targets.insert(guard.clone(), target.clone());
1076 self.runtime_sessions.insert(target.clone(), guard.clone());
1077 guard
1078 };
1079 let choose = |proofs: &BTreeMap<(String, String), ContextProof>, state: &DocumentState| {
1080 proofs.iter().find(|((owner, unique), proof)| {
1081 owner == &guard && if let Some(id) = params.get("contextId").or_else(|| params.get("executionContextId")).and_then(Value::as_i64) {
1082 proof.id == id
1083 } else if let Some(id) = params.get("uniqueContextId").or_else(|| params.get("executionContextUniqueId")).and_then(Value::as_str) {
1084 unique == id
1085 } else if let Some(name) = params.get("executionContextName").and_then(Value::as_str) {
1086 proof.name == name
1087 } else { proof.default && (state.worker.is_some() || state.scope.as_deref() == Some(proof.frame.as_str()) || state.frames.get(&proof.frame).is_some_and(|evidence| evidence[4].is_empty())) }
1088 }).map(|((_, unique), proof)| (unique.clone(), proof.clone()))
1089 };
1090 let mut selected = choose(&self.context_proofs, &state);
1091 #[cfg(test)]
1092 self.runtime_contexts(method, "private-context-initial", &guard, &target, &state, selected.as_ref().map(|(unique, _)| unique.as_str()));
1093 let paused = self.document_paused(&target);
1094 let stable_context = selected.as_ref().is_some_and(|(_, proof)| state.worker.is_some()
1095 || state.frames.get(&proof.frame).is_some_and(|evidence| proof.loader.as_deref() == Some(evidence[0].as_str())));
1096 if !paused && !stable_context {
1097 let deadline = Instant::now() + DOCUMENT_CHECK;
1098 for attempt in 0..2 {
1099 self.context_proofs.retain(|(owner, _), _| owner != &guard);
1100 let left = deadline.saturating_duration_since(Instant::now());
1101 if left.is_zero() { return Err(self.refusal()) }
1102 self.raw_call("Runtime.disable", json!({}), Some(&guard), left)?;
1103 let left = deadline.saturating_duration_since(Instant::now());
1104 if left.is_zero() { return Err(self.refusal()) }
1105 self.raw_call("Runtime.enable", json!({}), Some(&guard), left)?;
1106 #[cfg(test)]
1107 self.runtime_contexts(method, "private-context-replayed", &guard, &target, &state, selected.as_ref().map(|(unique, _)| unique.as_str()));
1108 let current = self.document_state_for(&target, true, state.scope.as_deref()).ok_or_else(|| self.refusal())?;
1109 #[cfg(test)]
1110 self.runtime_contexts(method, "private-context-current", &guard, &target, ¤t, selected.as_ref().map(|(unique, _)| unique.as_str()));
1111 let same = if state.worker.is_some() { state.worker == current.worker } else {
1112 state.frames.iter().filter(|(_, evidence)| evidence[4].is_empty()).all(|(id, evidence)| current.frames.get(id).is_some_and(|now| evidence[0] == now[0] && evidence[2] == now[2]))
1113 };
1114 if self.blocked.contains(session) { return Err(self.refusal()) }
1115 if !same {
1116 let ordinary_default_commit = state.worker.is_none() && state.scope.as_deref() == Some("") && state.frames.len() == 1
1117 && !Worlds::selectors(params) && self.guards.get(&target) == Some(&guard)
1118 && state.page_scope.as_ref().is_some_and(|proof| proof.0 == target)
1119 && state.frames.iter().all(|(id, before)| current.frames.get(id).is_some_and(|after| before[0] != after[0]
1120 && Self::runtime_page_reusable(¤t)));
1121 if attempt != 0 || !ordinary_default_commit { return Err(self.refusal()) }
1122 #[cfg(test)]
1123 self.runtime_stage(method, "private-context-readmission");
1124 *checks = self.prepare(method, params, Some(session))?;
1125 state = checks.iter().find(|(id, _)| id == &target).map(|(_, state)| state.clone()).ok_or_else(|| self.refusal())?;
1126 continue;
1127 }
1128 selected = choose(&self.context_proofs, &state);
1129 if selected.is_some() { break }
1130 }
1131 }
1132 #[cfg(test)]
1133 self.runtime_contexts(method, "private-context-validation", &guard, &target, &state, selected.as_ref().map(|(unique, _)| unique.as_str()));
1134 let (unique, proof) = selected.ok_or_else(|| self.refusal())?;
1135 let owned_world = !proof.default && self.created_contexts.contains(&(session.to_string(), proof.id));
1136 let owned_blank = proof.default && state.frames.get(&proof.frame).is_some_and(|evidence| evidence[1] == "about:blank"
1137 && self.owns_blank(&target, &proof.frame, &evidence[0]));
1138 if unique.is_empty() || proof.origin == "null" && !owned_blank || proof.origin.is_empty() && !owned_world && !owned_blank || self.guard.url(&proof.origin) { return Err(self.refusal()) }
1139 if state.worker.is_none() {
1140 let evidence = state.frames.get(&proof.frame).ok_or_else(|| self.refusal())?;
1141 if proof.loader.as_deref() != Some(evidence[0].as_str()) { return Err(self.refusal()) }
1142 }
1143 Ok(Some(unique))
1144 }
1145
1146 fn storage_scope_verified(&self, params: &Value) -> bool {
1147 let mut scopes = Vec::new();
1148 fn collect(value: &Value, scopes: &mut Vec<String>) -> bool {
1149 let Some(items) = value.as_object() else { return false };
1150 for (key, value) in items {
1151 if matches!(key.as_str(), "origin" | "securityOrigin" | "storageKey" | "cacheId") {
1152 let Some(text) = value.as_str() else { return false };
1153 scopes.push(if key == "cacheId" { text.split('|').next().unwrap_or("").to_string() } else { text.to_string() });
1154 } else if value.is_object() && !collect(value, scopes) { return false }
1155 }
1156 true
1157 }
1158 collect(params, &mut scopes) && !scopes.is_empty() && scopes.iter().all(|scope| {
1159 let mut parts = scope.split('^');
1160 let origin = parts.next().unwrap_or("");
1161 let valid = |text: &str| url::Url::parse(text).ok().is_some_and(|url| url.origin().ascii_serialization() != "null" && !self.guard.url(text));
1162 valid(origin) && !self.guard.storage_key(scope)
1163 && parts.filter_map(|part| part.strip_prefix('0')).all(valid)
1164 })
1165 }
1166
1167 fn verify_checks(&mut self, checks: &[(String, DocumentState)], same_document: bool, stable_page: bool) -> Result<(), String> {
1168 for (target, expected) in checks {
1169 let current = if let Some(proof) = &expected.page_scope {
1170 let session = self.guards.get(&proof.0).cloned().ok_or_else(|| self.refusal())?;
1171 let (current, state) = self.verify_page_scope_state(Some(&session))?;
1172 if stable_page && ¤t != proof { return Err(self.refusal()) }
1173 if current.0 == *target && (expected.runtime_scope_epoch.is_none() || Self::runtime_page_reusable(&state)) {
1174 Some(if expected.runtime_scope_epoch.is_some() { Self::scoped_page_state(&state, expected.scope.as_deref().unwrap_or("")).ok_or_else(|| self.refusal())? } else { state })
1175 } else { None }
1176 } else { None };
1177 let current = match current {
1178 Some(state) => state,
1179 None => self.document_state_for(target, true, expected.scope.as_deref()).ok_or_else(|| self.refusal())?,
1180 };
1181 if same_document && ¤t != expected { return Err(self.refusal()) }
1182 }
1183 Ok(())
1184 }
1185
1186 fn verify_execution(&mut self, checks: &[(String, DocumentState)]) -> Result<(), String> {
1187 for (_, state) in checks {
1188 if let Some(proof) = &state.page_scope {
1189 let session = self.guards.get(&proof.0).cloned().ok_or_else(|| self.refusal())?;
1190 if self.session_target(&session).as_deref() != Some(&proof.0) { return Err(self.refusal()) }
1191 if self.page_scope_unknown != proof.2 || state.runtime_scope_epoch != Some(self.runtime_scope_epochs.get(&proof.0).copied().unwrap_or(0)) {
1192 if self.verify_page_scope(Some(&session))?.0 != proof.0 { return Err(self.refusal()) }
1193 }
1194 }
1195 }
1196 for (target, state) in checks {
1197 let owner = state.worker.as_ref().map_or(target.as_str(), |(_, owner)| owner.as_str());
1198 if self.unchecked_document(owner) {
1199 let guard = self.guards.get(owner).cloned().ok_or_else(|| self.refusal())?;
1200 self.verify_page_scope_state(Some(&guard))?;
1201 }
1202 }
1203 let sealed = checks.iter().any(|(target, state)| {
1204 let owner = state.worker.as_ref().map_or(target.as_str(), |(_, owner)| owner.as_str());
1205 self.unchecked_document(owner) || self.documents.get(owner).is_some_and(|doc| self.unverified_error(&doc.url, &doc.unreachable_url))
1206 });
1207 if sealed { Err(self.refusal()) } else { Ok(()) }
1208 }
1209
1210 fn unverified_error(&self, url: &str, unreachable: &str) -> bool {
1211 let error = self.guard.error_page_url(url) || !unreachable.is_empty()
1212 || url.get(..13).is_some_and(|scheme| scheme.eq_ignore_ascii_case("chrome-error:"));
1213 error && (url::Url::parse(unreachable).is_err() || self.guard.url(unreachable))
1214 }
1215
1216 fn bind_streams(&mut self, method: &str, session: Option<&str>, result: &Value, checks: &[(String, DocumentState)]) {
1217 let target = session.and_then(|session| self.session_target(session));
1218 let document = target.as_ref().and_then(|target| checks.iter().find(|(known, _)| known == target)).map(|(_, state)| state.clone());
1219 for stream in [result.get("stream"), result.pointer("/resource/stream")].into_iter().flatten().filter_map(Value::as_str) {
1220 self.streams.insert(stream.to_string(), (target.clone(), document.clone()));
1221 }
1222 if method == "IO.resolveBlob" {
1223 if let Some(uuid) = result.get("uuid").and_then(Value::as_str) {
1224 self.streams.insert(format!("blob:{uuid}"), (target, document));
1225 }
1226 }
1227 }
1228
1229 fn request_state(&self, id: &str) -> Option<bool> {
1230 if self.redirects.contains(id) { Some(true) } else { self.requests.get(id).copied() }
1231 }
1232
1233 fn after(
1234 &mut self,
1235 method: &str,
1236 params: &Value,
1237 session_id: Option<&str>,
1238 mut result: Value,
1239 ) -> Result<Value, String> {
1240 if self.content_refused(method, params, session_id.unwrap_or("")) {
1241 return Err(self.refusal());
1242 }
1243 if Worlds::sensitive(method) {
1244 if let Some(reason) = self.worlds.reason(session_id.unwrap_or(""), method, params) {
1245 return Err(reason.into());
1246 }
1247 }
1248 if method == "Runtime.enable" {
1249 self.worlds.enable(session_id.unwrap_or(""));
1250 } else if method == "Runtime.disable" {
1251 self.worlds.disable(session_id.unwrap_or(""));
1252 }
1253 self.worlds.reply(session_id.unwrap_or(""), method, &result);
1254 if method == "Page.createIsolatedWorld" {
1255 self.worlds.created(session_id.unwrap_or(""), &result);
1256 if let (Some(frame), Some(id)) = (params.get("frameId").and_then(Value::as_str), result.get("executionContextId")) {
1257 if let Some(id) = id.as_i64() { self.created_contexts.insert((session_id.unwrap_or("").to_string(), id)); }
1258 self.context_frames.insert((session_id.unwrap_or("").to_string(), format!("id:{id}")), frame.to_string());
1259 }
1260 }
1261 if method == "Runtime.compileScript" {
1262 self.worlds.script(session_id.unwrap_or(""), &result);
1263 }
1264 match method {
1265 "Page.navigate" if params.get("url").and_then(Value::as_str) == Some("about:blank")
1266 && params.get("frameId").is_none() && result.get("errorText").is_none()
1267 && result.get("isDownload").is_none_or(|download| download == &json!(false)) => {
1268 if let (Some(target), Some(frame), Some(loader)) = (
1269 session_id.and_then(|session| self.session_target(session)),
1270 result.get("frameId").and_then(Value::as_str).filter(|id| frame_text_known(id)),
1271 result.get("loaderId").and_then(Value::as_str).filter(|id| frame_text_known(id)),
1272 ) {
1273 if let Some(guard) = self.guards.get(&target).filter(|guard| !self.blocked.contains(*guard)) {
1274 if !self.protected_targets.contains(&target) && !self.abandoned.contains(&target)
1275 && self.documents.get(&target).is_some_and(|doc| !doc.previous && doc.frame_id == frame)
1276 && self.targets.get(&target).and_then(|info| info.get("type")).and_then(Value::as_str) == Some("page") {
1277 self.navigated_blanks.insert(target, (guard.clone(), frame.to_string(), loader.to_string()));
1278 }
1279 }
1280 }
1281 }
1282 "Target.attachToTarget" => {
1283 let session = result
1284 .get("sessionId")
1285 .and_then(|v| v.as_str())
1286 .unwrap_or("")
1287 .to_string();
1288 let target = params
1289 .get("targetId")
1290 .and_then(|v| v.as_str())
1291 .unwrap_or("")
1292 .to_string();
1293 if !session.is_empty() {
1294 self.session_targets.insert(session.clone(), target.clone());
1295 if let Err(message) = self.touch(&target) {
1296 if transport_error(&message) {
1297 self.session_targets.remove(&session);
1298 return Err(message);
1299 }
1300 let detached = self.raw_call(
1301 "Target.detachFromTarget",
1302 json!({ "sessionId": session }),
1303 None,
1304 Duration::from_secs(5),
1305 );
1306 self.session_targets.remove(&session);
1307 if let Err(closed) = detached {
1308 if transport_error(&closed) {
1309 return Err(closed);
1310 }
1311 }
1312 return Err(message);
1313 }
1314 if self.protected_targets.contains(&target) {
1315 self.block(&session);
1316 return Err(self.refusal());
1317 }
1318 }
1319 }
1320 "Target.createBrowserContext" => {
1321 if let Some(id) = result.get("browserContextId").and_then(|v| v.as_str()) {
1322 self.contexts.insert(id.to_string());
1323 }
1324 }
1325 "Target.getTargets" => {
1326 let infos = result
1327 .get("targetInfos")
1328 .and_then(|v| v.as_array())
1329 .cloned()
1330 .unwrap_or_default();
1331 for info in &infos {
1332 self.probe_error(info)?;
1333 }
1334 for info in &infos {
1335 if let Some(id) = info.get("targetId").and_then(|v| v.as_str()) {
1336 self.targets.insert(id.to_string(), info.clone());
1337 }
1338 }
1339 let mut out = Vec::new();
1340 for info in &infos {
1341 let redacted = self.redact(info, &infos);
1342 let url = info.get("url").and_then(Value::as_str).unwrap_or("");
1343 if !(self.guard.target_type(info.get("type").and_then(Value::as_str).unwrap_or(""))
1344 || self.guard.unparseable(url) && self.guard.url(url)) {
1345 out.push(redacted);
1346 }
1347 }
1348 result["targetInfos"] = Value::Array(out);
1349 }
1350 "Target.getTargetInfo" => {
1351 if let Some(info) = result.get("targetInfo").cloned() {
1352 let target = info.get("targetId").and_then(|v| v.as_str()).unwrap_or("").to_string();
1353 self.targets.insert(target.clone(), info.clone());
1354 self.probe_error(&info)?;
1355 let protected = self.info_protected(&info, &[]);
1356 if protected && !self.documents.contains_key(&target) {
1357 self.protect_target(&target);
1358 }
1359 if protected || self.discovery_redacted(&info) {
1360 result["targetInfo"] = shield::redacted(&info);
1361 }
1362 }
1363 }
1364 _ if shield::cookie_reader(method) => {
1365 let mut cookies = result.get("cookies").and_then(Value::as_array).cloned().unwrap_or_default();
1366 cookies.extend(self.trace_cookies.iter().cloned());
1367 shield::redact_cookie_events(&self.guard, &mut self.events, &json!({ "cookies": cookies }));
1368 shield::drop_protected_cookies(&self.guard, &mut result);
1369 }
1370 _ => {}
1371 }
1372 shield::redact_strings(&self.guard, &mut result);
1373 Ok(result)
1374 }
1375
1376 fn discovery_redacted(&self, info: &Value) -> bool {
1377 self.guard.error_redaction()
1378 && self.guard.url(info.get("url").and_then(Value::as_str).unwrap_or(""))
1379 }
1380
1381 fn redact(&mut self, info: &Value, all: &[Value]) -> Value {
1382 let protected = self.info_protected(info, all);
1383 if protected {
1384 if let Some(target) = info.get("targetId").and_then(Value::as_str) {
1385 if !self.documents.contains_key(target) {
1386 self.protect_target(target);
1387 }
1388 }
1389 }
1390 if protected || self.discovery_redacted(info) {
1391 shield::redacted(info)
1392 } else {
1393 info.clone()
1394 }
1395 }
1396
1397 fn info_protected(&self, info: &Value, all: &[Value]) -> bool {
1398 if self.guard.target_type(info.get("type").and_then(Value::as_str).unwrap_or("")) {
1399 return true;
1400 }
1401 let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("");
1402 let id = info.get("targetId").and_then(|v| v.as_str()).unwrap_or("");
1403 let previous = self.protected_targets.contains(id);
1404 if let Some(doc) = self.documents.get(id) {
1405 if doc.navigating {
1406 return doc.previous || self.guard.url(url) || doc.navigation_url.is_empty() || self.guard.url(&doc.navigation_url);
1407 }
1408 if (self.guard.error_page_url(&doc.url) || !doc.unreachable_url.is_empty()) && !self.masked_error(doc, url) {
1409 if !self.guard.error_document(&doc.url, &doc.origin)
1410 || !(self.reported_unreachable(&doc.unreachable_url, url) || self.guard.error_page_url(url) && doc.url == url && !doc.unreachable_url.is_empty()) {
1411 return true;
1412 }
1413 return self.guard.target_with_document(&doc.unreachable_url, doc.previous, Opener::None,
1414 Some(&doc.url), Some(&doc.unreachable_url), doc.navigating);
1415 }
1416 if doc.previous {
1417 return true;
1418 }
1419 }
1420 let opener = match info
1421 .get("openerId")
1422 .and_then(|v| v.as_str())
1423 .filter(|v| !v.is_empty())
1424 {
1425 None => Opener::None,
1426 Some(opener) => match all
1427 .iter()
1428 .find(|item| item.get("targetId").and_then(|v| v.as_str()) == Some(opener))
1429 {
1430 Some(found) if found.get("targetId") == info.get("targetId") => Opener::Unknown,
1431 Some(found) if self.info_protected(found, &[]) => Opener::Protected,
1432 Some(_) => Opener::Unprotected,
1433 None if self.protected_targets.contains(opener) => Opener::Protected,
1434 None => match self.targets.get(opener) {
1435 Some(found) if found.get("targetId") != info.get("targetId") => {
1436 if self.info_protected(found, &[]) {
1437 Opener::Protected
1438 } else {
1439 Opener::Unprotected
1440 }
1441 }
1442 _ => Opener::Unknown,
1443 },
1444 },
1445 };
1446 self.guard.target(url, previous, opener)
1447 }
1448
1449 pub fn target_protected(&mut self, target_id: &str, frame: bool) -> Result<bool, String> {
1450 self.judge_target(target_id, frame, true)
1451 }
1452
1453 fn judge_target(&mut self, target_id: &str, frame: bool, refusal_protects: bool) -> Result<bool, String> {
1454 if self.protected_targets.contains(target_id) {
1455 return Ok(true);
1456 }
1457 let info = match self.raw_call(
1458 "Target.getTargetInfo",
1459 json!({ "targetId": target_id }),
1460 None,
1461 Duration::from_secs(30),
1462 ) {
1463 Ok(info) => info.get("targetInfo").cloned().unwrap_or(Value::Null),
1464 Err(message) if refusal_protects && message.starts_with("Target.getTargetInfo: slicc: refused:") => {
1465 self.protected_targets.insert(target_id.to_string());
1466 return Ok(true);
1467 }
1468 Err(_) if frame => return Ok(false),
1469 Err(message) => return Err(message),
1470 };
1471 if info.is_object() {
1472 self.targets.insert(target_id.to_string(), info.clone());
1473 if self.documents.get(target_id).is_some_and(|doc| doc.probed) {
1474 self.documents.remove(target_id);
1475 } else if !self.refresh(target_id)? {
1476 return Ok(true);
1477 }
1478 self.probe_error(&info)?;
1479 }
1480 let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
1481 let opener = match info
1482 .get("openerId")
1483 .and_then(|v| v.as_str())
1484 .filter(|id| !id.is_empty())
1485 .map(str::to_string)
1486 {
1487 None => Opener::None,
1488 Some(id) if self.protected_targets.contains(&id) => Opener::Protected,
1489 Some(id) => match self.raw_call(
1490 "Target.getTargetInfo",
1491 json!({ "targetId": id }),
1492 None,
1493 Duration::from_secs(30),
1494 ) {
1495 Ok(found) => {
1496 let opener_info = found.get("targetInfo").unwrap_or(&Value::Null);
1497 let opener_url = opener_info.get("url").and_then(Value::as_str).unwrap_or("");
1498 if self.guard.target_type(opener_info.get("type").and_then(Value::as_str).unwrap_or(""))
1499 || self.guard.target(opener_url, false, Opener::Unknown)
1500 {
1501 Opener::Protected
1502 } else {
1503 Opener::Unprotected
1504 }
1505 }
1506 Err(_) => Opener::Unknown,
1507 },
1508 };
1509 let protected = if self.documents.contains_key(target_id) {
1510 self.info_protected(&info, &[])
1511 } else {
1512 self.guard.target_type(info.get("type").and_then(Value::as_str).unwrap_or(""))
1513 || self.guard.target(&url, false, opener)
1514 };
1515 if protected && !self.documents.contains_key(target_id) {
1516 self.protect_target(target_id);
1517 }
1518 Ok(protected)
1519 }
1520
1521 fn create_target(&mut self, params: Value, timeout: Duration) -> Result<Value, String> {
1522 let url = params
1523 .get("url")
1524 .and_then(|v| v.as_str())
1525 .unwrap_or("about:blank")
1526 .to_string();
1527 let mut blank = params.clone();
1528 blank["url"] = json!("about:blank");
1529 let mark = self.events.len();
1530 let result = self.raw_call("Target.createTarget", blank, None, timeout)?;
1531 let target = result
1532 .get("targetId")
1533 .and_then(|v| v.as_str())
1534 .unwrap_or("")
1535 .to_string();
1536 if let Err(message) = self.touch(&target) {
1537 let closed = self
1538 .raw_call(
1539 "Target.closeTarget",
1540 json!({ "targetId": target }),
1541 None,
1542 Duration::from_secs(5),
1543 )
1544 .and_then(|result| {
1545 if result.get("success").and_then(Value::as_bool) != Some(true) {
1546 return Err("Target.closeTarget did not confirm success\n".into());
1547 }
1548 self.wait_destroyed(&target, mark, Duration::from_secs(2))
1549 });
1550 return Err(match closed {
1551 Ok(()) => message,
1552 Err(error) => format!("{message}slicc: refused: cleanup: {}\n", error.trim()),
1553 });
1554 }
1555 self.record_initial_blank(&target);
1556 if !url.eq_ignore_ascii_case("about:blank") {
1557 let guard = self.guards.get(&target).cloned();
1558 self.send("Page.navigate", json!({ "url": url }), guard.as_deref())?;
1559 }
1560 Ok(result)
1561 }
1562
1563 fn record_initial_blank(&mut self, target: &str) {
1564 if let Some(recorded) = self.initial_blank_evidence(target, true) {
1565 self.created_blanks.insert(target.to_string(), recorded);
1566 let _ = self.document_state_for(target, true, Some(""));
1567 }
1568 }
1569
1570 pub fn created_blank(&self, target: &str) -> Option<Value> {
1571 self.created_blanks.get(target).map(|(frame, loader, entry)| json!([frame, loader, entry]))
1572 }
1573
1574 pub fn restore_created_blank(&mut self, target: &str, proof: &Value) -> bool {
1575 let expected = proof.as_array().filter(|values| values.len() == 3).and_then(|values| {
1576 Some((values[0].as_str()?.to_string(), values[1].as_str()?.to_string(), values[2].as_i64()?))
1577 });
1578 let Some(expected) = expected else { return false };
1579 if self.initial_blank_evidence(target, false).as_ref() != Some(&expected) { return false }
1580 self.created_blanks.insert(target.to_string(), expected);
1581 true
1582 }
1583
1584 fn owns_blank(&self, target: &str, frame: &str, loader: &str) -> bool {
1585 self.created_blanks.get(target).is_some_and(|(id, version, _)| id == frame && version == loader)
1586 || self.navigated_blanks.get(target).is_some_and(|(guard, id, version)| self.guards.get(target) == Some(guard) && id == frame && version == loader)
1587 }
1588
1589 fn initial_blank_evidence(&mut self, target: &str, empty: bool) -> Option<(String, String, i64)> {
1590 let guard = self.guards.get(target)?.clone();
1591 let result = self.raw_call("Target.getTargetInfo", json!({"targetId":target}), None, DOCUMENT_CHECK).ok()?;
1592 let info = result.get("targetInfo")?;
1593 if info.get("targetId").and_then(Value::as_str) != Some(target)
1594 || info.get("type").and_then(Value::as_str) != Some("page")
1595 || info.get("url").and_then(Value::as_str) != Some("about:blank")
1596 || info.get("openerId").is_some() || info.get("openerFrameId").is_some() { return None }
1597 let history = self.raw_call("Page.getNavigationHistory", json!({}), Some(&guard), DOCUMENT_CHECK).ok()?;
1598 let entries = history.get("entries")?.as_array()?;
1599 if history.get("currentIndex").and_then(Value::as_i64) != Some(0) || entries.len() != 1
1600 || entries[0].get("url").and_then(Value::as_str) != Some("about:blank") { return None }
1601 let entry = entries[0].get("id")?.as_i64()?;
1602 let state = self.document_state(target, false)?;
1603 if empty && state.frames.len() != 1 { return None }
1604 let (id, evidence) = state.frames.iter().find(|(_, evidence)| evidence[4].is_empty())?;
1605 if evidence[1] != "about:blank" || !evidence[3].is_empty()
1606 || matches!(evidence[2].as_str(), "" | "null") || self.guard.url(&evidence[2]) { return None }
1607 Some((id.clone(), evidence[0].clone(), entry))
1608 }
1609
1610 fn wait_destroyed(&mut self, target: &str, mark: usize, timeout: Duration) -> Result<(), String> {
1611 let deadline = Instant::now() + timeout;
1612 let destroyed = |events: &[Value]| {
1613 events[mark..].iter().any(|event| {
1614 event.get("method").and_then(Value::as_str) == Some("Target.targetDestroyed")
1615 && event.pointer("/params/targetId").and_then(Value::as_str) == Some(target)
1616 })
1617 };
1618 loop {
1619 if destroyed(&self.events) {
1620 return Ok(());
1621 }
1622 let left = deadline.saturating_duration_since(Instant::now());
1623 if left.is_zero() {
1624 return Err(
1625 "Target.targetDestroyed timed out and Target.getTargets did not confirm closure\n".into()
1626 );
1627 }
1628 let listed = self.raw_call("Target.getTargets", json!({}), None, left);
1629 let absent = listed
1630 .as_ref()
1631 .ok()
1632 .and_then(|result| result.get("targetInfos"))
1633 .and_then(Value::as_array)
1634 .is_some_and(|infos| {
1635 infos.iter().all(|info| info.get("targetId").and_then(Value::as_str).is_some())
1636 && !infos.iter().any(|info| info.get("targetId").and_then(Value::as_str) == Some(target))
1637 });
1638 if destroyed(&self.events) || absent {
1639 return Ok(());
1640 }
1641 let left = deadline.saturating_duration_since(Instant::now());
1642 self.pump(Instant::now() + left.min(Duration::from_millis(100)))?;
1643 }
1644 }
1645
1646 fn touch(&mut self, target_id: &str) -> Result<(), String> {
1647 if target_id.is_empty() || self.guards.contains_key(target_id) {
1648 return Ok(());
1649 }
1650 let kind = match self.targets.get(target_id) {
1651 Some(info) => info.get("type").and_then(|v| v.as_str()).map(str::to_string),
1652 None => self
1653 .raw_call(
1654 "Target.getTargetInfo",
1655 json!({ "targetId": target_id }),
1656 None,
1657 Duration::from_secs(10),
1658 )
1659 .ok()
1660 .and_then(|info| {
1661 info.pointer("/targetInfo/type")
1662 .and_then(|v| v.as_str())
1663 .map(str::to_string)
1664 }),
1665 };
1666 if kind
1667 .as_deref()
1668 .is_some_and(|kind| !matches!(kind, "page" | "iframe" | "background_page" | "webview" | "other"))
1669 {
1670 return Ok(());
1671 }
1672 let budget = Instant::now() + GUARD_SETUP;
1673 if !self.discovering {
1674 self.raw_call(
1675 "Target.setDiscoverTargets",
1676 json!({ "discover": true }),
1677 None,
1678 GUARD_SETUP,
1679 )
1680 .map_err(unguarded)?;
1681 self.discovering = true;
1682 }
1683 self.guarding.insert(target_id.to_string());
1684 let left = budget.saturating_duration_since(Instant::now());
1685 let attached = if left.is_zero() {
1686 Err("Target.attachToTarget timed out\n".to_string())
1687 } else {
1688 self.raw_call(
1689 "Target.attachToTarget",
1690 json!({ "targetId": target_id, "flatten": true }),
1691 None,
1692 left,
1693 )
1694 };
1695 self.guarding.remove(target_id);
1696 let session = match attached
1697 .map(|value| value.get("sessionId").and_then(|v| v.as_str()).map(str::to_string))
1698 {
1699 Ok(Some(session)) => session,
1700 Ok(None) => {
1701 self.abandon(target_id);
1702 return Err(UNGUARDED.to_string());
1703 }
1704 Err(message) => {
1705 self.abandon(target_id);
1706 return Err(unguarded(message));
1707 }
1708 };
1709 self.guard_sessions.insert(session.clone());
1710 self.guards.insert(target_id.to_string(), session.clone());
1711 let left = budget.saturating_duration_since(Instant::now());
1712 let inspected = if left.is_zero() { Err("Inspector.enable timed out\n".to_string()) }
1713 else { self.raw_call("Inspector.enable", json!({}), Some(&session), left) };
1714 if let Err(message) = inspected {
1715 return Err(self.guard_failure(target_id, &session, message));
1716 }
1717 let setup = [
1718 (
1719 "Fetch.enable",
1720 json!({ "patterns": [{ "urlPattern": "*", "resourceType": "Document", "requestStage": "Request" }] }),
1721 ),
1722 ("Network.enable", json!({})),
1723 ("Network.setBypassServiceWorker", json!({ "bypass": true })),
1724 ("Page.enable", json!({})),
1725 ("Runtime.enable", json!({})),
1726 ];
1727 for (method, params) in setup {
1728 if method == "Runtime.enable" {
1729 let _ = self.document_state_for(target_id, false, Some(""));
1730 let _ = self.document_state_for(target_id, true, Some(""));
1731 }
1732 let optional = matches!(method, "Page.enable" | "Runtime.enable");
1733 let left = budget.saturating_duration_since(Instant::now());
1734 let span = if optional { left.min(OPTIONAL_STEP) } else { left };
1735 let answer = if span.is_zero() {
1736 Err(format!("{method} timed out\n"))
1737 } else {
1738 self.raw_call(method, params, Some(&session), span)
1739 };
1740 if method == "Page.enable" && answer.is_err() {
1741 self.unobserved.insert(target_id.to_string());
1742 }
1743 let failed = match answer {
1744 Ok(_) => None,
1745 Err(message) if optional && message == format!("{method} timed out\n") => {
1746 if self
1747 .unguarded_optional
1748 .entry(target_id.to_string())
1749 .or_default()
1750 .insert(method.to_string())
1751 {
1752 self.notices
1753 .push(format!("slicc: guard: {method} timed out; continuing\n"));
1754 }
1755 None
1756 }
1757 Err(message) if optional && message.starts_with(&format!("{method}:")) => None,
1758 Err(message) => Some(message),
1759 };
1760 if let Some(message) = failed {
1761 return Err(self.guard_failure(target_id, &session, message));
1762 }
1763 }
1764 if self.documents.get(target_id).is_none_or(|doc| doc.probed) {
1765 let left = budget.saturating_duration_since(Instant::now());
1766 if !left.is_zero() {
1767 let epoch = self.frame_epoch;
1768 if let Ok(tree) = self.raw_exchange("Page.getFrameTree", json!({}), Some(&session), left.min(OPTIONAL_STEP), false) {
1769 if self.tree_current(&tree, epoch) {
1770 self.record_frames(&tree, &session);
1771 }
1772 if self.documents.get(target_id).is_none_or(|doc| doc.probed) {
1773 if let Some(frame) = tree.pointer("/frameTree/frame").filter(|frame| self.main_frame_current(frame, epoch)) {
1774 self.main_commit(&session, frame);
1775 }
1776 }
1777 }
1778 }
1779 }
1780 if let Err(message) = self.connected() {
1781 return Err(self.guard_failure(target_id, &session, message));
1782 }
1783 Ok(())
1784 }
1785
1786 fn guard_failure(&mut self, target: &str, session: &str, message: String) -> String {
1787 self.guards.remove(target);
1788 self.abandon(target);
1789 let _ = self.raw_call("Target.detachFromTarget", json!({ "sessionId": session }), None, Duration::from_secs(5));
1790 unguarded(message)
1791 }
1792
1793 fn abandon(&mut self, target_id: &str) {
1794 self.abandoned.insert(target_id.to_string());
1795 self.protect_target(target_id);
1796 }
1797
1798 fn adopt_guard_attach(&mut self, value: &Value) {
1799 if value.get("method").and_then(|v| v.as_str()) != Some("Target.attachedToTarget") {
1800 return;
1801 }
1802 let target = value
1803 .pointer("/params/targetInfo/targetId")
1804 .and_then(|v| v.as_str())
1805 .unwrap_or("");
1806 let Some(session) = value.pointer("/params/sessionId").and_then(|v| v.as_str()) else {
1807 return;
1808 };
1809 if self.guarding.contains(target) {
1810 self.guard_sessions.insert(session.to_string());
1811 } else if self.abandoned.contains(target) {
1812 self.guard_sessions.insert(session.to_string());
1813 let _ = self.send(
1814 "Target.detachFromTarget",
1815 json!({ "sessionId": session }),
1816 None,
1817 );
1818 }
1819 }
1820
1821 fn block(&mut self, session_id: &str) {
1822 let newly_blocked = self.blocked.insert(session_id.to_string());
1823 if self.webmcp_is_paused(session_id) {
1824 self.webmcp.deferred.insert(session_id.to_string());
1825 self.webmcp_revoke(session_id);
1826 return;
1827 }
1828 if !newly_blocked && !self.webmcp.deferred.remove(session_id) {
1829 return;
1830 }
1831 self.webmcp_revoke(session_id);
1832 if let Some(target) = self.session_targets.get(session_id) {
1833 self.protected_targets.insert(target.clone());
1834 }
1835 let queued: Vec<Paused> = self.paused.drain(..).collect();
1836 for paused in queued {
1837 if paused.session_id == session_id {
1838 self.answer_protected(&paused);
1839 } else {
1840 self.paused.push_back(paused);
1841 }
1842 }
1843 if !self.executing_sessions.contains(session_id) { self.detach_blocked(session_id); }
1844 }
1845
1846 fn detach_blocked(&mut self, session_id: &str) {
1847 let _ = self.send("Fetch.disable", json!({}), Some(session_id));
1848 let _ = self.send(
1849 "Target.detachFromTarget",
1850 json!({ "sessionId": session_id }),
1851 None,
1852 );
1853 }
1854
1855 fn session_target(&self, session_id: &str) -> Option<String> {
1856 self.session_targets.get(session_id).cloned().or_else(|| {
1857 self.guards
1858 .iter()
1859 .find(|(_, guard)| guard.as_str() == session_id)
1860 .map(|(target, _)| target.clone())
1861 })
1862 }
1863
1864 fn main_commit(&mut self, session: &str, frame: &Value) {
1865 let Some(target) = self.session_target(session) else { return };
1866 if self.guards.get(&target).is_some_and(|guard| guard != session) && !self.unobserved.contains(&target) {
1867 if self.guard.url(frame.get("url").and_then(Value::as_str).unwrap_or(""))
1868 || self.guard.url(frame.get("securityOrigin").and_then(Value::as_str).unwrap_or(""))
1869 {
1870 self.committed_protected(session);
1871 }
1872 return;
1873 }
1874 let Some(frame_id) = frame.get("id").and_then(Value::as_str) else { return };
1875 if let Some(loader) = frame.get("loaderId").and_then(Value::as_str).filter(|loader| !loader.is_empty()) {
1876 self.document_versions.insert((target.clone(), frame_id.to_string()), loader.to_string());
1877 }
1878 let url = format!("{}{}", frame.get("url").and_then(Value::as_str).unwrap_or(""), frame.get("urlFragment").and_then(Value::as_str).unwrap_or(""));
1879 let origin = frame.get("securityOrigin").and_then(Value::as_str).unwrap_or("").to_string();
1880 let unreachable_url = frame.get("unreachableUrl").and_then(Value::as_str).unwrap_or("").to_string();
1881 let reported = self.targets.get(&target).and_then(|info| info.get("url")).and_then(Value::as_str);
1882 let unknown = unreachable_url.is_empty() && reported.is_none_or(|reported| self.guard.url(reported));
1883 let previous = self.documents.get(&target).map(|doc| doc.previous).unwrap_or_else(|| {
1884 self.protected_targets.contains(&target) || unknown || self.guard.url(&unreachable_url)
1885 });
1886 let error = self.guard.error_page_url(&url) || !unreachable_url.is_empty();
1887 let protected = self.targets.get(&target).is_some_and(|info| self.guard.target_type(info.get("type").and_then(Value::as_str).unwrap_or(""))) || if error {
1888 previous || unknown || !self.guard.error_document(&url, &origin)
1889 } else {
1890 self.guard.target(&url, self.protected_targets.contains(&target), Opener::None) || self.guard.url(&origin)
1891 };
1892 self.documents.insert(target.clone(), MainDocument {
1893 frame_id: frame_id.to_string(), url, origin, unreachable_url, navigating: false, navigation_url: String::new(), previous: protected, probed: false,
1894 });
1895 if protected {
1896 self.protect_target(&target);
1897 } else if !self.abandoned.contains(&target) {
1898 self.protected_targets.remove(&target);
1899 }
1900 }
1901
1902 pub fn recovering(&self, target_id: &str) -> bool {
1903 self.documents.get(target_id).is_some_and(|doc| doc.probed && !doc.previous)
1904 }
1905
1906 fn document_state(&mut self, target: &str, content: bool) -> Option<DocumentState> {
1907 self.document_state_for(target, content, None)
1908 }
1909
1910 fn document_state_for(&mut self, target: &str, content: bool, scope: Option<&str>) -> Option<DocumentState> {
1911 let Some(guard) = self.guards.get(target).cloned() else {
1912 if self.protected_targets.contains(target) { return None }
1913 let result = self.raw_call("Target.getTargetInfo", json!({"targetId":target}), None, DOCUMENT_CHECK).ok()?;
1914 let info = result.get("targetInfo")?;
1915 let text = |key| info.get(key).and_then(Value::as_str).unwrap_or("");
1916 if text("targetId") != target || !matches!(text("type"), "worker" | "service_worker" | "shared_worker")
1917 || url::Url::parse(text("url")).is_err() || self.guard.target(text("url"), false, Opener::Unknown) { return None }
1918 let owner = text("openerId");
1919 if !self.guards.contains_key(owner) { return None }
1920 let mut state = self.document_state(owner, true)?;
1921 state.worker = Some((text("url").to_string(), owner.to_string()));
1922 return Some(state);
1923 };
1924 #[cfg(test)]
1925 self.runtime_prepare_probe("private-document-read", json!({}));
1926 let tree = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), DOCUMENT_CHECK).ok()?;
1927 if content { self.verify_document_order(&tree, Some(&guard)).ok()?; }
1928 #[cfg(test)]
1929 self.runtime_prepare_probe("private-document-stability", json!({"guard_stable":self.guards.get(target) == Some(&guard)}));
1930 if self.guards.get(target) != Some(&guard) { return None }
1931 let epoch = self.document_epochs.get(target).copied().unwrap_or(0);
1932 #[cfg(test)]
1933 self.runtime_prepare_probe("private-selected-placement", json!({"tree_present":tree.get("frameTree").is_some()}));
1934 let node = tree.get("frameTree")?;
1935 let scoped;
1936 let node = if let Some(id) = scope {
1937 scoped = Self::scoped_document_tree(node, id)?;
1938 &scoped
1939 } else { node };
1940 let frame = node.get("frame")?;
1941 let parent = frame.get("parentId").and_then(Value::as_str);
1942 let mut frames = BTreeMap::new();
1943 #[cfg(test)]
1944 self.runtime_prepare_probe("private-parent-placement", json!({"parent_present":parent.is_some()}));
1945 if let Some(parent) = parent {
1946 let info = self.raw_call("Target.getTargetInfo", json!({"targetId":target}), None, DOCUMENT_CHECK).ok()?;
1947 let info = info.get("targetInfo")?;
1948 if info.get("targetId").and_then(Value::as_str) != Some(target) || info.get("type").and_then(Value::as_str) != Some("iframe")
1949 || info.get("parentFrameId").and_then(Value::as_str) != Some(parent) { return None }
1950 let owner = self.guards.keys().find(|owner| owner.as_str() != target && self.targets.get(*owner).and_then(|info| info.get("type")).and_then(Value::as_str) == Some("page")
1951 && self.documents.get(*owner).is_some_and(|doc| doc.frame_id == parent)).cloned()?;
1952 let parent_guard = self.guards.get(&owner)?.clone();
1953 let parent_tree = self.raw_call("Page.getFrameTree", json!({}), Some(&parent_guard), DOCUMENT_CHECK).ok()?;
1954 let parent_frame = parent_tree.pointer("/frameTree/frame")?;
1955 if parent_frame.get("id").and_then(Value::as_str) != Some(parent) || parent_frame.get("parentId").is_some() { return None }
1956 frames = self.document_state_for(&owner, true, Some(parent))?.frames;
1957 }
1958 if !self.collect_document_frames(node, &mut frames, content, parent) {
1959 #[cfg(test)]
1960 self.runtime_prepare_frames(node, &frames);
1961 return None;
1962 }
1963 #[cfg(test)]
1964 self.runtime_prepare_probe("private-loader-provenance", json!({"loader_regressed":content && frames.iter().any(|(id, evidence)| evidence[0].is_empty() && self.document_versions.contains_key(&(target.to_string(), id.clone())))}));
1965 if content && frames.iter().any(|(id, evidence)| evidence[0].is_empty() && self.document_versions.contains_key(&(target.to_string(), id.clone()))) { return None }
1966 if content && self.guard.inheriting(frame.get("url").and_then(Value::as_str).unwrap_or(""))
1967 && frame.get("securityOrigin").and_then(Value::as_str) == Some("://")
1968 {
1969 let id = frame.get("id").and_then(Value::as_str)?;
1970 let loader = frame.get("loaderId").and_then(Value::as_str)?;
1971 #[cfg(test)]
1972 self.runtime_prepare_probe("private-blank-provenance", json!({"blank_url":frame.get("url").and_then(Value::as_str) == Some("about:blank"),"owned_blank":self.owns_blank(target, id, loader)}));
1973 if frame.get("url").and_then(Value::as_str) != Some("about:blank") || !self.owns_blank(target, id, loader) { return None }
1974 if self.navigated_blanks.contains_key(target) {
1975 let info = self.raw_call("Target.getTargetInfo", json!({"targetId":target}), None, DOCUMENT_CHECK).ok()?;
1976 let info = info.get("targetInfo")?;
1977 if info.get("targetId").and_then(Value::as_str) != Some(target) || info.get("type").and_then(Value::as_str) != Some("page")
1978 || info.get("url").and_then(Value::as_str) != Some("about:blank") || info.get("openerId").is_some() || info.get("openerFrameId").is_some()
1979 || frame.get("parentId").is_some() || self.guards.get(target) != Some(&guard) || !self.owns_blank(target, id, loader) { return None }
1980 }
1981 }
1982 self.main_commit(&guard, frame);
1983 #[cfg(test)]
1984 self.runtime_prepare_probe("private-document-origin", json!({"target_protected":self.protected_targets.contains(target)}));
1985 if self.protected_targets.contains(target) { return None }
1986 for (id, evidence) in &frames {
1987 self.frames.insert(id.clone(), vec![evidence[1].clone(), evidence[2].clone()]);
1988 self.note_parent(id, &evidence[4]);
1989 if content { self.frame_decisions.insert(id.clone(), false); }
1990 if !evidence[0].is_empty() {
1991 self.document_versions.insert((target.to_string(), id.clone()), evidence[0].clone());
1992 }
1993 }
1994 let scope = if parent.is_some() && scope == Some("") { frame.get("id").and_then(Value::as_str) } else { scope };
1995 let state = DocumentState { frames, worker:None, scope:scope.map(str::to_string), epoch, page_scope:None, runtime_scope_epoch:None };
1996 if content { self.cached_documents.insert(target.to_string(), state.clone()); }
1997 Some(state)
1998 }
1999
2000 fn scoped_document_tree(node: &Value, id: &str) -> Option<Value> {
2001 let frame = node.get("frame")?;
2002 if id.is_empty() || frame.get("id").and_then(Value::as_str) == Some(id) { return Some(json!({"frame":frame})) }
2003 node.get("childFrames")?.as_array()?.iter().find_map(|child| Self::scoped_document_tree(child, id))
2004 .map(|child| json!({"frame":frame,"childFrames":[child]}))
2005 }
2006
2007 fn collect_document_frames(&self, node: &Value, frames: &mut BTreeMap<String, Vec<String>>, content: bool, parent: Option<&str>) -> bool {
2008 let Some(frame) = node.get("frame") else { return false };
2009 let origin = self.page_scope_origin(frame);
2010 let text = |key| if key == "securityOrigin" { origin.unwrap_or_else(|| frame.get(key).and_then(Value::as_str).unwrap_or("")) } else { frame.get(key).and_then(Value::as_str).unwrap_or("") };
2011 let root = parent.is_none();
2012 if ["id", "url"].iter().any(|key| matches!(text(*key), "" | "null" | "://"))
2013 || frame.get("parentId").is_some_and(|id| id.as_str() != parent) { return false }
2014 let initial_blank = text("url") == "about:blank" && frame.get("loaderId").and_then(Value::as_str) == Some("")
2015 && (root || parent.and_then(|id| frames.get(id)).is_some_and(|evidence| !self.guard.url(&evidence[1]) && !self.guard.url(&evidence[2])));
2016 let parent_allowed = !root && parent.and_then(|id| frames.get(id)).is_some_and(|evidence| !self.guard.url(&evidence[1]) && !self.guard.url(&evidence[2]));
2017 let inherited = parent_allowed && self.guard.inheriting(text("url")) && !matches!(text("securityOrigin"), "" | "null" | "://") && self.frame_decisions.get(text("id")) != Some(&true);
2018 let allowed_error = !root && self.guard.error_document(text("url"), text("securityOrigin"))
2019 && !text("unreachableUrl").is_empty() && self.frame_decisions.get(text("id")) == Some(&false);
2020 let data = (root || parent_allowed) && self.guard.data_document(text("url"));
2021 let opaque = self.guard.opaque_document(text("url"), root) || !root && initial_blank || inherited || allowed_error || data;
2022 if content && (text("loaderId") == "null" || text("loaderId").is_empty() && !initial_blank
2023 || matches!(text("securityOrigin"), "" | "null") || text("securityOrigin") == "://" && !opaque
2024 || !root && !allowed_error && (self.guard.url(text("url")) || self.guard.url(text("securityOrigin")))
2025 || self.unverified_error(text("url"), text("unreachableUrl"))) { return false }
2026 let evidence = vec![text("loaderId").to_string(), format!("{}{}", text("url"), text("urlFragment")), text("securityOrigin").to_string(), text("unreachableUrl").to_string(), parent.unwrap_or("").to_string()];
2027 if frames.insert(text("id").to_string(), evidence).is_some() { return false }
2028 match node.get("childFrames") {
2029 None => true,
2030 Some(children) => children.as_array().is_some_and(|children| children.iter().all(|child| self.collect_document_frames(child, frames, content, Some(text("id"))))),
2031 }
2032 }
2033
2034 fn refresh(&mut self, target: &str) -> Result<bool, String> {
2035 let Some(doc) = self.documents.get(target) else { return Ok(true) };
2036 if self.document_paused(target) { return Ok(true) }
2037 if doc.navigating || doc.probed || !(self.guard.error_page_url(&doc.url) || !doc.unreachable_url.is_empty()) {
2038 return Ok(true);
2039 }
2040 let Some(guard) = self.guards.get(target).cloned() else { return Ok(true) };
2041 let frame = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), OPTIONAL_STEP).ok().and_then(|tree| tree.pointer("/frameTree/frame").cloned());
2042 self.connected()?;
2043 Ok(match frame {
2044 Some(frame) if self.guards.get(target) == Some(&guard) => {
2045 self.main_commit(&guard, &frame);
2046 true
2047 }
2048 _ => false,
2049 })
2050 }
2051
2052 fn probe_candidate(&self, info: &Value) -> bool {
2053 let text = |key: &str| info.get(key).and_then(Value::as_str).unwrap_or("");
2054 let target = text("targetId");
2055 !target.is_empty() && text("type") == "page" && text("openerId").is_empty() && (self.guard.url(text("url")) || self.guard.error_page_url(text("url"))) && !self.guard.unparseable(text("url"))
2056 && !self.documents.contains_key(target) && !self.abandoned.contains(target) && !self.guards.contains_key(target)
2057 }
2058
2059 fn probe_error(&mut self, info: &Value) -> Result<(), String> {
2060 let text = |value: &Value, key: &str| value.get(key).and_then(Value::as_str).unwrap_or("").to_string();
2061 let target = text(info, "targetId");
2062 let url = text(info, "url");
2063 if !self.probe_candidate(info) || self.protected_targets.contains(&target) {
2064 return Ok(());
2065 }
2066 self.probing.insert(target.clone());
2067 self.guarding.insert(target.clone());
2068 let attached = self.raw_call("Target.attachToTarget", json!({ "targetId": target, "flatten": true }), None, OPTIONAL_STEP);
2069 self.guarding.remove(&target);
2070 let Some(session) = attached.ok().map(|value| text(&value, "sessionId")).filter(|session| !session.is_empty()) else {
2071 self.probing.remove(&target);
2072 return self.connected();
2073 };
2074 self.guard_sessions.insert(session.clone());
2075 let frame = self.raw_call("Page.getFrameTree", json!({}), Some(&session), OPTIONAL_STEP).ok()
2076 .and_then(|tree| tree.pointer("/frameTree/frame").cloned())
2077 .filter(|frame| self.guard.error_document(&text(frame, "url"), &text(frame, "securityOrigin"))
2078 && (self.reported_unreachable(&text(frame, "unreachableUrl"), &url) || self.guard.error_page_url(&url) && text(frame, "url") == url));
2079 let history = match frame {
2080 Some(_) => self.raw_call("Page.getNavigationHistory", json!({}), Some(&session), OPTIONAL_STEP).ok(),
2081 None => None,
2082 };
2083 let _ = self.raw_call("Target.detachFromTarget", json!({ "sessionId": session }), None, OPTIONAL_STEP);
2084 self.probing.remove(&target);
2085 self.connected()?;
2086 let (Some(frame), Some(history)) = (frame, history) else { return Ok(()) };
2087 let unreachable_url = text(&frame, "unreachableUrl");
2088 if self.documents.contains_key(&target) || !self.ordinary_history(&history, &unreachable_url) {
2089 return Ok(());
2090 }
2091 self.documents.insert(target, MainDocument {
2092 frame_id: text(&frame, "id"), url: text(&frame, "url"), origin: text(&frame, "securityOrigin"), unreachable_url,
2093 navigating: false, navigation_url: String::new(), previous: false, probed: true,
2094 });
2095 Ok(())
2096 }
2097
2098 fn reported_unreachable(&self, unreachable: &str, reported: &str) -> bool {
2099 !unreachable.is_empty()
2100 && (reported == unreachable
2101 || url::Url::parse(unreachable).is_ok_and(|parsed| {
2102 let origin = parsed.origin().ascii_serialization();
2103 reported == origin || reported.strip_suffix('/') == Some(origin.as_str())
2104 }))
2105 }
2106
2107 fn ordinary_history(&self, history: &Value, url: &str) -> bool {
2108 let entries = history.get("entries").and_then(Value::as_array).cloned().unwrap_or_default();
2109 let current = history.get("currentIndex").and_then(Value::as_u64).and_then(|index| usize::try_from(index).ok()).unwrap_or(0);
2110 current >= 1
2111 && entries.get(current).is_some_and(|entry| {
2112 self.reported_unreachable(url, entry.get("url").and_then(Value::as_str).unwrap_or("")) && entry.get("transitionType").and_then(Value::as_str) != Some("reload")
2113 })
2114 && entries.iter().enumerate().all(|(index, entry)| {
2115 index == current || ["url", "userTypedURL"].iter().all(|key| !self.guard.url(entry.get(*key).and_then(Value::as_str).unwrap_or("")))
2116 })
2117 }
2118
2119 fn sealed(&self, doc: &MainDocument) -> bool {
2120 (self.guard.error_page_url(&doc.url) || !doc.unreachable_url.is_empty())
2121 && (doc.unreachable_url.is_empty() || self.guard.url(&doc.unreachable_url))
2122 }
2123
2124 fn masked_error(&self, doc: &MainDocument, reported: &str) -> bool {
2125 doc.unreachable_url.is_empty()
2126 && self.guard.error_document(&doc.url, &doc.origin)
2127 && !self.guard.url(reported)
2128 }
2129
2130 fn committed_protected(&mut self, session_id: &str) {
2131 match self.session_target(session_id) {
2132 Some(target) => self.protect_target(&target),
2133 None => self.block(session_id),
2134 }
2135 if !self.guard_sessions.contains(session_id) {
2136 self.block(session_id);
2137 }
2138 }
2139
2140 fn frame_tree_refused(&mut self, session_id: Option<&str>) -> Result<bool, String> {
2141 let Some(session) = session_id else {
2142 return Ok(true);
2143 };
2144 let tree = match self.raw_call("Page.getFrameTree", json!({}), Some(session), Duration::from_secs(10)) {
2145 Ok(tree) => tree,
2146 Err(message) if transport_error(&message) => return Err(message),
2147 Err(_) => return Ok(true),
2148 };
2149 self.verify_page_scope(Some(session))?;
2150 Ok(tree.get("frameTree").is_none_or(|node| self.tree_node_refused(node)))
2151 }
2152
2153 fn tree_node_refused(&self, node: &Value) -> bool {
2154 let frame = node.get("frame").unwrap_or(&Value::Null);
2155 let unknown = ["id", "url", "securityOrigin"].iter().any(|key| {
2156 let text = if *key == "securityOrigin" { self.page_scope_origin(frame).or_else(|| frame.get(*key).and_then(Value::as_str)) } else { frame.get(*key).and_then(Value::as_str) };
2157 text.is_none_or(|text| !frame_text_known(text))
2158 });
2159 unknown || self.frame_protected(frame.get("id").and_then(Value::as_str).unwrap_or(""))
2160 || node.get("childFrames").is_some_and(|children| children.as_array().is_none_or(|children| children.iter().any(|child| self.tree_node_refused(child))))
2161 }
2162
2163 fn frame_refused(&mut self, frame_id: &str, session_id: Option<&str>) -> Result<bool, String> {
2164 if self.frames.get(frame_id).is_some_and(|known| known.first().is_some_and(|url| self.guard.inheriting(url)) && !self.frame_verified(frame_id)) {
2165 self.verify_page_scope(session_id)?;
2166 }
2167 let known = self.frames.contains_key(frame_id);
2168 if self.frame_protected(frame_id) || known && !self.frame_verified(frame_id) {
2169 return Ok(true);
2170 }
2171 let other_target = session_id.and_then(|session| self.session_target(session)).is_some_and(|target| target != frame_id);
2172 let target_allowed = match self.judge_target(frame_id, false, !other_target) {
2173 Ok(true) => return Ok(true),
2174 Ok(false) => self.targets.get(frame_id).is_some_and(|info| info.get("targetId").and_then(Value::as_str) == Some(frame_id)
2175 && info.get("url").and_then(Value::as_str).is_some_and(frame_text_known)),
2176 Err(message) if transport_error(&message) => return Err(message),
2177 Err(_) => false,
2178 };
2179 let Some(session) = session_id else {
2180 return Ok(!target_allowed);
2181 };
2182 match self.raw_call("Page.getFrameTree", json!({}), Some(session), Duration::from_secs(10)) {
2183 Ok(_) => Ok(!self.frame_sessions.get(session).is_some_and(|frames| frames.contains(frame_id)) || !self.frame_verified(frame_id)),
2184 Err(message) if transport_error(&message) => Err(message),
2185 Err(message) if message.contains("slicc: refused:") => Ok(true),
2186 Err(_) => Ok(known || !target_allowed),
2187 }
2188 }
2189
2190 fn frame_verified(&self, frame_id: &str) -> bool {
2191 self.frame_chain_verified(frame_id, 0)
2192 }
2193
2194 fn frame_chain_verified(&self, frame_id: &str, depth: usize) -> bool {
2195 let Some(known) = self.frames.get(frame_id) else { return false };
2196 if depth > self.frames.len() || !frame_text_known(frame_id) || self.protected_targets.contains(frame_id)
2197 || known.len() < 2 || known.iter().any(|text| self.guard.url(text)) { return false }
2198 if known.iter().all(|text| frame_text_known(text)) { return true }
2199 frame_text_known(&known[0]) && self.guard.data_document(&known[0]) && known[1..].iter().all(|text| text == "://" || frame_text_known(text))
2200 && self.frame_parents.get(frame_id).is_none_or(|parent| self.frame_chain_verified(parent, depth + 1))
2201 }
2202
2203 fn tree_current(&self, tree: &Value, epoch: u64) -> bool {
2204 epoch == self.frame_epoch || self.tree_matches_updates(tree, epoch)
2205 }
2206
2207 fn main_frame_current(&self, frame: &Value, epoch: u64) -> bool {
2208 frame.get("id").and_then(Value::as_str).is_some_and(|id| self.frame_versions.get(id).is_none_or(|version| *version <= epoch))
2209 }
2210
2211 fn tree_matches_updates(&self, tree: &Value, epoch: u64) -> bool {
2212 let mut frames = BTreeMap::new();
2213 if let Some(node) = tree.get("frameTree") {
2214 Self::collect_frames(node, &mut frames);
2215 }
2216 let updates = self.frame_versions.iter().filter(|(_, version)| **version > epoch).collect::<Vec<_>>();
2217 !updates.is_empty() && updates.iter().all(|(id, _)| self.frames.get(*id).is_some_and(|known| {
2218 frames.get(*id).is_some_and(|fresh| known.get(..2) == fresh.get(..2)
2219 && known.iter().skip(2).all(|origin| Some(origin) == fresh.get(1)))
2220 }))
2221 }
2222
2223 fn record_frames(&mut self, tree: &Value, session: &str) {
2224 let mut frames = BTreeMap::new();
2225 if let Some(node) = tree.get("frameTree") {
2226 Self::collect_frames(node, &mut frames);
2227 let mut queue = vec![(node, None::<&str>)];
2228 while let Some((node, parent)) = queue.pop() {
2229 if let Some(frame) = node.get("frame") {
2230 if let Some(id) = frame.get("id").and_then(Value::as_str) {
2231 if let Some(known) = frames.get_mut(id) {
2232 if let Some(origin) = self.page_scope_origin(frame) { known[1] = origin.to_string(); }
2233 if let Some(parent) = parent { if frame.get("parentId").is_none() { known[2] = parent.to_string(); } }
2234 }
2235 for child in node.get("childFrames").and_then(Value::as_array).into_iter().flatten() { queue.push((child, Some(id))); }
2236 }
2237 }
2238 }
2239 }
2240 let ids = frames.keys().cloned().collect::<BTreeSet<_>>();
2241 for id in self.frame_sessions.insert(session.to_string(), ids.clone()).unwrap_or_default().difference(&ids) {
2242 self.frames.remove(id);
2243 self.frame_parents.remove(id);
2244 }
2245 for (id, mut evidence) in frames {
2246 let parent = evidence.pop().unwrap_or_default();
2247 self.note_parent(&id, &parent);
2248 self.frames.insert(id, evidence);
2249 }
2250 }
2251
2252 fn note_parent(&mut self, frame_id: &str, parent: &str) {
2253 if parent.is_empty() { self.frame_parents.remove(frame_id); } else { self.frame_parents.insert(frame_id.to_string(), parent.to_string()); }
2254 }
2255
2256 fn collect_frames(node: &Value, frames: &mut BTreeMap<String, Vec<String>>) {
2257 let frame = node.get("frame").unwrap_or(&Value::Null);
2258 let text = |key: &str| frame.get(key).and_then(Value::as_str).unwrap_or("");
2259 if !text("id").is_empty() {
2260 let url = format!("{}{}", text("url"), text("urlFragment"));
2261 frames.insert(text("id").to_string(), vec![url, text("securityOrigin").to_string(), text("parentId").to_string()]);
2262 }
2263 for child in node.get("childFrames").and_then(Value::as_array).into_iter().flatten() {
2264 Self::collect_frames(child, frames);
2265 }
2266 }
2267
2268 fn frame_protected(&self, frame_id: &str) -> bool {
2269 if frame_id.is_empty() {
2270 return false;
2271 }
2272 self.protected_targets.contains(frame_id)
2273 || self
2274 .frames
2275 .get(frame_id)
2276 .is_some_and(|known| known.iter().any(|url| self.guard.url(url)))
2277 }
2278
2279 fn initiated_by_protected(&self, paused: &Paused) -> bool {
2280 if self.frame_protected(&paused.frame_id) {
2281 return true;
2282 }
2283 if self
2284 .session_target(&paused.session_id)
2285 .is_some_and(|target| self.protected_targets.contains(&target))
2286 {
2287 return true;
2288 }
2289 ["referer", "origin"].iter().any(|name| {
2290 paused
2291 .header(name)
2292 .is_some_and(|value| value != "null" && self.guard.url(value))
2293 })
2294 }
2295
2296 fn protect_target(&mut self, target_id: &str) {
2297 self.protected_targets.insert(target_id.to_string());
2298 if let Some(doc) = self.documents.get_mut(target_id) {
2299 doc.previous = true;
2300 }
2301 let sessions: Vec<String> = self
2302 .session_targets
2303 .iter()
2304 .filter(|(_, have)| have.as_str() == target_id)
2305 .map(|(session, _)| session.clone())
2306 .collect();
2307 for session in sessions {
2308 self.block(&session);
2309 }
2310 }
2311
2312 fn answer_protected(&mut self, paused: &Paused) {
2313 self.fail_seen_network(&json!({"sessionId":paused.session_id,"params":{"networkId":paused.network_id,"requestId":paused.request_id}}));
2314 self.settle(&paused.session_id, &paused.request_id);
2315 if paused.resource_type == "Document" {
2316 let _ = self.send(
2317 "Fetch.failRequest",
2318 json!({ "requestId": paused.request_id, "errorReason": "BlockedByClient" }),
2319 Some(&paused.session_id),
2320 );
2321 } else {
2322 self.continue_plain(&paused.session_id, &paused.request_id);
2323 }
2324 }
2325
2326 fn continue_plain(&mut self, session_id: &str, request_id: &str) {
2327 self.settle(session_id, request_id);
2328 let _ = self.send(
2329 "Fetch.continueRequest",
2330 json!({ "requestId": request_id }),
2331 Some(session_id),
2332 );
2333 }
2334
2335 fn content_refused(&self, method: &str, params: &Value, session: &str) -> bool {
2336 if shield::body_method(method) && shield::request_key(params).is_some_and(|id| self.protected_body_requests.contains(&id)) { return true }
2337 if shield::body_method(method) && shield::request_key(params).is_some_and(|id| self.request_state(&id) == Some(true)) {
2338 return true;
2339 }
2340 if matches!(method, "Page.getResourceContent" | "Page.searchInResource") {
2341 return self.resource_protected(session, params.get("url").and_then(Value::as_str).unwrap_or(""));
2342 }
2343 if matches!(method, "CSS.getStyleSheetText" | "Debugger.getScriptSource" | "Debugger.getWasmBytecode" | "Debugger.searchInContent" | "Debugger.disassembleWasmModule") {
2344 let id = params.get("styleSheetId").or_else(|| params.get("scriptId")).and_then(Value::as_str).unwrap_or("");
2345 return self.source_resources.get(&(session.to_string(), id.to_string())).is_none_or(|url| self.resource_protected(session, url));
2346 }
2347 false
2348 }
2349
2350 fn protect_resource(&mut self, session: &str, url: &str) {
2351 let Some(url) = self.guard.resource_key(url) else { return };
2352 self.protected_resources.insert((session.to_string(), url.clone()));
2353 if let Some(target) = self.session_target(session) {
2354 self.protected_target_resources.insert((target, url));
2355 }
2356 }
2357
2358 fn redirector_protected(&self, url: &str) -> bool {
2359 let Some(key) = self.guard.resource_key(url) else { return true };
2360 self.guard.url(url) || self.protected_resources.iter().any(|(_, resource)| resource == &key)
2361 }
2362
2363 fn resource_protected(&self, session: &str, url: &str) -> bool {
2364 let Some(key) = self.guard.resource_key(url) else { return true };
2365 self.guard.url(url) || self.protected_resources.contains(&(session.to_string(), key.clone()))
2366 || self.session_target(session).is_some_and(|target| self.protected_target_resources.contains(&(target, key)))
2367 }
2368
2369 fn protect_body_request(&mut self, id: &str) {
2370 let mut ids = self.request_aliases.get(id).cloned().unwrap_or_default();
2371 ids.insert(id.to_string());
2372 for id in ids {
2373 self.protected_body_requests.insert(id.clone());
2374 for (peer, url) in self.request_resources.get(&id).cloned().unwrap_or_default() { self.protect_resource(&peer, &url); }
2375 }
2376 }
2377
2378 fn mark_redirect(&mut self, id: &str) {
2379 let mut ids = self.request_aliases.get(id).cloned().unwrap_or_default();
2380 ids.insert(id.to_string());
2381 for id in ids {
2382 self.redirects.insert(id.clone());
2383 for (session, url) in self.request_resources.get(&id).cloned().unwrap_or_default() {
2384 self.protect_resource(&session, &url);
2385 }
2386 }
2387 }
2388
2389 fn watch(&mut self, value: &mut Value) {
2390 let method = value
2391 .get("method")
2392 .and_then(|v| v.as_str())
2393 .unwrap_or("")
2394 .to_string();
2395 let session = value
2396 .get("sessionId")
2397 .and_then(|v| v.as_str())
2398 .map(str::to_string);
2399 let params = value.get("params").cloned().unwrap_or(Value::Null);
2400 self.observe_document_order(&method, ¶ms, session.as_deref());
2401 self.page_scope_changed(&method, ¶ms, session.as_deref());
2402 if let Some(target) = session.as_deref().and_then(|session| self.session_target(session)) {
2403 if method == "Page.frameNavigated" {
2404 let id = params.pointer("/frame/id").and_then(Value::as_str).unwrap_or("").to_string();
2405 let loader = params.pointer("/frame/loaderId").and_then(Value::as_str).unwrap_or("").to_string();
2406 let key = (target.clone(), id);
2407 if loader.is_empty() || self.document_versions.get(&key) != Some(&loader) {
2408 if !loader.is_empty() { self.document_versions.insert(key, loader); }
2409 *self.document_epochs.entry(target).or_default() += 1;
2410 }
2411 } else if method == "Page.frameDetached" {
2412 let id = params.get("frameId").and_then(Value::as_str).unwrap_or("").to_string();
2413 if params.get("reason").and_then(Value::as_str) == Some("remove") { self.note_frame_removal(&target, &id); }
2414 self.forget_frame_order(&target, session.as_deref().unwrap_or(""), &id, matches!(params.get("reason").and_then(Value::as_str), Some("remove" | "swap")));
2415 if self.document_versions.remove(&(target.clone(), id)).is_some() {
2416 *self.document_epochs.entry(target).or_default() += 1;
2417 }
2418 } else if method == "Page.frameAttached" {
2419 if let Some(id) = params.get("frameId").and_then(Value::as_str) { self.removed_frames.remove(&(target, id.to_string())); }
2420 }
2421 }
2422 if matches!(method.as_str(), "Runtime.executionContextDestroyed" | "Runtime.executionContextsCleared") {
2423 if let Some(session) = session.as_ref() {
2424 let target = self.session_target(session);
2425 let id = params.get("executionContextId").and_then(Value::as_i64);
2426 let unique = params.get("executionContextUniqueId").and_then(Value::as_str);
2427 let cleared = method == "Runtime.executionContextsCleared";
2428 let owners: BTreeSet<String> = self.context_proofs.keys().map(|(owner, _)| owner.clone()).chain(self.created_contexts.iter().map(|(owner, _)| owner.clone())).filter(|owner| owner == session || target.is_some() && self.session_target(owner) == target).collect();
2429 self.context_proofs.retain(|(owner, key), proof| !owners.contains(owner) || !(cleared || id == Some(proof.id) || unique == Some(key.as_str())));
2430 self.created_contexts.retain(|(owner, key)| !owners.contains(owner) || !(cleared || id == Some(*key)));
2431 }
2432 }
2433 if method == "Runtime.executionContextCreated" {
2434 if let (Some(session), Some(unique), Some(id)) = (session.as_ref(), params.pointer("/context/uniqueId").and_then(Value::as_str), params.pointer("/context/id").and_then(Value::as_i64)) {
2435 let frame = params.pointer("/context/auxData/frameId").and_then(Value::as_str).unwrap_or("").to_string();
2436 let loader = self.session_target(session).and_then(|target| self.document_versions.get(&(target, frame.clone())).cloned());
2437 self.context_proofs.insert((session.clone(), unique.to_string()), ContextProof {
2438 id, frame, loader, origin:params.pointer("/context/origin").and_then(Value::as_str).unwrap_or("").to_string(),
2439 name:params.pointer("/context/name").and_then(Value::as_str).unwrap_or("").to_string(),
2440 default:params.pointer("/context/auxData/isDefault").and_then(Value::as_bool) == Some(true),
2441 });
2442 }
2443 if let Some(session) = session.as_ref() {
2444 let frame = params.pointer("/context/auxData/frameId").and_then(Value::as_str);
2445 for (label, key) in [("id", "id"), ("unique", "uniqueId"), ("name", "name")] {
2446 if let Some(id) = params.pointer("/context").and_then(|context| context.get(key)) {
2447 let id = id.as_str().map(str::to_string).unwrap_or_else(|| id.to_string());
2448 let key = (session.clone(), format!("{label}:{id}"));
2449 if let Some(frame) = frame { self.context_frames.insert(key, frame.to_string()); }
2450 else { self.context_frames.remove(&key); }
2451 }
2452 }
2453 }
2454 }
2455 if let Some(id) = params.get("requestId").and_then(Value::as_str) {
2456 if self.redirect_page_protected(value) {
2457 self.protected_request_pages.insert((session.clone().unwrap_or_default(), id.to_string()));
2458 }
2459 }
2460 if let Some(id) = params.get("requestId").and_then(|v| v.as_str()) {
2461 if let Some(url) = shield::request_url(¶ms) {
2462 let resource = (session.clone().unwrap_or_default(), url.clone());
2463 self.request_resources.entry(id.to_string()).or_default().insert(resource.clone());
2464 if self.redirects.contains(id) || self.protected_body_requests.contains(id) || self.guard.url(&url) {
2465 self.protect_resource(&resource.0, &resource.1);
2466 }
2467 let initiator = ["documentURL"]
2468 .iter()
2469 .filter_map(|key| params.get(*key).and_then(|v| v.as_str()))
2470 .chain(params.pointer("/initiator/url").and_then(|v| v.as_str()))
2471 .any(|text| self.guard.url(text))
2472 || params
2473 .get("frameId")
2474 .and_then(|v| v.as_str())
2475 .is_some_and(|frame| self.frame_protected(frame));
2476 let protected = initiator
2477 || self.guard.url(&url)
2478 || self.guard.url(&shield::web_socket_as_http(&url));
2479 let entry = self.requests.entry(id.to_string()).or_insert(false);
2480 *entry = *entry || protected;
2481 }
2482 }
2483 if let Some(id) = params.get("requestId").and_then(Value::as_str) {
2484 let authority = if method == "Network.requestWillBeSentExtraInfo" { params.clone() } else { params.get("request").cloned().unwrap_or(Value::Null) };
2485 if shield::scheme_less_protected(&self.guard, &authority) {
2486 self.requests.insert(id.to_string(), true);
2487 for (peer, url) in self.request_resources.get(id).cloned().unwrap_or_default() { self.protect_resource(&peer, &url); }
2488 }
2489 if self.protected_carrier(value, false) { self.protect_body_request(id); }
2490 if self.actual_redirect_carrier(value) { self.mark_redirect(id); }
2491 }
2492 if let Some((id, url)) = match method.as_str() {
2493 "Debugger.scriptParsed" | "Debugger.scriptFailedToParse" => params.get("scriptId").and_then(Value::as_str).zip(params.get("url").and_then(Value::as_str)),
2494 "CSS.styleSheetAdded" => params.pointer("/header/styleSheetId").and_then(Value::as_str).zip(params.pointer("/header/sourceURL").and_then(Value::as_str)),
2495 _ => None,
2496 } {
2497 self.source_resources.insert((session.clone().unwrap_or_default(), id.to_string()), url.to_string());
2498 }
2499 if matches!(method.as_str(), "Page.frameNavigated" | "Page.frameAttached" | "Page.frameDetached" | "Page.navigatedWithinDocument") {
2500 self.frame_epoch += 1;
2501 }
2502 match method.as_str() {
2503 "Page.frameAttached" | "Page.frameDetached" => {
2504 if method == "Page.frameAttached" && session.as_ref().is_some_and(|session| self.guard_sessions.contains(session)) {
2505 if let (Some(id), Some(parent)) = (params.get("frameId").and_then(Value::as_str), params.get("parentFrameId").and_then(Value::as_str)) {
2506 if let Some(protected) = self.frame_decisions.get(parent).copied() {
2507 self.frame_decisions.entry(id.to_string()).or_insert(protected);
2508 }
2509 }
2510 }
2511 if let Some(id) = params.get("frameId").and_then(Value::as_str) {
2512 self.frames.remove(id);
2513 self.frame_parents.remove(id);
2514 self.frame_versions.insert(id.to_string(), self.frame_epoch);
2515 for frames in self.frame_sessions.values_mut() {
2516 frames.remove(id);
2517 }
2518 }
2519 }
2520 "Page.navigatedWithinDocument" => {
2521 if let Some(id) = params.get("frameId").and_then(Value::as_str) {
2522 self.frame_versions.insert(id.to_string(), self.frame_epoch);
2523 if let Some(known) = self.frames.get_mut(id).and_then(|known| known.first_mut()) {
2524 *known = params.get("url").and_then(Value::as_str).unwrap_or("").to_string();
2525 }
2526 }
2527 }
2528 "Page.frameNavigated" => {
2529 let Some(session) = session else { return };
2530 let frame = params.get("frame").cloned().unwrap_or(Value::Null);
2531 let url = format!(
2532 "{}{}",
2533 frame.get("url").and_then(|v| v.as_str()).unwrap_or(""),
2534 frame.get("urlFragment").and_then(|v| v.as_str()).unwrap_or("")
2535 );
2536 let origin = frame
2537 .get("securityOrigin")
2538 .and_then(|v| v.as_str())
2539 .unwrap_or("")
2540 .to_string();
2541 if let Some(id) = frame.get("id").and_then(|v| v.as_str()) {
2542 self.frames.insert(id.to_string(), vec![url.clone(), origin.clone()]);
2543 self.note_parent(id, frame.get("parentId").and_then(Value::as_str).unwrap_or(""));
2544 if self.guard.url(&url) || self.guard.url(&origin) {
2545 self.frame_decisions.insert(id.to_string(), true);
2546 } else if !self.guard.error_page_url(&url) && !matches!(origin.as_str(), "" | "null" | "://") {
2547 self.frame_decisions.insert(id.to_string(), false);
2548 }
2549 self.frame_versions.insert(id.to_string(), self.frame_epoch);
2550 self.frame_sessions.entry(session.clone()).or_default().insert(id.to_string());
2551 }
2552 if frame.get("parentId").is_none() {
2553 self.main_commit(&session, &frame);
2554 if self.session_target(&session).is_none() && (self.guard.url(&url) || self.guard.url(&origin)) {
2555 self.committed_protected(&session);
2556 }
2557 }
2558 }
2559 "Page.frameStartedNavigating" => {
2560 if matches!(params.get("navigationType").and_then(Value::as_str), Some("sameDocument" | "historySameDocument")) {
2561 return;
2562 }
2563 if let Some(target) = session.as_deref().and_then(|session| self.session_target(session)) {
2564 if self.guards.get(&target).is_some_and(|guard| Some(guard) != session.as_ref()) && !self.unobserved.contains(&target) {
2565 return;
2566 }
2567 if let Some(doc) = self.documents.get_mut(&target) {
2568 if params.get("frameId").and_then(Value::as_str) == Some(&doc.frame_id) {
2569 doc.navigating = true;
2570 doc.navigation_url = params.get("url").and_then(Value::as_str).unwrap_or("").to_string();
2571 }
2572 }
2573 }
2574 }
2575 "Runtime.executionContextCreated" => {
2576 let Some(session) = session else { return };
2577 let origin = params
2578 .pointer("/context/origin")
2579 .and_then(|v| v.as_str())
2580 .unwrap_or("");
2581 let default = params
2582 .pointer("/context/auxData/isDefault")
2583 .and_then(|v| v.as_bool())
2584 .unwrap_or(true);
2585 if let Some(frame) = params.pointer("/context/auxData/frameId").and_then(|v| v.as_str()) {
2586 if default {
2587 self.frames
2588 .entry(frame.to_string())
2589 .or_default()
2590 .push(origin.to_string());
2591 }
2592 }
2593 if default && self.guard.url(origin) {
2594 self.committed_protected(&session);
2595 }
2596 }
2597 "Target.targetCreated" | "Target.targetInfoChanged" => {
2598 let info = params.get("targetInfo").cloned().unwrap_or(Value::Null);
2599 let target = info
2600 .get("targetId")
2601 .and_then(|v| v.as_str())
2602 .unwrap_or("")
2603 .to_string();
2604 let url = info.get("url").and_then(|v| v.as_str()).unwrap_or("");
2605 if !target.is_empty() {
2606 self.targets.insert(target.clone(), info.clone());
2607 }
2608 if self.info_protected(&info, &[]) && !self.documents.contains_key(&target) && !self.probing.contains(&target) && !self.probe_candidate(&info) {
2609 self.protect_target(&target);
2610 }
2611 if self.info_protected(&info, &[]) || self.discovery_redacted(&info) {
2612 value["params"]["targetInfo"] = shield::redacted(&info);
2613 } else if !self.documents.contains_key(&target) && !self.guard.target(url, false, Opener::Unknown)
2614 && !self.abandoned.contains(&target)
2615 {
2616 self.protected_targets.remove(&target);
2617 }
2618 }
2619 "Target.attachedToTarget" => {
2620 if let Some(info) = params.get("targetInfo") {
2621 value["params"]["targetInfo"] = if info.get("targetId").and_then(Value::as_str).is_some_and(|target| self.probing.contains(target)) {
2622 shield::redacted(info)
2623 } else {
2624 self.redact(info, &[])
2625 };
2626 }
2627 }
2628 "Target.targetDestroyed" => {
2629 if let Some(target) = params.get("targetId").and_then(|v| v.as_str()) {
2630 self.guards.remove(target);
2631 self.targets.remove(target);
2632 self.created_blanks.remove(target);
2633 self.navigated_blanks.remove(target);
2634 self.documents.remove(target);
2635 self.unchecked_documents.retain(|(owner, _, _), _| owner != target);
2636 self.removed_frames.retain(|(owner, _), _| owner != target);
2637 self.unobserved.remove(target);
2638 }
2639 }
2640 "Target.detachedFromTarget" => {
2641 if let Some(child) = params.get("sessionId").and_then(|v| v.as_str()) {
2642 self.forget_session(child);
2643 }
2644 }
2645 _ => {}
2646 }
2647 }
2648
2649 fn hand(&mut self, session_id: &str, request_id: &str) {
2650 self.handed
2651 .insert((session_id.to_string(), request_id.to_string()));
2652 }
2653
2654 fn settle(&mut self, session_id: &str, request_id: &str) {
2655 self.paused_documents.remove(&(session_id.to_string(), request_id.to_string()));
2656 self.handed
2657 .remove(&(session_id.to_string(), request_id.to_string()));
2658 }
2659
2660 fn challenge_protected(&self, value: &Value) -> bool {
2661 let session = value.get("sessionId").and_then(|v| v.as_str()).unwrap_or("");
2662 let params = value.get("params").cloned().unwrap_or(Value::Null);
2663 let id = params.get("requestId").and_then(|v| v.as_str()).unwrap_or("");
2664 let text = |pointer: &str| params.pointer(pointer).and_then(|v| v.as_str()).unwrap_or("");
2665 self.request_state(id) == Some(true)
2666 || self.blocked.contains(session)
2667 || self.guard_sessions.contains(session)
2668 || self.frame_protected(text("/frameId"))
2669 || self
2670 .session_target(session)
2671 .is_some_and(|target| self.protected_targets.contains(&target))
2672 || ["/request/url", "/authChallenge/origin"]
2673 .iter()
2674 .any(|pointer| self.guard.url(text(pointer)))
2675 }
2676
2677 fn hidden(&self, value: &Value) -> bool {
2678 let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("");
2679 if method == "Target.receivedMessageFromTarget" {
2680 return true;
2681 }
2682 if let Some(session) = value.get("sessionId").and_then(|v| v.as_str()) {
2683 if self.blocked.contains(session) || self.guard_sessions.contains(session) {
2684 return true;
2685 }
2686 }
2687 if method.starts_with("Target.") {
2688 let info = value.pointer("/params/targetInfo");
2689 let reported = info.and_then(|info| info.get("targetId")).and_then(Value::as_str)
2690 .and_then(|target| self.targets.get(target)).or(info);
2691 if let Some(url) = reported.and_then(|info| info.get("url")).and_then(Value::as_str) {
2692 if self.guard.unparseable(url) && self.guard.url(url) {
2693 return true;
2694 }
2695 }
2696 if let Some(child) = value.pointer("/params/sessionId").and_then(|v| v.as_str()) {
2697 if self.guard_sessions.contains(child) || self.blocked.contains(child)
2698 && !(method == "Target.detachedFromTarget" && self.detached_ordinary.contains(child)) {
2699 return true;
2700 }
2701 }
2702 return false;
2703 }
2704 let params = value.get("params").cloned().unwrap_or(Value::Null);
2705 if let Some(id) = params.get("requestId").and_then(|v| v.as_str()) {
2706 if self.requests.get(id) == Some(&true) || self.redirect_page_protected(value) {
2707 return true;
2708 }
2709 }
2710 shield::loaded_urls_protected(&self.guard, ¶ms)
2711 }
2712
2713 fn fail_seen_network(&mut self, value: &Value) {
2714 let Some(id) = value.pointer("/params/networkId").and_then(Value::as_str).or_else(|| value.pointer("/params/requestId").and_then(Value::as_str)) else { return };
2715 let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
2716 if self.guard_sessions.contains(session) && self.request_state(id) != Some(true) { return }
2717 let target = self.session_target(session);
2718 let keys: Vec<_> = self.seen_network.keys().filter(|(peer, request)| request == id && (peer == session || target.is_some() && self.session_target(peer) == target)).cloned().collect();
2719 for key in keys {
2720 let kind = self.seen_network.remove(&key).unwrap();
2721 if self.terminal_network.insert(key.clone()) {
2722 self.events.push(json!({"method":"Network.loadingFailed","sessionId":key.0,"params":{"requestId":key.1,"timestamp":value.pointer("/params/timestamp").and_then(Value::as_f64).unwrap_or(0.0),"type":kind,"errorText":"net::ERR_BLOCKED_BY_CLIENT","canceled":true,"blockedReason":"inspector"}}));
2723 }
2724 }
2725 }
2726
2727 fn visible_network(&mut self, value: &Value) {
2728 let method = value.get("method").and_then(Value::as_str).unwrap_or("");
2729 if !method.starts_with("Network.") { return }
2730 let Some(id) = value.pointer("/params/requestId").and_then(Value::as_str) else { return };
2731 let key = (value.get("sessionId").and_then(Value::as_str).unwrap_or("").to_string(), id.to_string());
2732 if matches!(method, "Network.loadingFinished" | "Network.loadingFailed") {
2733 self.seen_network.remove(&key);
2734 self.terminal_network.insert(key);
2735 } else {
2736 let kind = value.pointer("/params/type").and_then(Value::as_str).filter(|kind| matches!(*kind, "Document" | "Stylesheet" | "Image" | "Media" | "Font" | "Script" | "TextTrack" | "XHR" | "Fetch" | "Prefetch" | "EventSource" | "WebSocket" | "Manifest" | "SignedExchange" | "Ping" | "CSPViolationReport" | "Preflight" | "Other")).unwrap_or("Other");
2737 self.seen_network.entry(key).or_insert_with(|| kind.to_string());
2738 }
2739 }
2740
2741 fn ordinary_redirect_url(&self, url: &str) -> bool {
2742 !self.guard.url(url) && !self.guard.url(&shield::web_socket_as_http(url)) && url::Url::parse(&shield::web_socket_as_http(url)).is_ok_and(|url| matches!(url.scheme(), "http" | "https") && url.has_host())
2743 }
2744
2745 fn remember_navigation(&mut self, params: &Value, session: Option<&str>) {
2746 if let Some((session, url)) = session.zip(params["url"].as_str()).filter(|(_, url)| self.ordinary_redirect_url(url)) {
2747 self.client_navigation_urls.insert(session.to_string(), url.to_string());
2748 }
2749 }
2750
2751 fn ordinary_network_source(&self, session: &str, id: &str) -> Option<String> {
2752 let resources: Vec<_> = self.request_resources.get(id).into_iter().flatten().filter(|(peer, _)| peer == session).map(|(_, url)| url).collect();
2753 if resources.is_empty() || resources.iter().any(|url| !self.ordinary_redirect_url(url)) { return None }
2754 resources.first().map(|url| (*url).clone())
2755 }
2756
2757 fn actual_redirect_carrier(&self, value: &Value) -> bool {
2758 self.protected_carrier(value, true)
2759 }
2760
2761 fn protected_carrier(&self, value: &Value, navigation: bool) -> bool {
2762 let params = &value["params"];
2763 let method = value["method"].as_str().unwrap_or("");
2764 if navigation && method == "Network.requestWillBeSentExtraInfo" { return false }
2765 let (headers, status, source) = match method {
2766 "Network.requestWillBeSent" => (params.pointer("/redirectResponse/headers"), params.pointer("/redirectResponse/status"), params.pointer("/redirectResponse/url").and_then(Value::as_str).map(str::to_string)),
2767 "Network.responseReceived" => (params.pointer("/response/headers"), params.pointer("/response/status"), params.pointer("/response/url").and_then(Value::as_str).map(str::to_string)),
2768 "Fetch.requestPaused" => (params.get("responseHeaders"), params.get("responseStatusCode"), params.pointer("/request/url").and_then(Value::as_str).map(str::to_string)),
2769 "Network.responseReceivedExtraInfo" | "Network.requestWillBeSentExtraInfo" => (params.get("headers"), params.get("statusCode"), self.ordinary_network_source(value["sessionId"].as_str().unwrap_or(""), params["requestId"].as_str().unwrap_or(""))),
2770 _ => return false,
2771 };
2772 let source = source.as_deref().unwrap_or("https://ordinary.invalid/");
2773 let redirect = status.and_then(Value::as_u64).is_some_and(|status| matches!(status, 301 | 302 | 303 | 307 | 308));
2774 let sensitive = |target: &str| Self::carrier_targets(&self.guard, target, source).is_none_or(|targets| targets.iter().any(|target| target.protected && {
2775 let url = &target.url;
2776 !url.path().trim_matches('/').is_empty() || url.query().is_some() || url.fragment().is_some() || !url.username().is_empty() || url.password().is_some()
2777 }));
2778 let protected = |name: &str, text: &str| {
2779 match name.to_ascii_lowercase().as_str() {
2780 "location" if navigation && redirect => Self::carrier_targets(&self.guard, text, source).is_none_or(|targets| targets.iter().any(|target| target.protected)),
2781 "location" | "content-location" if !navigation => sensitive(text),
2782 "refresh" => Self::redirect_header_value(&self.guard, name, text, source).is_none_or(|text| text.split_once(";url=").is_none_or(|(_, url)| self.guard.url(url))),
2783 "link" => text.split([',', '\n']).any(|part| part.trim().strip_prefix('<').and_then(|part| part.split_once('>')).is_some_and(|(target, _)| sensitive(target))),
2784 _ => false,
2785 }
2786 };
2787 let found = match headers {
2788 Some(Value::Object(headers)) => headers.iter().any(|(name, value)| value.as_str().is_some_and(|text| protected(name, text))),
2789 Some(Value::Array(headers)) => headers.iter().any(|field| field["name"].as_str().zip(field["value"].as_str()).is_some_and(|(name, text)| protected(name, text))),
2790 _ => false,
2791 };
2792 found || ["headersText", "requestHeadersText"].iter().filter(|field| !navigation || **field == "headersText").any(|field| params.get(*field).and_then(Value::as_str).is_some_and(|text| text.lines().filter_map(|line| line.split_once(':')).any(|(name, value)| protected(name.trim(), value.trim()))))
2793 }
2794
2795 fn ordinary_late_extra_info(&self, value: &Value) -> bool {
2796 let session = value["sessionId"].as_str().unwrap_or("");
2797 let id = value["params"]["requestId"].as_str().unwrap_or("");
2798 self.requests.get(id) == Some(&false) && !self.redirects.contains(id) && !self.protected_body_requests.contains(id) && !self.guard_sessions.contains(session)
2799 && !self.redirect_page_protected(value) && !shield::loaded_urls_protected(&self.guard, &value["params"])
2800 && self.ordinary_network_source(session, id).is_some()
2801 }
2802
2803 fn redirect_page_protected(&self, value: &Value) -> bool {
2804 let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
2805 let params = &value["params"];
2806 let hop = value["method"] == "Network.requestWillBeSent" && params["type"] == "Document" && params.pointer("/redirectResponse/url").and_then(Value::as_str).is_some_and(|url| self.ordinary_redirect_url(url)) && params.get("documentURL").is_some_and(|url| Some(url) == params.pointer("/request/url"));
2807 self.blocked.contains(session)
2808 || self.protected_request_pages.iter().any(|(peer, id)| id == params["requestId"].as_str().unwrap_or("") && (peer == session || self.session_target(session).is_some_and(|target| self.session_target(peer).as_ref() == Some(&target))))
2809 || self.session_target(session).is_some_and(|target| self.protected_targets.contains(&target) || self.documents.get(&target).is_some_and(|doc| self.guard.url(&doc.url) || self.guard.url(&doc.origin)))
2810 || params.get("frameId").and_then(Value::as_str).is_some_and(|frame| self.frame_protected(frame))
2811 || params.get("initiator").is_some_and(|initiator| shield::loaded_urls_protected(&self.guard, initiator))
2812 || !hop && params.get("documentURL").and_then(Value::as_str).is_some_and(|url| self.guard.url(url))
2813 }
2814
2815 fn refresh_parts(text: &str) -> Option<(&str, Option<&str>)> {
2816 let text = text.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
2817 let bytes = text.as_bytes();
2818 let mut at = bytes.iter().take_while(|byte| byte.is_ascii_digit()).count();
2819 if at == 0 { return None }
2820 if bytes.get(at) == Some(&b'.') {
2821 at += 1;
2822 while bytes.get(at).is_some_and(u8::is_ascii_digit) { at += 1; }
2823 }
2824 let delay = &text[..at];
2825 let tail = text[at..].trim_start_matches(|ch: char| ch.is_ascii_whitespace());
2826 if tail.is_empty() { return Some((delay, None)) }
2827 let mut target = tail.strip_prefix(';').or_else(|| tail.strip_prefix(','))?.trim_matches(|ch: char| ch.is_ascii_whitespace());
2828 if target.get(..3).is_some_and(|key| key.eq_ignore_ascii_case("url")) {
2829 if let Some(value) = target[3..].trim_start_matches(|ch: char| ch.is_ascii_whitespace()).strip_prefix('=') {
2830 target = value.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
2831 }
2832 }
2833 if let Some(quote) = target.chars().next().filter(|ch| matches!(ch, '\'' | '"')) {
2834 target = &target[1..];
2835 target = target.split(quote).next().unwrap_or("");
2836 }
2837 target = target.trim_matches(|ch: char| ch.is_ascii_whitespace());
2838 Some((delay, (!target.is_empty()).then_some(target)))
2839 }
2840
2841 pub fn ordinary_redirect_target(&self, source: &str, target: &str) -> Result<String, String> {
2842 target_refusal(&self.guard, source, target).map_err(|_| self.refusal())
2843 }
2844
2845 fn carrier_targets(guard: &Protected, target: &str, source: &str) -> Option<Vec<CarrierTarget>> {
2846 let base = url::Url::parse(source).ok()?;
2847 let mut target = target.trim().to_string();
2848 if target.chars().any(char::is_control) { return None }
2849 let mut targets = Vec::new();
2850 for _ in 0..9 {
2851 let url = base.join(&target).ok()?;
2852 let keyed = guard.resource_key(&target).and_then(|key| url::Url::parse(&key).ok()).unwrap_or_else(|| url.clone());
2853 let origin = keyed.origin().ascii_serialization();
2854 let web = origin.starts_with("http://") || origin.starts_with("https://");
2855 let protected = if web { guard.url(&origin) } else { guard.url(keyed.as_str()) };
2856 targets.push(CarrierTarget { origin: web.then_some(origin), protected, url });
2857 let decoded = shield::decode_percent(&target);
2858 if decoded == target { return Some(targets) }
2859 target = decoded;
2860 }
2861 None
2862 }
2863
2864 fn redirect_header_value(guard: &Protected, name: &str, text: &str, source: &str) -> Option<String> {
2865 if text.is_empty() || text.chars().any(|ch| ch.is_control() && !(name.eq_ignore_ascii_case("refresh") && ch == '\t')) { return None }
2866 let origin = |target: &str| {
2867 let targets = Self::carrier_targets(guard, target, source)?;
2868 targets.iter().find(|target| target.protected).unwrap_or(&targets[0]).origin.clone()
2869 };
2870 match name.to_ascii_lowercase().as_str() {
2871 "location" | "content-location" => origin(text),
2872 "refresh" => {
2873 let (delay, target) = Self::refresh_parts(text)?;
2874 Some(format!("{delay};url={}", origin(target.unwrap_or(source))?))
2875 }
2876 "link" => text.split(',').map(|part| {
2877 let (target, params) = part.trim().strip_prefix('<')?.split_once('>')?;
2878 let mut link = format!("<{}>", origin(target)?);
2879 for param in params.split(';') {
2880 let Some((key, value)) = param.trim().split_once('=') else { continue };
2881 let key = key.to_ascii_lowercase();
2882 let value = value.trim();
2883 let tokens = value.trim_matches('"');
2884 if matches!(key.as_str(), "rel" | "rev") && !tokens.is_empty() && tokens.split_ascii_whitespace().all(|token| matches!(token, "preconnect" | "prefetch" | "preload" | "next" | "prev" | "stylesheet" | "canonical" | "alternate" | "noopener" | "noreferrer" | "icon" | "dns-prefetch" | "upskill" | "https://www.sliccy.ai/rel/upskill")) {
2885 link.push_str(&format!("; {key}={value}"));
2886 }
2887 }
2888 Some(link)
2889 }).collect::<Option<Vec<_>>>().map(|links| links.join(", ")),
2890 _ => None,
2891 }
2892 }
2893
2894 pub fn ordinary_response_body(&self, session: &str, request_id: &str) -> bool {
2895 !self.content_blocked(session) && self.request_state(request_id) == Some(false)
2896 && !self.protected_body_requests.contains(request_id) && self.ordinary_network_source(session, request_id).is_some()
2897 }
2898
2899 pub fn redact_redirect_carriers(&self, headers: &mut Value, source: &str) {
2900 let Some(fields) = headers.as_object_mut() else { return };
2901 fields.retain(|name, value| {
2902 let kind = name.to_ascii_lowercase();
2903 if !matches!(kind.as_str(), "location" | "content-location" | "refresh" | "link") { return true }
2904 let Some(text) = value.as_str() else { return false };
2905 let targets = match kind.as_str() {
2906 "refresh" => Self::refresh_parts(text).and_then(|(_, target)| Self::carrier_targets(&self.guard, target.unwrap_or(source), source)),
2907 "link" => text.split(',').map(|part| part.trim().strip_prefix('<').and_then(|part| part.split_once('>')).and_then(|(target, _)| Self::carrier_targets(&self.guard, target, source))).collect::<Option<Vec<_>>>().map(|parts| parts.into_iter().flatten().collect()),
2908 _ => Self::carrier_targets(&self.guard, text, source),
2909 };
2910 if targets.is_some_and(|targets| !targets.is_empty() && targets.iter().all(|target| !target.protected && matches!(target.url.scheme(), "http" | "https") && target.url.has_host())) { return true }
2911 match Self::redirect_header_value(&self.guard, name, text, source) {
2912 Some(safe) => { *value = json!(safe); true }
2913 None => false,
2914 }
2915 });
2916 }
2917
2918 fn redirect_headers(headers: &mut Value, source: &str, guard: &Protected) {
2919 match headers {
2920 Value::Object(fields) => fields.retain(|name, value| {
2921 let safe = value.as_str().and_then(|text| Self::redirect_header_value(guard, name, text, source));
2922 if let Some(safe) = safe { *value = json!(safe); true } else { false }
2923 }),
2924 Value::Array(fields) => {
2925 fields.retain_mut(|field| {
2926 let safe = field.get("name").and_then(Value::as_str).zip(field.get("value").and_then(Value::as_str)).and_then(|(name, text)| Self::redirect_header_value(guard, name, text, source));
2927 if let Some(safe) = safe { field["value"] = json!(safe); true } else { false }
2928 });
2929 for field in fields { if let Some(fields) = field.as_object_mut() { fields.retain(|name, _| matches!(name.as_str(), "name" | "value")); } }
2930 }
2931 _ => *headers = json!({}),
2932 }
2933 }
2934
2935 fn visible_redirect(&self, value: &mut Value) -> bool {
2936 let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("").to_string();
2937 if self.guard_sessions.contains(&session) || self.redirect_page_protected(value) { return false }
2938 let id = value.pointer("/params/requestId").and_then(Value::as_str).unwrap_or("").to_string();
2939 let method = value.get("method").and_then(Value::as_str).unwrap_or("").to_string();
2940 let params = &value["params"];
2941 let source = match method.as_str() {
2942 "Network.requestWillBeSent" => params.pointer("/redirectResponse/url").and_then(Value::as_str).map(str::to_string),
2943 "Network.responseReceived" => params.pointer("/response/url").and_then(Value::as_str).map(str::to_string),
2944 "Fetch.requestPaused" => params.pointer("/request/url").and_then(Value::as_str).map(str::to_string),
2945 "Network.responseReceivedExtraInfo" => self.ordinary_network_source(&session, &id),
2946 _ => return false,
2947 };
2948 let Some(source) = source.filter(|url| self.ordinary_redirect_url(url)) else { return false };
2949 let mut loaded = params.clone();
2950 if method == "Network.requestWillBeSent" {
2951 if let Some(fields) = loaded.as_object_mut() { fields.remove("request"); fields.remove("documentURL"); }
2952 }
2953 if shield::loaded_urls_protected(&self.guard, &loaded) { return false }
2954 if method == "Fetch.requestPaused" && Paused::from_event(value).is_none_or(|paused| self.initiated_by_protected(&paused)) { return false }
2955 let mut carriers = match method.as_str() {
2956 "Network.responseReceived" => params.pointer("/response/headers"),
2957 "Network.requestWillBeSent" => params.pointer("/redirectResponse/headers"),
2958 "Network.responseReceivedExtraInfo" => params.get("headers"),
2959 _ => params.get("responseHeaders"),
2960 }.cloned().unwrap_or(Value::Null);
2961 Self::redirect_headers(&mut carriers, &source, &self.guard);
2962 if !shield::redact_strings(&self.guard, &mut carriers) { return false }
2963 if method == "Network.responseReceivedExtraInfo" && !self.requests.contains_key(&id) { return false }
2964 let status = match method.as_str() {
2965 "Network.requestWillBeSent" => value.pointer("/params/redirectResponse/status"),
2966 "Network.responseReceived" => value.pointer("/params/response/status"),
2967 "Network.responseReceivedExtraInfo" => value.pointer("/params/statusCode"),
2968 "Fetch.requestPaused" => value.pointer("/params/responseStatusCode"),
2969 _ => return false,
2970 }.and_then(Value::as_u64);
2971 if !status.is_some_and(|status| matches!(status, 301 | 302 | 303 | 307 | 308)) || method == "Network.requestWillBeSent" && !self.seen_network.contains_key(&(session, id)) {
2972 return false;
2973 }
2974 let mut redacted = value.clone();
2975 if !shield::redact_strings(&self.guard, &mut redacted) { return false }
2976 let params = &mut redacted["params"];
2977 if method == "Network.responseReceivedExtraInfo" {
2978 if let Some(fields) = params.as_object_mut() { fields.retain(|key, _| matches!(key.as_str(), "requestId" | "statusCode" | "headers" | "headersText")); }
2979 Self::redirect_headers(&mut params["headers"], &source, &self.guard);
2980 if params.get("headersText").is_some() {
2981 let headers = params["headers"].as_object().map(|headers| headers.iter().map(|(name, value)| format!("{name}: {}\r\n", value.as_str().unwrap())).collect::<String>()).unwrap_or_default();
2982 params["headersText"] = json!(format!("HTTP/1.1 {}\r\n{headers}\r\n", status.unwrap()));
2983 }
2984 } else {
2985 let request = json!({"url":params.pointer("/request/url").cloned().unwrap_or(Value::Null),"method":params.pointer("/request/method").and_then(Value::as_str).filter(|method| matches!(*method, "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH")).unwrap_or("GET"),"headers":{}});
2986 if method == "Fetch.requestPaused" {
2987 if let Some(fields) = params.as_object_mut() { fields.retain(|key, _| matches!(key.as_str(), "requestId" | "networkId" | "frameId" | "resourceType" | "responseStatusCode" | "responseHeaders")); }
2988 Self::redirect_headers(&mut params["responseHeaders"], &source, &self.guard);
2989 params["responseStatusText"] = json!("");
2990 params["request"] = request;
2991 } else {
2992 let key = if method == "Network.requestWillBeSent" { "redirectResponse" } else { "response" };
2993 let mut response = params[key].clone();
2994 if let Some(fields) = response.as_object_mut() { fields.retain(|key, _| matches!(key.as_str(), "url" | "status" | "headers")); }
2995 response["statusText"] = json!("");
2996 Self::redirect_headers(&mut response["headers"], &source, &self.guard);
2997 if let Some(fields) = params.as_object_mut() { fields.retain(|key, _| matches!(key.as_str(), "requestId" | "loaderId" | "frameId" | "timestamp" | "wallTime" | "type")); }
2998 if method == "Network.requestWillBeSent" { params["request"] = request; }
2999 params[key] = response;
3000 }
3001 }
3002 *value = redacted;
3003 true
3004 }
3005
3006 fn deliver(&mut self, value: Value) {
3007 self.deliver_event(value);
3008 if !self.checking_page_events {
3009 while let Some(event) = self.pending_page_events.pop_front() { self.deliver_event(event); }
3010 }
3011 }
3012
3013 fn deliver_event(&mut self, mut value: Value) {
3014 self.watch(&mut value);
3015 if value.get("method").and_then(Value::as_str) == Some("Page.screencastFrame") {
3016 if let Some(session) = value.get("sessionId").and_then(Value::as_str) {
3017 let _ = self.send("Page.stopScreencast", json!({}), Some(session));
3018 }
3019 return;
3020 }
3021 if value.get("method").and_then(Value::as_str).is_some_and(page_scope::content_event) {
3022 let session = value.get("sessionId").and_then(Value::as_str);
3023 if session.is_some_and(|session| self.guard_sessions.contains(session)) { return }
3024 if self.checking_page_events { self.pending_page_events.push_back(value); return }
3025 self.checking_page_events = true;
3026 let verified = self.verify_page_scope(session).is_ok();
3027 self.checking_page_events = false;
3028 if !verified { return }
3029 }
3030 let key = (value.get("sessionId").and_then(Value::as_str).unwrap_or("").to_string(), value.pointer("/params/requestId").and_then(Value::as_str).unwrap_or("").to_string());
3031 let terminal = self.terminal_network.contains(&key);
3032 let late_ordinary = terminal && matches!(value["method"].as_str(), Some("Network.responseReceivedExtraInfo" | "Network.requestWillBeSentExtraInfo")) && self.ordinary_late_extra_info(&value);
3033 if value.get("method").and_then(Value::as_str).is_some_and(|method| method.starts_with("Network.") && method != "Network.responseReceivedExtraInfo") && terminal && !late_ordinary { return }
3034 let guarded = self.blocked.contains(&key.0) || shield::loaded_urls_protected(&self.guard, &value["params"]) || self.hidden(&value) || self.redirects.contains(&key.1);
3035 if guarded && self.visible_redirect(&mut value) {
3036 let hop = value.get("method").and_then(Value::as_str) == Some("Network.requestWillBeSent");
3037 self.events.push(value.clone());
3038 if hop { self.fail_seen_network(&value); }
3039 return;
3040 }
3041 if terminal && !late_ordinary { return }
3042 if self.worlds.event(&value, &self.guard) {
3043 if value.get("method").and_then(Value::as_str) == Some("Debugger.paused") {
3044 let _ = self.send("Debugger.resume", json!({}), value.get("sessionId").and_then(Value::as_str));
3045 }
3046 return;
3047 }
3048 if guarded && !value.get("method").and_then(Value::as_str).unwrap_or("").starts_with("Target.") {
3049 self.fail_seen_network(&value);
3050 return;
3051 }
3052 self.adopt_guard_attach(&value);
3053 if self.blocked.contains(value.get("sessionId").and_then(Value::as_str).unwrap_or("")) || !value.get("method").and_then(Value::as_str).unwrap_or("").starts_with("Target.") && shield::loaded_urls_protected(&self.guard, &value["params"]) {
3054 self.fail_seen_network(&value);
3055 return;
3056 }
3057 let echoed_url = (value["method"] == "Network.requestWillBeSent" && value["params"]["type"] == "Document" && !guarded
3058 && !self.protected_body_requests.contains(&key.1) && self.client_navigation_urls.get(&key.0).is_some_and(|url| value.pointer("/params/request/url").and_then(Value::as_str) == Some(url.as_str()))).then(|| value["params"]["request"]["url"].clone());
3059 shield::redact_strings(&self.guard, &mut value);
3060 if let Some(url) = echoed_url { value["params"]["request"]["url"] = url; }
3061 if self.hidden(&value) {
3062 self.fail_seen_network(&value);
3063 return;
3064 }
3065 let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("");
3066 self.worlds.visible_event(value.get("sessionId").and_then(Value::as_str).unwrap_or(""), method, &value["params"]);
3067 let id = value
3068 .pointer("/params/requestId")
3069 .and_then(|v| v.as_str())
3070 .map(str::to_string);
3071 let session = value.get("sessionId").and_then(|v| v.as_str());
3072 if let (true, Some(session), Some(id)) = (method.starts_with("Fetch."), session, &id) {
3073 let session = session.to_string();
3074 if value.pointer("/params/resourceType").and_then(Value::as_str) == Some("Document") {
3075 self.paused_documents.insert((session.clone(), id.clone()));
3076 }
3077 self.hand(&session, id);
3078 }
3079 if method.ends_with("ExtraInfo") {
3080 if let Some(id) = id.clone() {
3081 if let Some(cookies) = value.pointer("/params/associatedCookies").and_then(Value::as_array) {
3082 self.trace_cookies.extend(cookies.iter().filter_map(|item| item.get("cookie").cloned()));
3083 }
3084 shield::strip_protected_associated(&self.guard, &mut value);
3085 if self.requests.get(&id) != Some(&false) {
3086 self.held.entry(id).or_default().push(value);
3087 return;
3088 }
3089 }
3090 }
3091 if !terminal { self.visible_network(&value); }
3092 self.events.push(value);
3093 if let Some(id) = id {
3094 if self.requests.get(&id) == Some(&false) {
3095 if let Some(held) = self.held.remove(&id) {
3096 for event in held { self.deliver(event); }
3097 }
3098 }
3099 }
3100 }
3101
3102 fn renderer_crashed(&self, session: Option<&str>, target: Option<&str>) -> bool {
3103 target.is_some_and(|target| self.crashed_targets.contains(target))
3104 || session.is_some_and(|session| self.crashed_sessions.contains(session)
3105 || self.session_target(session).is_some_and(|target| self.crashed_targets.contains(&target)))
3106 }
3107
3108 fn observe_renderer_crash(&mut self, value: &Value) {
3109 let method = value.get("method").and_then(Value::as_str).unwrap_or("");
3110 let reason = value.pointer("/params/reason").and_then(Value::as_str).unwrap_or("").to_ascii_lowercase();
3111 if method != "Inspector.targetCrashed" && !(matches!(method, "Inspector.detached" | "Target.detachedFromTarget")
3112 && reason.contains("crash")) { return }
3113 let session = if method == "Target.detachedFromTarget" { value.pointer("/params/sessionId") } else { value.get("sessionId") }
3114 .and_then(Value::as_str).filter(|session| !session.is_empty());
3115 if let Some(session) = session {
3116 self.crashed_sessions.insert(session.to_string());
3117 if let Some(target) = self.session_target(session) {
3118 self.crashed_targets.insert(target.clone());
3119 self.crashed_sessions.extend(self.session_targets.iter().filter(|(_, owner)| *owner == &target).map(|(session, _)| session.clone()));
3120 }
3121 }
3122 }
3123
3124 fn raw_call(
3125 &mut self,
3126 method: &str,
3127 params: Value,
3128 session_id: Option<&str>,
3129 timeout: Duration,
3130 ) -> Result<Value, String> {
3131 self.raw_call_execution(method, params, session_id, timeout, false)
3132 }
3133
3134 fn raw_call_execution(
3135 &mut self,
3136 method: &str,
3137 params: Value,
3138 session_id: Option<&str>,
3139 timeout: Duration,
3140 pinned: bool,
3141 ) -> Result<Value, String> {
3142 let active = pinned.then_some(session_id).flatten();
3143 if let Some(session) = active { self.executing_sessions.insert(session.to_string()); }
3144 let result = self.raw_call_wait(method, params, session_id, timeout, pinned);
3145 if let Some(session) = active {
3146 self.executing_sessions.remove(session);
3147 if self.blocked.contains(session) { self.detach_blocked(session); }
3148 }
3149 result
3150 }
3151
3152 fn raw_call_wait(
3153 &mut self,
3154 method: &str,
3155 params: Value,
3156 session_id: Option<&str>,
3157 timeout: Duration,
3158 pinned: bool,
3159 ) -> Result<Value, String> {
3160 let frame_epoch = self.frame_epoch;
3161 let page = session_id.and_then(|session| self.session_target(session)).and_then(|target| self.page_scope_owner(&target))
3162 .map(|page| (page.clone(), self.page_scope_epochs.get(&page).copied().unwrap_or(0), self.page_scope_unknown));
3163 let result = self.raw_exchange(method, params, session_id, timeout, pinned)?;
3164 if method == "Page.getFrameTree" {
3165 let stable_page = page.is_some_and(|(page, epoch, unknown)| self.page_scope_epochs.get(&page).copied().unwrap_or(0) == epoch && self.page_scope_unknown == unknown);
3166 if !stable_page && !self.tree_current(&result, frame_epoch) {
3167 return Err(self.refusal());
3168 }
3169 self.record_frames(&result, session_id.unwrap_or(""));
3170 }
3171 Ok(result)
3172 }
3173
3174 fn raw_exchange(
3175 &mut self,
3176 method: &str,
3177 params: Value,
3178 session_id: Option<&str>,
3179 timeout: Duration,
3180 pinned: bool,
3181 ) -> Result<Value, String> {
3182 if self.renderer_crashed(session_id, None) { return Err(RENDERER_CRASHED.to_string()) }
3183 let id = self.send(method, params, session_id)?;
3184 self.waiting_calls.insert(id);
3185 let result = self.raw_reply(method, id, session_id, timeout, pinned);
3186 self.waiting_calls.remove(&id);
3187 self.call_replies.remove(&id);
3188 if method == "Page.getFrameTree" && session_id.is_some_and(|session| self.guard_sessions.contains(session)) {
3189 if let Ok(tree) = &result { let _ = self.verify_document_order(tree, session_id); }
3190 }
3191 result
3192 }
3193
3194 fn raw_reply(&mut self, method: &str, id: i64, session_id: Option<&str>, timeout: Duration, pinned: bool) -> Result<Value, String> {
3195 let deadline = Instant::now() + timeout;
3196 let was_blocked = session_id.is_some_and(|session| self.blocked.contains(session));
3197 loop {
3198 if self.renderer_crashed(session_id, None) { return Err(RENDERER_CRASHED.to_string()) }
3199 let value = match self.call_replies.remove(&id) {
3200 Some(value) => value,
3201 None => {
3202 let text = match self.recv(deadline)? {
3203 Some(text) => text,
3204 None => return Err(if pinned && session_id.is_some_and(|session| self.blocked.contains(session)) { self.refusal() } else { format!("{method} timed out\n") }),
3205 };
3206 serde_json::from_str::<Value>(&text)
3207 .map_err(|err| format!("CDP response was not JSON: {err}\n"))?
3208 }
3209 };
3210 if value.get("id").and_then(|item| item.as_i64()) == Some(id) {
3211 self.detach_reply(id, &value);
3212 if let Some(error) = value.get("error") {
3213 if pinned && session_id.is_some_and(|session| self.blocked.contains(session)) { return Err(self.refusal()) }
3214 let text = error
3215 .get("message")
3216 .and_then(|item| item.as_str())
3217 .unwrap_or("CDP error");
3218 let (text, _) = shield::redact_text(&self.guard, text);
3219 return Err(format!("{method}: {text}\n"));
3220 }
3221 if method == "Target.attachToTarget" {
3222 if let Some(session) = value.pointer("/result/sessionId").and_then(Value::as_str) {
3223 self.blocked.remove(session);
3224 self.detached_ordinary.remove(session);
3225 }
3226 }
3227 return Ok(value.get("result").cloned().unwrap_or(Value::Null));
3228 }
3229 let detached = value.get("method").and_then(|v| v.as_str())
3230 == Some("Target.detachedFromTarget")
3231 && value.pointer("/params/sessionId").and_then(|v| v.as_str()) == session_id;
3232 self.absorb(value);
3233 if self.renderer_crashed(session_id, None) { return Err(RENDERER_CRASHED.to_string()) }
3234 if let Some(session) = session_id {
3235 if detached || (!pinned && !was_blocked && self.blocked.contains(session)) {
3236 return Err(self.refusal());
3237 }
3238 }
3239 }
3240 }
3241
3242 fn send(&mut self, method: &str, params: Value, session_id: Option<&str>) -> Result<i64, String> {
3243 if self.next_id >= (1 << 30) - 1 { return Err("CDP client message ID range exhausted\n".into()) }
3244 self.webmcp_trace_fetch_command(method, ¶ms, session_id);
3245 self.next_id += 1;
3246 let id = self.next_id;
3247 let mut message = json!({
3248 "id": id,
3249 "method": method,
3250 "params": params,
3251 });
3252 if let Some(session_id) = session_id {
3253 message["sessionId"] = json!(session_id);
3254 }
3255 self.ws.send_text(&message.to_string()).map_err(|error| self.closing(error))?;
3256 if method == "Target.detachFromTarget" {
3257 if let Some(session) = message.pointer("/params/sessionId").and_then(Value::as_str) {
3258 self.detaching.insert(id, session.to_string());
3259 }
3260 }
3261 Ok(id)
3262 }
3263
3264 pub fn set_waiter(&mut self, waiter: Option<Waiter>) {
3265 self.waiter = waiter;
3266 }
3267
3268 fn recv(&mut self, deadline: Instant) -> Result<Option<String>, String> {
3269 let received = self.receive(deadline);
3270 received.map_err(|error| self.closing(error))
3271 }
3272
3273 fn receive(&mut self, deadline: Instant) -> Result<Option<String>, String> {
3274 loop {
3275 let Some(mut waiter) = self.waiter.take() else { return self.ws.recv_text(deadline) };
3276 let now = Instant::now();
3277 if now >= self.waiter_due {
3278 waiter(self);
3279 self.waiter_due = Instant::now() + WAITER_SLICE;
3280 }
3281 self.waiter = Some(waiter);
3282 let until = deadline.min(self.waiter_due);
3283 if let Some(text) = self.ws.recv_text(until)? { return Ok(Some(text)) }
3284 if until == deadline { return Ok(None) }
3285 }
3286 }
3287
3288 pub fn pump(&mut self, deadline: Instant) -> Result<bool, String> {
3289 self.webmcp_sweep();
3290 let result = match self.recv(deadline)? {
3291 Some(text) => {
3292 if let Ok(value) = serde_json::from_str::<Value>(&text) {
3293 self.absorb(value);
3294 }
3295 Ok(true)
3296 }
3297 None => Ok(false),
3298 };
3299 self.webmcp_sweep();
3300 result
3301 }
3302
3303 pub fn next_paused(
3304 &mut self,
3305 session_id: Option<&str>,
3306 timeout: Duration,
3307 ) -> Result<Option<Paused>, String> {
3308 let deadline = Instant::now() + timeout;
3309 loop {
3310 let found = self
3311 .paused
3312 .iter()
3313 .position(|paused| session_id.is_none_or(|id| paused.session_id == id));
3314 if let Some(index) = found {
3315 return Ok(self.paused.remove(index));
3316 }
3317 if let Some(session) = session_id {
3318 if self.blocked.contains(session) {
3319 return Err(self.refusal());
3320 }
3321 }
3322 if Instant::now() >= deadline || !self.pump(deadline)? {
3323 return Ok(None);
3324 }
3325 }
3326 }
3327
3328 pub fn wait_event(
3329 &mut self,
3330 from: usize,
3331 timeout: Duration,
3332 mut matches: impl FnMut(&Value) -> bool,
3333 ) -> Result<Option<Value>, String> {
3334 let deadline = Instant::now() + timeout;
3335 let mut seen = from;
3336 loop {
3337 while seen < self.events.len() {
3338 if matches(&self.events[seen]) {
3339 return Ok(Some(self.events[seen].clone()));
3340 }
3341 seen += 1;
3342 }
3343 if Instant::now() >= deadline || !self.pump(deadline)? {
3344 return Ok(None);
3345 }
3346 }
3347 }
3348
3349 pub(crate) fn forget_paused(&mut self, session_id: &str) {
3350 self.paused.retain(|paused| paused.session_id != session_id);
3351 self.deciders.remove(session_id);
3352 }
3353
3354 fn detach_reply(&mut self, id: i64, value: &Value) {
3355 if let Some(session) = self.detaching.remove(&id) {
3356 if value.get("error").is_none() { self.forget_session(&session); }
3357 }
3358 }
3359
3360 fn forget_session(&mut self, session: &str) {
3361 self.webmcp_cancel_queued(session);
3362 if !self.blocked.contains(session) && !self.guard_sessions.contains(session) {
3363 self.detached_ordinary.insert(session.to_string());
3364 }
3365 let target = self.session_target(session);
3366 if self.blocked.contains(session) {
3367 if let Some(target) = &target { self.protected_targets.insert(target.clone()); }
3368 self.unchecked_documents.retain(|(_, owner, _), _| owner != session);
3369 }
3370 let guards: Vec<String> = self.guards.iter().filter(|(_, guard)| guard.as_str() == session).map(|(target, _)| target.clone()).collect();
3371 self.session_targets.remove(session);
3372 self.page_scope_sessions.retain(|_, owner| owner != session);
3373 self.guard_sessions.remove(session);
3374 self.blocked.insert(session.to_string());
3375 self.detaching.retain(|_, owner| owner != session);
3376 for target in guards {
3377 self.guards.remove(&target);
3378 self.documents.remove(&target);
3379 self.cached_documents.remove(&target);
3380 self.abandon(&target);
3381 }
3382 self.runtime_sessions.retain(|_, owner| owner != session);
3383 self.frame_sessions.remove(session);
3384 self.context_frames.retain(|(owner, _), _| owner != session);
3385 self.context_proofs.retain(|(owner, _), _| owner != session);
3386 self.created_contexts.retain(|(owner, _)| owner != session);
3387 self.executing_sessions.remove(session);
3388 self.dialog_sessions.remove(session);
3389 self.dialogs.remove(session);
3390 self.dialog_documents.remove(session);
3391 self.forget_paused(session);
3392 self.paused_documents.retain(|(owner, _)| owner != session);
3393 self.handed.retain(|(owner, _)| owner != session);
3394 self.answering.retain(|_, (owner, _)| owner != session);
3395 self.decided.retain(|_, (owner, _)| owner != session);
3396 self.held.remove(session);
3397 self.navigated_blanks.retain(|_, (owner, _, _)| owner != session);
3398 self.seen_network.retain(|(owner, _), _| owner != session);
3399 self.terminal_network.retain(|(owner, _)| owner != session);
3400 self.client_navigation_urls.remove(session);
3401 self.protected_request_pages.retain(|(owner, _)| owner != session);
3402 self.protected_resources.retain(|(owner, _)| owner != session);
3403 self.source_resources.retain(|(owner, _), _| owner != session);
3404 for resources in self.request_resources.values_mut() { resources.retain(|(owner, _)| owner != session); }
3405 for navigation in self.navigations.values_mut() {
3406 if navigation.session.as_deref() == Some(session) { navigation.detached = true; }
3407 }
3408 self.worlds.event(&json!({"method":"Target.detachedFromTarget","params":{"sessionId":session}}), &self.guard);
3409 self.issued.forget(session);
3410 self.webmcp.forget(session);
3411 }
3412
3413 fn absorb(&mut self, mut value: Value) {
3414 if let Some(id) = value.get("id").and_then(|item| item.as_i64()) {
3415 self.detach_reply(id, &value);
3416 if self.waiting_calls.contains(&id) {
3417 self.call_replies.insert(id, value.clone());
3418 }
3419 if let Some(navigation) = self.navigations.get_mut(&id) {
3420 navigation.reply = Some(value);
3421 return;
3422 }
3423 if let Some((session, request)) = self.answering.remove(&id) {
3424 if value.get("error").is_none() {
3425 self.settle(&session, &request);
3426 }
3427 }
3428 if let Some((session, request)) = self.decided.remove(&id) {
3429 if let Some(error) = value.get("error") {
3430 let text = error
3431 .get("message")
3432 .and_then(|item| item.as_str())
3433 .unwrap_or("CDP error");
3434 let (text, _) = shield::redact_text(&self.guard, text);
3435 self.fetch_errors.push(format!("Fetch: {text}\n"));
3436 self.continue_plain(&session, &request);
3437 }
3438 }
3439 return;
3440 }
3441 self.observe_renderer_crash(&value);
3442 self.observe_dialog(&value);
3443 if value.get("method").and_then(Value::as_str) == Some("Target.detachedFromTarget") {
3444 let session = value.pointer("/params/sessionId").and_then(Value::as_str);
3445 for navigation in self.navigations.values_mut() {
3446 if navigation.session.as_deref() == session { navigation.detached = true; }
3447 }
3448 }
3449 if self.webmcp_event(&value) {
3450 return;
3451 }
3452 if value.get("method").and_then(|item| item.as_str()) == Some("Fetch.authRequired")
3453 && self.challenge_protected(&value)
3454 {
3455 let id = value.pointer("/params/requestId").cloned().unwrap_or(Value::Null);
3456 if let Some(key) = id.as_str() {
3457 self.requests.insert(key.to_string(), true);
3458 }
3459 let session = value.get("sessionId").and_then(|v| v.as_str()).unwrap_or("").to_string();
3460 let _ = self.send(
3461 "Fetch.continueWithAuth",
3462 json!({ "requestId": id, "authChallengeResponse": { "response": "Default" } }),
3463 Some(&session),
3464 );
3465 return;
3466 }
3467 if value.get("method").and_then(Value::as_str) == Some("Fetch.authRequired") && self.guard_auth(&value) {
3468 return;
3469 }
3470 if value.get("method").and_then(|item| item.as_str()) == Some("Fetch.requestPaused") {
3471 let Some(mut paused) = Paused::from_event(&value) else {
3472 return;
3473 };
3474 if let Some(network) = &paused.network_id {
3475 self.request_aliases.entry(network.clone()).or_default().insert(paused.request_id.clone());
3476 self.request_aliases.entry(paused.request_id.clone()).or_default().insert(network.clone());
3477 if self.redirects.contains(network) {
3478 self.mark_redirect(network);
3479 }
3480 if self.protected_body_requests.contains(network) { self.protect_body_request(network); }
3481 }
3482 if self.issued_hop(&value, &paused) { return; }
3483 let guard_only = self.guard_only(&paused);
3484 let authority_protected = shield::scheme_less_protected(&self.guard, &value["params"]["request"]);
3485 let redirect = self.actual_redirect_carrier(&value);
3486 if self.protected_carrier(&value, false) {
3487 self.protect_body_request(&paused.request_id);
3488 if let Some(network) = &paused.network_id { self.protect_body_request(network); self.protect_resource(&paused.session_id, &paused.url); }
3489 }
3490 let reaches = self.guard.url(&paused.full_url()) || redirect || authority_protected;
3491 let loaded_protected = self.guard.url(&paused.full_url()) || authority_protected;
3492 let protected = reaches;
3493 if loaded_protected {
3494 self.protect_resource(&paused.session_id, &paused.full_url());
3495 }
3496 if redirect {
3497 self.mark_redirect(&paused.request_id);
3498 if let Some(network) = &paused.network_id {
3499 self.mark_redirect(network);
3500 self.protect_resource(&paused.session_id, &paused.url);
3501 }
3502 }
3503 let initiated = self.initiated_by_protected(&paused);
3504 if self.redirect_page_protected(&value) || initiated {
3505 self.protected_request_pages.insert((paused.session_id.clone(), paused.request_id.clone()));
3506 if let Some(network) = &paused.network_id { self.protected_request_pages.insert((paused.session_id.clone(), network.clone())); }
3507 }
3508 #[cfg(test)]
3509 let observed_url = paused.url.clone();
3510 let ordinary_url = (!redirect && !loaded_protected && !initiated && !self.guard_sessions.contains(&paused.session_id) && !self.redirect_page_protected(&value)
3511 && paused.resource_type == "Document" && self.client_navigation_urls.get(&paused.session_id) == Some(&paused.url)
3512 && !self.protected_body_requests.contains(&paused.request_id)
3513 && self.ordinary_redirect_url(&paused.url)).then(|| paused.url.clone());
3514 shield::redact_strings(&self.guard, &mut value);
3515 if let Some(url) = ordinary_url { value["params"]["request"]["url"] = json!(url); }
3516 paused = Paused::from_event(&value).unwrap();
3517 self.requests.insert(paused.request_id.clone(), loaded_protected || initiated);
3518 if let Some(network) = &paused.network_id {
3519 let entry = self.requests.entry(network.clone()).or_insert(false);
3520 *entry = *entry || loaded_protected || initiated;
3521 }
3522 #[cfg(test)]
3523 {
3524 let reason = if self.guard_sessions.contains(&paused.session_id) {
3525 "guard session"
3526 } else if protected {
3527 "reaches a protected origin"
3528 } else if self.blocked.contains(&paused.session_id) {
3529 "blocked session"
3530 } else if initiated {
3531 "initiated by protected"
3532 } else if self.deciders.contains_key(&paused.session_id) {
3533 "decider"
3534 } else {
3535 "queued"
3536 };
3537 self.paused_trail
3538 .push((paused.session_id.clone(), observed_url, reason));
3539 }
3540 if self.guard_sessions.contains(&paused.session_id) {
3541 if reaches && paused.resource_type == "Document" {
3542 self.answer_protected(&paused);
3543 } else {
3544 self.continue_plain(&paused.session_id, &paused.request_id);
3545 }
3546 return;
3547 }
3548 if protected {
3549 if redirect && !authority_protected && self.visible_redirect(&mut value) {
3550 self.events.push(value);
3551 }
3552 self.answer_protected(&paused);
3553 return;
3554 }
3555 if self.blocked.contains(&paused.session_id) || initiated {
3556 self.fail_seen_network(&value);
3557 self.continue_plain(&paused.session_id, &paused.request_id);
3558 return;
3559 }
3560 if self.redirects.contains(&paused.request_id) && !redirect && !protected {
3561 self.continue_plain(&paused.session_id, &paused.request_id);
3562 return;
3563 }
3564 if guard_only {
3565 self.continue_plain(&paused.session_id, &paused.request_id);
3566 return;
3567 }
3568 if paused.resource_type == "Document" {
3569 self.paused_documents.insert((paused.session_id.clone(), paused.request_id.clone()));
3570 }
3571 self.hand(&paused.session_id, &paused.request_id);
3572 if self.deciders.contains_key(&paused.session_id) {
3573 self.decide_inline(paused);
3574 } else {
3575 self.paused.push_back(paused);
3576 }
3577 return;
3578 }
3579 if value.get("method").is_some() {
3580 self.deliver(value);
3581 }
3582 }
3583
3584 fn decide_inline(&mut self, paused: Paused) {
3585 if self.blocked.contains(&paused.session_id) {
3586 self.answer_protected(&paused);
3587 return;
3588 }
3589 if self.defer_issued(&paused) { return }
3590 let Some(decider) = self.deciders.get_mut(&paused.session_id) else {
3591 self.paused.push_back(paused);
3592 return;
3593 };
3594 let decision = decider(&paused);
3595 let (method, params, issued) = match fetch::command(&self.guard, &paused, &decision)
3596 .and_then(|(method, params)| self.client_issued(method, ¶ms, Some(&paused.session_id)).map(|issued| (method, params, issued))) {
3597 Ok(command) => command,
3598 Err(message) => {
3599 self.fetch_errors.push(message);
3600 let (method, params) = fetch::command(&self.guard, &paused, &Decision::default())
3601 .expect("a plain continue is always allowed");
3602 (method, params, false)
3603 }
3604 };
3605 if issued {
3606 if let Err(message) = self.issue(¶ms, &paused.session_id) {
3607 self.fetch_errors.push(message);
3608 self.continue_plain(&paused.session_id, &paused.request_id);
3609 return;
3610 }
3611 }
3612 self.settle(&paused.session_id, &paused.request_id);
3613 if let Ok(id) = self.send(method, params, Some(&paused.session_id)) {
3614 self.decided
3615 .insert(id, (paused.session_id.clone(), paused.request_id.clone()));
3616 }
3617 }
3618
3619 pub fn wait_load(&mut self, session_id: &str) -> Result<(), String> {
3620 if self.events.iter().any(|event| is_load(event, session_id)) {
3621 return Ok(());
3622 }
3623 let probe = self.call_for(
3624 "Runtime.evaluate",
3625 json!({
3626 "expression": "document.readyState",
3627 "returnByValue": true,
3628 }),
3629 Some(session_id),
3630 Duration::from_secs(1),
3631 );
3632 let settled = match &probe {
3633 Ok(result) => probe_is_settled(Ok(result
3634 .pointer("/result/value")
3635 .and_then(|value| value.as_str())))?,
3636 Err(err) => probe_is_settled(Err(err.as_str()))?,
3637 };
3638 if settled {
3639 return Ok(());
3640 }
3641 let deadline = Instant::now() + Duration::from_secs(15);
3642 loop {
3643 if self.blocked.contains(session_id) {
3644 return Err(self.refusal());
3645 }
3646 if self.events.iter().any(|event| is_load(event, session_id)) {
3647 return Ok(());
3648 }
3649 if Instant::now() >= deadline {
3650 return Ok(());
3651 }
3652 if !self.pump(deadline)? {
3653 return Ok(());
3654 }
3655 }
3656 }
3657
3658 pub fn wait_load_since(&mut self, session_id: &str, from: usize) -> Result<(), String> {
3659 let deadline = Instant::now() + Duration::from_secs(15);
3660 loop {
3661 if self.blocked.contains(session_id) {
3662 return Err(self.refusal());
3663 }
3664 if navigation_settled(&self.events, session_id, from) {
3665 return Ok(());
3666 }
3667 if Instant::now() >= deadline {
3668 return Ok(());
3669 }
3670 if !self.pump(deadline)? {
3671 return Ok(());
3672 }
3673 }
3674 }
3675}
3676
3677pub fn transport_error(message: &str) -> bool {
3678 message.starts_with("websocket ") || message.starts_with("CDP response was not JSON")
3679}
3680
3681fn unguarded(message: String) -> String {
3682 if transport_error(&message) || message == RENDERER_CRASHED {
3683 message
3684 } else if let Some(step) = message.strip_suffix(" timed out\n") {
3685 format!("{}: {step} timed out\n", UNGUARDED.trim_end())
3686 } else {
3687 UNGUARDED.to_string()
3688 }
3689}
3690
3691fn frame_text_known(text: &str) -> bool {
3692 !matches!(text, "" | "null" | "://")
3693}
3694
3695fn crosses_frames(method: &str, params: &Value) -> bool {
3696 matches!(method, "DOMSnapshot.captureSnapshot" | "DOMSnapshot.getSnapshot" | "DOM.getFlattenedDocument")
3697 || method.starts_with("DOM.") && (params.get("pierce").and_then(Value::as_bool) == Some(true)
3698 || params.get("depth").and_then(Value::as_i64).is_some_and(|depth| depth < 0 || depth > 1))
3699}
3700
3701fn recovery(method: &str, params: &Value) -> bool {
3702 match method {
3703 "Page.enable" | "Page.disable" | "Page.navigate" | "Page.reload" | "Page.stopLoading" | "Page.navigateToHistoryEntry"
3704 | "Page.getNavigationHistory" | "Page.getFrameTree" | "Page.close" | "Runtime.enable" | "Runtime.disable"
3705 | "Runtime.runIfWaitingForDebugger" | "Network.enable" | "Network.disable" | "Log.enable" | "Log.disable"
3706 | "Fetch.enable" | "Fetch.disable" | "Fetch.continueRequest" | "Fetch.failRequest" | "Fetch.fulfillRequest"
3707 | "Fetch.continueResponse" | "Fetch.continueWithAuth" => true,
3708 "Runtime.evaluate" => {
3709 matches!(params.get("expression").and_then(Value::as_str), Some("document.readyState" | "0"))
3710 && params.as_object().is_some_and(|object| object.keys().all(|key| matches!(key.as_str(), "expression" | "returnByValue")))
3711 }
3712 _ => false,
3713 }
3714}
3715
3716fn content_command(method: &str, params: &Value) -> Option<bool> {
3717 if method == "Runtime.evaluate"
3718 && matches!(params.get("expression").and_then(Value::as_str), Some("document.readyState" | "0" | "document.readyState === 'complete' ? location.href : ''"))
3719 && params.as_object().is_some_and(|object| object.keys().all(|key| matches!(key.as_str(), "expression" | "returnByValue")))
3720 { return Some(false) }
3721 if method == "Runtime.compileScript" && params.get("persistScript").and_then(Value::as_bool) != Some(false) { return None }
3722 if method == "Page.addScriptToEvaluateOnNewDocument" {
3723 return Some(params.get("runImmediately").is_some_and(|value| value != &json!(false)));
3724 }
3725 match method {
3726 "Debugger.setInstrumentationBreakpoint" | "Emulation.setVirtualTimePolicy" | "Overlay.setShowPaintRects"
3727 | "Page.addScriptToEvaluateOnLoad" | "Security.setIgnoreCertificateErrors" | "Inspector.enable" => None,
3728 "Runtime.evaluate" | "Runtime.callFunctionOn" | "Runtime.awaitPromise" | "Runtime.compileScript" | "Runtime.runScript"
3729 | "Runtime.getProperties" | "Runtime.getExceptionDetails" | "Runtime.queryObjects" | "Runtime.globalLexicalScopeNames" | "Runtime.getHeapUsage" | "Runtime.discardConsoleEntries"
3730 | "DOM.collectClassNamesFromSubtree" | "DOM.copyTo" | "DOM.describeNode" | "DOM.focus" | "DOM.getAttributes" | "DOM.getBoxModel" | "DOM.getContentQuads"
3731 | "DOM.getDocument" | "DOM.getFlattenedDocument" | "DOM.getNodesForSubtreeByStyle" | "DOM.getNodeForLocation" | "DOM.getOuterHTML" | "DOM.getRelayoutBoundary" | "DOM.getSearchResults"
3732 | "DOM.hideHighlight" | "DOM.highlightNode" | "DOM.highlightRect" | "DOM.markUndoableState" | "DOM.moveTo" | "DOM.performSearch" | "DOM.pushNodeByPathToFrontend"
3733 | "DOM.pushNodesByBackendIdsToFrontend" | "DOM.querySelector" | "DOM.querySelectorAll" | "DOM.redo" | "DOM.removeAttribute" | "DOM.removeNode" | "DOM.requestChildNodes" | "DOM.requestNode"
3734 | "DOM.resolveNode" | "DOM.setAttributeValue" | "DOM.setAttributesAsText" | "DOM.setFileInputFiles" | "DOM.setNodeName" | "DOM.setNodeValue" | "DOM.setOuterHTML" | "DOM.undo"
3735 | "DOM.getFrameOwner" | "DOM.getNodeStackTraces" | "DOM.getFileInfo" | "DOM.getDetachedDomNodes" | "DOM.getTopLayerElements" | "DOM.discardSearchResults" | "DOM.forceShowPopover"
3736 | "DOMSnapshot.captureSnapshot" | "DOMSnapshot.getSnapshot" | "DOMDebugger.getEventListeners"
3737 | "Input.dispatchDragEvent" | "Input.dispatchKeyEvent" | "Input.dispatchMouseEvent" | "Input.dispatchTouchEvent" | "Input.emulateTouchFromMouseEvent" | "Input.insertText"
3738 | "Input.synthesizePinchGesture" | "Input.synthesizeScrollGesture" | "Input.synthesizeTapGesture" | "Input.cancelDragging" | "Input.imeSetComposition"
3739 | "Accessibility.getFullAXTree" | "Accessibility.getPartialAXTree" | "Accessibility.getRootAXNode" | "Accessibility.getAXNodeAndAncestors" | "Accessibility.getChildAXNodes" | "Accessibility.queryAXTree"
3740 | "Page.captureScreenshot" | "Page.captureSnapshot" | "Page.printToPDF" | "Page.getResourceContent" | "Page.searchInResource" | "Page.getLayoutMetrics" | "Page.getPermissionsPolicyState" | "Page.setDocumentContent"
3741 | "Page.getAppManifest" | "Page.getInstallabilityErrors" | "Page.getAppId" | "Page.startScreencast" | "Page.generateTestReport"
3742 | "Network.getResponseBody" | "Network.getRequestPostData" | "Network.getResponseBodyForInterception" | "Network.searchInResponseBody" | "Network.loadNetworkResource"
3743 | "Network.streamResourceContent" | "Network.takeResponseBodyForInterceptionAsStream" | "Network.replayXHR" | "Audits.getEncodedResponse"
3744 | "Fetch.getResponseBody" | "Fetch.takeResponseBodyAsStream" | "IO.read" | "IO.close" | "IO.resolveBlob"
3745 | "Debugger.evaluateOnCallFrame" | "Debugger.getScriptSource" | "Debugger.searchInContent" | "Debugger.setScriptSource" | "Debugger.setVariableValue" | "Debugger.restartFrame" | "Debugger.stepInto" | "Debugger.stepOut" | "Debugger.stepOver"
3746 | "Debugger.getWasmBytecode" | "Debugger.disassembleWasmModule" | "Debugger.nextWasmDisassemblyChunk" | "Debugger.getPossibleBreakpoints" | "Debugger.pause"
3747 | "HeapProfiler.takeHeapSnapshot" | "HeapProfiler.getHeapObjectId" | "HeapProfiler.getObjectByHeapObjectId" | "HeapProfiler.getSamplingProfile" | "HeapProfiler.stopSampling" | "HeapProfiler.stopTrackingHeapObjects"
3748 | "DOMStorage.getDOMStorageItems" | "DOMStorage.setDOMStorageItem" | "DOMStorage.removeDOMStorageItem" | "DOMStorage.clear"
3749 | "Storage.clearDataForOrigin" | "Storage.clearDataForStorageKey" | "Storage.getUsageAndQuota" | "Storage.getStorageKeyForFrame"
3750 | "CacheStorage.requestCacheNames" | "CacheStorage.requestEntries" | "CacheStorage.requestCachedResponse" | "CacheStorage.deleteCache" | "CacheStorage.deleteEntry"
3751 | "IndexedDB.requestDatabaseNames" | "IndexedDB.requestDatabase" | "IndexedDB.requestData" | "IndexedDB.deleteDatabase" | "IndexedDB.deleteObjectStoreEntries" | "IndexedDB.clearObjectStore" | "IndexedDB.getMetadata"
3752 | "FileSystem.getDirectory"
3753 | "Target.sendMessageToTarget" | "Target.exposeDevToolsProtocol"
3754 | "Profiler.stop" | "Profiler.takePreciseCoverage" | "Profiler.getBestEffortCoverage" | "CSS.getStyleSheetText" | "CSS.getComputedStyleForNode" | "CSS.getMatchedStylesForNode" | "CSS.getInlineStylesForNode"
3755 | "CSS.setStyleSheetText" | "CSS.setStyleTexts" | "CSS.setEffectivePropertyValueForNode" | "CSS.getMediaQueries" | "CSS.getPlatformFontsForNode" => Some(true),
3756 "Page.enable" | "Page.disable" | "Page.navigate" | "Page.reload" | "Page.stopLoading" | "Page.navigateToHistoryEntry" | "Page.close" | "Page.bringToFront"
3757 | "Page.getFrameTree" | "Page.getResourceTree" | "Page.getNavigationHistory" | "Page.handleJavaScriptDialog" | "Page.createIsolatedWorld" | "Page.setLifecycleEventsEnabled" | "Page.removeScriptToEvaluateOnNewDocument"
3758 | "Page.setBypassCSP" | "Page.setWebLifecycleState" | "Page.setInterceptFileChooserDialog" | "Page.stopScreencast" | "Page.screencastFrameAck" | "Page.setDownloadBehavior" | "Page.getCookies" | "Page.deleteCookie"
3759 | "Runtime.enable" | "Runtime.disable" | "Runtime.runIfWaitingForDebugger" | "Runtime.addBinding" | "Runtime.removeBinding" | "Runtime.releaseObject" | "Runtime.releaseObjectGroup" | "Runtime.getIsolateId"
3760 | "Runtime.setAsyncCallStackDepth" | "Runtime.setCustomObjectFormatterEnabled" | "Runtime.setMaxCallStackSizeToCapture" | "Runtime.terminateExecution"
3761 | "DOM.enable" | "DOM.disable" | "DOM.setNodeStackTracesEnabled" | "DOMSnapshot.enable" | "DOMSnapshot.disable" | "Accessibility.enable" | "Accessibility.disable" | "CSS.enable" | "CSS.disable"
3762 | "Input.setIgnoreInputEvents" | "Input.setInterceptDrags" | "Fetch.enable" | "Fetch.disable" | "Fetch.continueRequest" | "Fetch.fulfillRequest" | "Fetch.failRequest" | "Fetch.continueWithAuth" | "Fetch.continueResponse"
3763 | "Network.enable" | "Network.disable" | "Network.setBypassServiceWorker" | "Network.setCacheDisabled" | "Network.setExtraHTTPHeaders" | "Network.setRequestInterception" | "Network.continueInterceptedRequest"
3764 | "Network.setUserAgentOverride" | "Network.setBlockedURLs" | "Network.emulateNetworkConditions" | "Network.emulateNetworkConditionsByRule" | "Network.overrideNetworkState"
3765 | "Network.getCookies" | "Network.getAllCookies" | "Network.setCookie" | "Network.setCookies" | "Network.deleteCookies" | "Network.clearBrowserCookies" | "Network.clearBrowserCache" | "Network.setAcceptedEncodings" | "Network.clearAcceptedEncodingsOverride"
3766 | "Target.attachToTarget" | "Target.attachToBrowserTarget" | "Target.detachFromTarget" | "Target.createTarget" | "Target.closeTarget" | "Target.activateTarget" | "Target.getTargets" | "Target.getTargetInfo"
3767 | "Target.setDiscoverTargets" | "Target.setAutoAttach" | "Target.autoAttachRelated" | "Target.createBrowserContext" | "Target.disposeBrowserContext" | "Target.getBrowserContexts"
3768 | "Browser.getVersion" | "Browser.close" | "Browser.getWindowForTarget" | "Browser.getWindowBounds" | "Browser.setWindowBounds" | "Browser.grantPermissions" | "Browser.resetPermissions" | "Browser.setPermission"
3769 | "Browser.setDownloadBehavior" | "Browser.cancelDownload" | "Browser.getBrowserCommandLine" | "Browser.getHistograms" | "Browser.getHistogram" | "Browser.executeBrowserCommand" | "Browser.setDockTile"
3770 | "Emulation.setCPUThrottlingRate" | "Emulation.setFocusEmulationEnabled" | "Emulation.setDeviceMetricsOverride" | "Emulation.clearDeviceMetricsOverride" | "Emulation.setTouchEmulationEnabled"
3771 | "Emulation.setUserAgentOverride" | "Emulation.setLocaleOverride" | "Emulation.setTimezoneOverride" | "Emulation.setEmulatedMedia" | "Emulation.setGeolocationOverride" | "Emulation.clearGeolocationOverride"
3772 | "Emulation.setScriptExecutionDisabled" | "Emulation.setEmulatedVisionDeficiency" | "Emulation.setEmitTouchEventsForMouse" | "Emulation.setScrollbarsHidden" | "Emulation.setDefaultBackgroundColorOverride"
3773 | "Debugger.enable" | "Debugger.disable" | "Debugger.resume" | "Debugger.setBreakpoint" | "Debugger.setBreakpointByUrl" | "Debugger.removeBreakpoint"
3774 | "Debugger.setBreakpointsActive" | "Debugger.setPauseOnExceptions" | "Debugger.setSkipAllPauses" | "Debugger.setAsyncCallStackDepth" | "Debugger.setBlackboxPatterns" | "Debugger.setBlackboxedRanges"
3775 | "Profiler.enable" | "Profiler.disable" | "Profiler.start" | "Profiler.startPreciseCoverage" | "Profiler.stopPreciseCoverage" | "Profiler.setSamplingInterval"
3776 | "HeapProfiler.enable" | "HeapProfiler.disable" | "HeapProfiler.startSampling" | "HeapProfiler.startTrackingHeapObjects" | "HeapProfiler.collectGarbage"
3777 | "Log.enable" | "Log.disable" | "Log.clear" | "Log.startViolationsReport" | "Log.stopViolationsReport" | "Audits.enable" | "Audits.disable"
3778 | "DOMStorage.enable" | "DOMStorage.disable"
3779 | "Storage.getCookies" | "Storage.setCookies" | "Storage.clearCookies"
3780 | "IndexedDB.enable" | "IndexedDB.disable" | "IndexedDB.releaseObject"
3781 | "ServiceWorker.enable" | "ServiceWorker.disable" | "ServiceWorker.unregister" | "ServiceWorker.startWorker" | "ServiceWorker.stopWorker" | "ServiceWorker.stopAllWorkers" | "ServiceWorker.updateRegistration"
3782 | "PWA.install" | "PWA.launch" | "PWA.uninstall" | "Extensions.loadUnpacked" | "Extensions.uninstall" | "Extensions.getExtensions" | "SystemInfo.getInfo" | "SystemInfo.getProcessInfo"
3783 | "Tracing.start" | "Tracing.end" | "Tracing.getCategories" | "Tracing.recordClockSyncMarker" | "Tracing.requestMemoryDump" | "Slicc.action" => Some(false),
3784 _ => None,
3785 }
3786}
3787
3788fn storage_content(method: &str) -> bool {
3789 matches!(method, "Storage.getStorageKeyForFrame" | "FileSystem.getDirectory")
3790 || ["DOMStorage.", "IndexedDB.", "CacheStorage."].iter().any(|prefix| method.starts_with(prefix))
3791 || matches!(method, "Storage.clearDataForOrigin" | "Storage.clearDataForStorageKey" | "Storage.getUsageAndQuota")
3792}
3793
3794fn harmless(method: &str, params: &Value) -> bool {
3795 match method {
3796 "Fetch.disable" => true,
3797 "Fetch.continueRequest" => params
3798 .as_object()
3799 .is_some_and(|object| object.keys().all(|key| key == "requestId")),
3800 _ => false,
3801 }
3802}
3803
3804fn probe_is_settled(outcome: Result<Option<&str>, &str>) -> Result<bool, String> {
3805 match outcome {
3806 Ok(Some("loading")) | Ok(Some("interactive")) => Ok(false),
3807 Ok(_) => Ok(true),
3808 Err(err)
3809 if err.starts_with("Runtime.evaluate timed out")
3810 || err.starts_with("Runtime.evaluate:") =>
3811 {
3812 Ok(true)
3813 }
3814 Err(err) => Err(err.to_string()),
3815 }
3816}
3817
3818fn navigation_settled(events: &[Value], session_id: &str, from: usize) -> bool {
3819 let mut committed = false;
3820 for event in events.iter().skip(from) {
3821 if !committed && is_commit(event, session_id) {
3822 committed = true;
3823 continue;
3824 }
3825 if committed && is_load(event, session_id) {
3826 return true;
3827 }
3828 }
3829 false
3830}
3831
3832fn is_commit(event: &Value, session_id: &str) -> bool {
3833 if event.get("sessionId").and_then(|value| value.as_str()) != Some(session_id) {
3834 return false;
3835 }
3836 if event.get("method").and_then(|value| value.as_str()) != Some("Page.frameNavigated") {
3837 return false;
3838 }
3839 let url = event
3840 .pointer("/params/frame/url")
3841 .and_then(|value| value.as_str())
3842 .unwrap_or("");
3843 !url.is_empty() && !url.eq_ignore_ascii_case("about:blank")
3844}
3845
3846fn is_load(event: &Value, session_id: &str) -> bool {
3847 if event.get("sessionId").and_then(|v| v.as_str()) != Some(session_id) {
3848 return false;
3849 }
3850 match event.get("method").and_then(|v| v.as_str()) {
3851 Some("Page.loadEventFired") => true,
3852 Some("Page.lifecycleEvent") => {
3853 event.pointer("/params/name").and_then(|v| v.as_str()) == Some("load")
3854 }
3855 _ => false,
3856 }
3857}
3858
3859pub fn attach(cdp: &mut Cdp, target_id: &str) -> Result<String, String> {
3860 let result = cdp.call(
3861 "Target.attachToTarget",
3862 json!({ "targetId": target_id, "flatten": true }),
3863 None,
3864 )?;
3865 result
3866 .get("sessionId")
3867 .and_then(|v| v.as_str())
3868 .map(str::to_string)
3869 .ok_or_else(|| "Target.attachToTarget returned no sessionId\n".to_string())
3870}
3871
3872pub fn detach(cdp: &mut Cdp, session_id: &str) {
3873 let _ = cdp.call(
3874 "Target.detachFromTarget",
3875 json!({ "sessionId": session_id }),
3876 None,
3877 );
3878}
3879
3880#[derive(Clone, Debug, Default)]
3881pub struct ActionNote {
3882 pub kind: String,
3883 pub tab: Option<String>,
3884 pub target: Option<String>,
3885 pub url: Option<String>,
3886 pub key: Option<String>,
3887 pub length: Option<u64>,
3888 pub method: Option<String>,
3889 pub agent: Option<String>,
3890}
3891
3892impl ActionNote {
3893 pub fn start_params(&self) -> Value {
3894 let mut params = serde_json::Map::new();
3895 params.insert("phase".into(), json!("start"));
3896 params.insert("kind".into(), json!(self.kind));
3897 insert_text(&mut params, "tab", self.tab.as_deref());
3898 insert_text(&mut params, "target", self.target.as_deref());
3899 if let Some(url) = self.url.as_deref().and_then(public_url) {
3900 params.insert("url".into(), json!(url));
3901 }
3902 insert_text(&mut params, "key", self.key.as_deref());
3903 if let Some(length) = self.length {
3904 params.insert("length".into(), json!(length));
3905 }
3906 insert_text(&mut params, "method", self.method.as_deref());
3907 insert_text(&mut params, "agent", self.agent.as_deref());
3908 Value::Object(params)
3909 }
3910
3911 pub fn end_params(&self, ok: bool, stderr: &str) -> Value {
3912 let mut params = serde_json::Map::new();
3913 params.insert("phase".into(), json!("end"));
3914 params.insert("ok".into(), json!(ok));
3915 if !ok {
3916 if let Some(error) = action_error(&self.kind, stderr) {
3917 params.insert("error".into(), json!(error));
3918 }
3919 }
3920 insert_text(&mut params, "tab", self.tab.as_deref());
3921 Value::Object(params)
3922 }
3923}
3924
3925fn insert_text(params: &mut serde_json::Map<String, Value>, key: &str, value: Option<&str>) {
3926 if let Some(value) = value.filter(|value| !value.is_empty()) {
3927 params.insert(key.to_string(), json!(value));
3928 }
3929}
3930
3931pub fn first_stderr_line(stderr: &str) -> Option<&str> {
3932 stderr.lines().next().filter(|line| !line.is_empty())
3933}
3934
3935fn public_url(raw: &str) -> Option<String> {
3936 let raw = raw.trim();
3937 if raw.is_empty() {
3938 return None;
3939 }
3940 let without_fragment = raw.split_once('#').map(|(head, _)| head).unwrap_or(raw);
3941 let without_query = without_fragment
3942 .split_once('?')
3943 .map(|(head, _)| head)
3944 .unwrap_or(without_fragment);
3945 if without_query.is_empty() {
3946 return None;
3947 }
3948 if is_about_blank(without_query) {
3949 return Some("about:blank".to_string());
3950 }
3951 if let Some(rest) = without_query.strip_prefix("//") {
3952 let (host, path) = host_and_path(rest)?;
3953 return Some(format!("//{host}{path}"));
3954 }
3955 let (scheme, rest) = without_query.split_once(':')?;
3956 if !is_scheme(scheme) || !scheme_allowed(scheme) {
3957 return None;
3958 }
3959 let scheme = scheme.to_ascii_lowercase();
3960 let Some(authority) = rest.strip_prefix("//") else {
3961 return Some(format!("{scheme}:"));
3962 };
3963 let (host, path) = host_and_path(authority)?;
3964 Some(format!("{scheme}://{host}{path}"))
3965}
3966
3967fn scheme_allowed(scheme: &str) -> bool {
3968 matches!(
3969 scheme.to_ascii_lowercase().as_str(),
3970 "http"
3971 | "https"
3972 | "ws"
3973 | "wss"
3974 | "file"
3975 | "data"
3976 | "javascript"
3977 | "blob"
3978 | "about"
3979 | "chrome"
3980 | "chrome-extension"
3981 | "view-source"
3982 | "ftp"
3983 )
3984}
3985
3986fn is_about_blank(url: &str) -> bool {
3987 let Some((scheme, rest)) = url.split_once(':') else {
3988 return false;
3989 };
3990 scheme.eq_ignore_ascii_case("about") && rest.eq_ignore_ascii_case("blank")
3991}
3992
3993fn is_scheme(scheme: &str) -> bool {
3994 let mut chars = scheme.chars();
3995 match chars.next() {
3996 Some(ch) if ch.is_ascii_alphabetic() => {}
3997 _ => return false,
3998 }
3999 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.')
4000}
4001
4002fn host_and_path(rest: &str) -> Option<(&str, &str)> {
4003 let (authority, path) = match rest.find('/') {
4004 Some(index) => (&rest[..index], &rest[index..]),
4005 None => (rest, ""),
4006 };
4007 let host = match authority.rfind('@') {
4008 Some(index) => &authority[index + 1..],
4009 None => authority,
4010 };
4011 if host.is_empty() { None } else { Some((host, path)) }
4012}
4013
4014fn action_error(kind: &str, stderr: &str) -> Option<&'static str> {
4015 if kind == "eval" {
4016 return None;
4017 }
4018 let line = first_stderr_line(stderr).unwrap_or("").to_ascii_lowercase();
4019 if line.contains("timed out") {
4020 return Some("timeout");
4021 }
4022 if line.contains("websocket closed")
4023 || line.contains("websocket connection closed")
4024 || line.contains("websocket write failed")
4025 || line.contains("websocket read failed")
4026 || line.contains("connection reset")
4027 || line.contains("broken pipe")
4028 {
4029 return Some("connection lost");
4030 }
4031 if line.contains("no snapshot") || line.contains("likely stale") {
4032 return Some("no snapshot");
4033 }
4034 if line.contains("unknown ref")
4035 || line.contains("element not found")
4036 || line.contains("not an element ref")
4037 || line.contains("could not resolve")
4038 || line.contains("no backend node")
4039 {
4040 return Some("element not found");
4041 }
4042 if line.contains("no open tab is on") {
4043 return Some("not allowed");
4044 }
4045 if line.contains("unknown tab")
4046 || line.contains("--tab")
4047 || line.contains("no open tabs")
4048 || line.contains("explicit tab")
4049 {
4050 return Some("unknown tab");
4051 }
4052 if line.starts_with("navigate:") || line.contains("page.navigate") {
4053 return Some("navigation failed");
4054 }
4055 Some("failed")
4056}
4057
4058pub fn slicc_agent() -> Option<String> {
4059 std::env::var("SLICC_AGENT")
4060 .ok()
4061 .filter(|value| !value.is_empty())
4062}
4063
4064pub fn open(start: crate::connect::Start, runtime: Option<&str>) -> Result<Cdp, String> {
4065 let socket = match start {
4066 crate::connect::Start::Direct(url) => url,
4067 crate::connect::Start::Discover(url) => {
4068 let response = match crate::net::http_get(&url) {
4069 Ok(response) => response,
4070 Err(message) => {
4071 if message.starts_with("HTTP ") || message.contains("does not launch Chrome") {
4072 return Err(message);
4073 }
4074 return Err(format!(
4075 "{message}This CLI does not launch Chrome. Pass --cdp or set SLICC_CDP_URL.\n"
4076 ));
4077 }
4078 };
4079 let body = String::from_utf8_lossy(&response.body).to_string();
4080 crate::connect::websocket_url_from_version(&body)?
4081 }
4082 };
4083 let socket = crate::connect::append_runtime(&socket, runtime);
4084 Cdp::connect(&socket)
4085}
4086
4087include!("dialog.rs");
4088include!("front.rs");
4089include!("window.rs");
4090
4091#[cfg(test)]
4092mod tests {
4093 use super::{first_stderr_line, probe_is_settled, ActionNote, Cdp, Transport};
4094 use serde_json::{json, Value};
4095 use std::collections::VecDeque;
4096 use std::time::{Duration, Instant};
4097
4098 include!("persistent_test.rs");
4099 include!("redirect_test.rs");
4100 include!("redirect_matrix_test.rs");
4101 include!("redirect_race_test.rs");
4102 include!("redirect_terminal_test.rs");
4103 include!("redirect_terminal_guard_test.rs");
4104 include!("redirect_c2_carrier_test.rs");
4105 include!("redirect_grok_test.rs");
4106 include!("redirect_scope_review_test.rs");
4107 include!("ordinary_network_review_test.rs");
4108 include!("pause_metadata_recovery_test.rs");
4109 include!("navigation_echo_test.rs");
4110 include!("refresh_review_test.rs");
4111 include!("refresh_forms_review_test.rs");
4112 include!("redirect_target_taint_test.rs");
4113 include!("redirect_resource_control_test.rs");
4114 include!("navigation_queue_test.rs");
4115 include!("redirect_fragment_test.rs");
4116 include!("redirect_review_test.rs");
4117 include!("redirect_relation_test.rs");
4118 include!("redirect_userinfo_test.rs");
4119 include!("guard_attach_tree_test.rs");
4120 include!("front_test.rs");
4121 include!("window_test.rs");
4122 include!("settled_test.rs");
4123 include!("renderer_crash_test.rs");
4124 include!("client_id_test.rs");
4125 include!("late_reply_test.rs");
4126 include!("close_latch_test.rs");
4127
4128 #[test]
4129 fn queued_raw_replies_keep_pinned_execution_and_guard_denial_semantics() {
4130 let mut cdp = pending(|_| Vec::new());
4131 cdp.blocked.insert("S".into());
4132 cdp.call_replies.insert(700, json!({"id":700,"error":{"message":"Cannot find context with specified id"}}));
4133 assert_eq!(cdp.raw_reply("Runtime.evaluate", 700, Some("S"), Duration::ZERO, true).unwrap_err(), cdp.refusal());
4134 cdp.call_replies.insert(701, json!({"id":701,"result":{"result":{"value":"ordinary-executed-value"}}}));
4135 assert_eq!(cdp.raw_reply("Runtime.evaluate", 701, Some("S"), Duration::ZERO, true).unwrap()["result"]["value"], "ordinary-executed-value");
4136 assert_eq!(cdp.raw_reply("Runtime.evaluate", 702, Some("S"), Duration::ZERO, true).unwrap_err(), cdp.refusal());
4137 assert!(cdp.call_replies.is_empty());
4138 }
4139
4140 struct Pending {
4141 inbox: VecDeque<String>,
4142 answer: fn(&Value) -> Vec<Value>,
4143 }
4144
4145 impl Transport for Pending {
4146 fn send_text(&mut self, text: &str) -> Result<(), String> {
4147 let message: Value = serde_json::from_str(text).unwrap();
4148 for out in (self.answer)(&message) {
4149 self.inbox.push_back(out.to_string());
4150 }
4151 Ok(())
4152 }
4153
4154 fn recv_text(&mut self, _deadline: Instant) -> Result<Option<String>, String> {
4155 Ok(self.inbox.pop_front())
4156 }
4157 }
4158
4159 fn pending(answer: fn(&Value) -> Vec<Value>) -> Cdp {
4160 let mut cdp = Cdp::from_transport(Box::new(Pending {
4161 inbox: VecDeque::new(),
4162 answer,
4163 }));
4164 cdp.fixture_session("S");
4165 cdp
4166 }
4167
4168 struct Cleanup {
4169 inner: Pending,
4170 destroy_after: Option<Duration>,
4171 destroyed_at: Option<Instant>,
4172 closed: bool,
4173 }
4174
4175 impl Transport for Cleanup {
4176 fn send_text(&mut self, text: &str) -> Result<(), String> {
4177 self.inner.send_text(text)?;
4178 let message: Value = serde_json::from_str(text).unwrap();
4179 if message["method"] == "Target.closeTarget" {
4180 self.closed = true;
4181 self.destroyed_at = self.destroy_after.map(|delay| Instant::now() + delay);
4182 }
4183 Ok(())
4184 }
4185
4186 fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
4187 if let Some(message) = self.inner.inbox.pop_front() {
4188 return Ok(Some(message));
4189 }
4190 if let Some(at) = self.destroyed_at.take() {
4191 if at <= deadline {
4192 std::thread::sleep(at.saturating_duration_since(Instant::now()));
4193 return Ok(Some(json!({
4194 "method": "Target.targetDestroyed", "params": { "targetId": "T" }
4195 }).to_string()));
4196 }
4197 self.destroyed_at = Some(at);
4198 }
4199 if self.closed {
4200 std::thread::sleep(deadline.saturating_duration_since(Instant::now()));
4201 }
4202 Ok(None)
4203 }
4204 }
4205
4206 fn error_page_client() -> Cdp {
4207 error_page_client_url("https://seven.sliccy.ai/path?secret#fragment")
4208 }
4209
4210 fn error_page_client_url(url: &str) -> Cdp {
4211 let mut cdp = url_backend(url);
4212 cdp.session_targets.insert("S".into(), "T".into());
4213 cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://example.test/"}}}));
4214 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://example.test/","securityOrigin":"https://example.test"}}}));
4215 cdp
4216 }
4217
4218 fn observe_document(cdp: &mut Cdp) {
4219 struct Observed {
4220 inner: Box<dyn Transport>,
4221 frame: Value,
4222 inbox: VecDeque<String>,
4223 }
4224 impl Transport for Observed {
4225 fn send_text(&mut self, text: &str) -> Result<(), String> {
4226 let call: Value = serde_json::from_str(text).unwrap();
4227 if call["method"] == "Target.getTargets" {
4228 self.inbox.push_back(json!({"id":call["id"],"result":{"targetInfos":[{"targetId":"T","type":"page","url":self.frame["url"]}]}}).to_string());
4229 Ok(())
4230 } else if call["method"] == "Page.getFrameTree" {
4231 self.inbox.push_back(json!({"id":call["id"],"result":{"frameTree":{"frame":self.frame}}}).to_string());
4232 Ok(())
4233 } else if call["method"] == "Runtime.enable" {
4234 self.inbox.push_back(json!({"method":"Runtime.executionContextCreated","sessionId":call["sessionId"],"params":{"context":{"id":1,"uniqueId":"observed-context","origin":self.frame["securityOrigin"],"name":"","auxData":{"frameId":self.frame["id"],"isDefault":true}}}}).to_string());
4235 self.inbox.push_back(json!({"id":call["id"],"result":{}}).to_string());
4236 Ok(())
4237 } else {
4238 self.inner.send_text(text)
4239 }
4240 }
4241 fn recv_text(&mut self, deadline: Instant) -> Result<Option<String>, String> {
4242 match self.inbox.pop_front() {
4243 Some(text) => Ok(Some(text)),
4244 None => self.inner.recv_text(deadline),
4245 }
4246 }
4247 }
4248 let doc = &cdp.documents["T"];
4249 let frame = json!({"id":doc.frame_id,"loaderId":"L","url":doc.url,"securityOrigin":doc.origin,"unreachableUrl":doc.unreachable_url});
4250 let inner = std::mem::replace(&mut cdp.ws, Box::new(Pending { inbox:VecDeque::new(), answer: |_| Vec::new() }));
4251 cdp.ws = Box::new(Observed { inner, frame, inbox:VecDeque::new() });
4252 cdp.guards.insert("T".into(), "G".into());
4253 cdp.guard_sessions.insert("G".into());
4254 }
4255
4256 fn error_commit(cdp: &mut Cdp, unreachable: &str, origin: &str) {
4257 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"chrome-error://chromewebdata/","securityOrigin":origin,"unreachableUrl":unreachable}}}));
4258 }
4259
4260 #[test]
4261 fn protected_error_page_retains_allowed_recovery_and_redacts_discovery() {
4262 let url = "https://seven.sliccy.ai/path?secret#fragment";
4263 let mut cdp = error_page_client();
4264 error_commit(&mut cdp, url, "://");
4265 let info = json!({"targetId":"T","type":"page","url":url,"title":"SECRET"});
4266 for method in ["Target.targetCreated", "Target.targetInfoChanged", "Target.attachedToTarget"] {
4267 cdp.absorb(json!({"method":method,"params":{"sessionId":"A","targetInfo":info}}));
4268 let event = cdp.events().last().unwrap();
4269 assert_eq!(event["params"]["targetInfo"]["url"], "https://seven.sliccy.ai", "{method}");
4270 assert_eq!(event["params"]["targetInfo"]["title"], "https://seven.sliccy.ai", "{method}");
4271 }
4272 assert!(!cdp.target_protected("T", false).unwrap());
4273 assert!(!cdp.session_blocked("S"));
4274 let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
4275 assert_eq!(listed["targetInfos"][0]["url"], "https://seven.sliccy.ai");
4276 assert_eq!(listed["targetInfos"][0]["title"], "https://seven.sliccy.ai");
4277 let found = cdp.call("Target.getTargetInfo", json!({"targetId":"T"}), None).unwrap();
4278 assert_eq!(found["targetInfo"]["url"], "https://seven.sliccy.ai");
4279 assert_eq!(found["targetInfo"]["title"], "https://seven.sliccy.ai");
4280 observe_document(&mut cdp);
4281 for method in ["Page.reload", "Page.navigate", "Target.closeTarget"] {
4282 let (params, session) = if method == "Target.closeTarget" {
4283 (json!({"targetId":"T"}), None)
4284 } else {
4285 (json!({"url":"about:blank"}), Some("S"))
4286 };
4287 assert!(cdp.call(method, params, session).is_ok(), "{method}");
4288 }
4289 assert!(cdp.call("Page.navigate", json!({"url":url}), Some("S")).is_err());
4290 }
4291
4292 #[test]
4293 fn protected_error_page_pending_report_does_not_change_previous_decision() {
4294 let mut cdp = error_page_client();
4295 cdp.absorb(json!({"method":"Page.frameStartedNavigating","sessionId":"S","params":{"frameId":"F","url":"https://seven.sliccy.ai/path?secret#fragment"}}));
4296 cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://seven.sliccy.ai/path?secret#fragment"}}}));
4297 assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.title"}), Some("S")).is_err());
4298 error_commit(&mut cdp, "https://seven.sliccy.ai/path?secret#fragment", "://");
4299 assert!(!cdp.target_protected("T", false).unwrap());
4300 observe_document(&mut cdp);
4301 assert!(cdp.call("Page.reload", json!({}), Some("S")).is_ok());
4302 }
4303
4304 #[test]
4305 fn protected_error_page_unknown_mismatch_origin_and_inflight_fail_closed() {
4306 let url = "https://seven.sliccy.ai/path?secret#fragment";
4307 for (unreachable, origin, navigating) in [(url, "://", true), ("", "://", false), ("https://seven.sliccy.ai/other", "://", false), (url, "null", false), (url, "https://example.test", false)] {
4308 let mut cdp = error_page_client();
4309 error_commit(&mut cdp, unreachable, origin);
4310 if navigating {
4311 cdp.absorb(json!({"method":"Page.frameStartedNavigating","sessionId":"S","params":{"frameId":"F","url":url}}));
4312 }
4313 assert!(cdp.target_protected("T", false).unwrap(), "{unreachable} {origin} {navigating}");
4314 assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.title"}), Some("S")).is_err());
4315 }
4316 let mut cdp = url_backend(url);
4317 cdp.session_targets.insert("S".into(), "T".into());
4318 error_commit(&mut cdp, url, "://");
4319 assert!(cdp.target_protected("T", false).unwrap());
4320 }
4321
4322 #[test]
4323 fn protected_document_restore_and_error_page_sticky_lock() {
4324 let mut cdp = error_page_client();
4325 let url = "https://seven.sliccy.ai/path?secret#fragment";
4326 error_commit(&mut cdp, url, "://");
4327 assert!(!cdp.target_protected("T", false).unwrap());
4328 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"type":"BackForwardCacheRestore","frame":{"id":"F","url":"https://seven.sliccy.ai/real-document","securityOrigin":"https://seven.sliccy.ai"}}}));
4329 assert!(cdp.is_protected_target("T"));
4330 error_commit(&mut cdp, url, "://");
4331 for method in ["Runtime.evaluate", "Page.reload", "Page.navigate", "Target.closeTarget", "Target.attachToTarget"] {
4332 let params = json!({"targetId":"T","url":"about:blank","flatten":true});
4333 assert!(cdp.call(method, params, Some("S")).is_err(), "{method}");
4334 }
4335 }
4336
4337 #[test]
4338 fn protected_error_page_subframes_and_same_document_do_not_replace_evidence() {
4339 let mut cdp = error_page_client();
4340 let url = "https://seven.sliccy.ai/path?secret#fragment";
4341 error_commit(&mut cdp, url, "://");
4342 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"CHILD","parentId":"F","url":"https://seven.sliccy.ai/child","securityOrigin":"https://seven.sliccy.ai"}}}));
4343 cdp.absorb(json!({"method":"Page.frameStartedNavigating","sessionId":"S","params":{"frameId":"F","navigationType":"sameDocument","url":url}}));
4344 assert!(!cdp.target_protected("T", false).unwrap());
4345 assert!(cdp.frame_protected("CHILD"));
4346 observe_document(&mut cdp);
4347 assert!(cdp.call("Page.reload", json!({}), Some("S")).is_ok());
4348 }
4349
4350 #[test]
4351 fn protected_error_page_real_commit_and_guard_loss_fail_closed() {
4352 let url = "https://seven.sliccy.ai/path?secret#fragment";
4353 for origin in ["https://seven.sliccy.ai", "https://example.test"] {
4354 let mut cdp = error_page_client();
4355 error_commit(&mut cdp, url, "://");
4356 assert!(!cdp.target_protected("T", false).unwrap());
4357 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":url,"securityOrigin":origin}}}));
4358 assert!(cdp.is_protected_target("T"));
4359 assert!(cdp.session_blocked("S"));
4360 }
4361 let mut cdp = error_page_client();
4362 error_commit(&mut cdp, url, "://");
4363 cdp.guards.insert("T".into(), "G".into());
4364 cdp.guard_sessions.insert("G".into());
4365 cdp.absorb(json!({"method":"Target.detachedFromTarget","params":{"sessionId":"G"}}));
4366 assert!(cdp.target_protected("T", false).unwrap());
4367 assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.title"}), Some("S")).is_err());
4368 }
4369
4370 #[test]
4371 fn protected_error_page_ordinary_navigation_and_fresh_dns_error_remain_drivable() {
4372 let mut cdp = url_backend("https://example.test/next");
4373 cdp.session_targets.insert("S".into(), "T".into());
4374 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://example.test/","securityOrigin":"https://example.test"}}}));
4375 cdp.absorb(json!({"method":"Page.frameStartedNavigating","sessionId":"S","params":{"frameId":"F","url":"https://example.test/next"}}));
4376 assert!(!cdp.target_protected("T", false).unwrap());
4377 observe_document(&mut cdp);
4378 assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.title"}), Some("S")).is_ok());
4379 let mut cdp = url_backend("http://ordinary.invalid/");
4380 cdp.session_targets.insert("S".into(), "T".into());
4381 error_commit(&mut cdp, "http://ordinary.invalid/", "://");
4382 assert!(!cdp.target_protected("T", false).unwrap());
4383 observe_document(&mut cdp);
4384 assert!(cdp.call("Page.navigate", json!({"url":"about:blank"}), Some("S")).is_ok());
4385 }
4386
4387 #[test]
4388 fn protected_error_page_path_query_and_fragment_redact_url_and_title_everywhere() {
4389 for url in [
4390 "https://seven.sliccy.ai/path-secret",
4391 "https://seven.sliccy.ai/?query-secret=hidden",
4392 "https://seven.sliccy.ai/#fragment-secret-hidden",
4393 "https://seven.sliccy.ai/path-secret?query-secret=hidden#fragment-secret-hidden",
4394 ] {
4395 let mut cdp = error_page_client_url(url);
4396 error_commit(&mut cdp, url, "://");
4397 let info = json!({"targetId":"T","type":"page","url":url,"title":url});
4398 for method in ["Target.targetCreated", "Target.targetInfoChanged", "Target.attachedToTarget"] {
4399 cdp.absorb(json!({"method":method,"params":{"sessionId":"A","targetInfo":info}}));
4400 let event = cdp.events().last().unwrap();
4401 for key in ["url", "title"] {
4402 assert_eq!(event["params"]["targetInfo"][key], "https://seven.sliccy.ai", "{method} {key} {url}");
4403 }
4404 }
4405 for method in ["Target.getTargets", "Target.getTargetInfo"] {
4406 let out = cdp.call(method, json!({"targetId":"T"}), None).unwrap();
4407 let info = if method == "Target.getTargets" { &out["targetInfos"][0] } else { &out["targetInfo"] };
4408 for key in ["url", "title"] {
4409 assert_eq!(info[key], "https://seven.sliccy.ai", "{method} {key} {url}");
4410 }
4411 }
4412 assert!(!cdp.target_protected("T", false).unwrap());
4413 assert!(!cdp.session_blocked("S"));
4414 }
4415 }
4416
4417 #[test]
4418 fn protected_seven_fragment_targets_and_url_changes_never_disclose_markers() {
4419 for url in [
4420 "https://seven.sliccy.ai/auth/callback#access_token=MARK",
4421 "https://seven.sliccy.ai/#tailscale=MARK1",
4422 "https://seven.sliccy.ai/#proxy=http%3A%2F%2F127.0.0.1%3A9%2F&key=MARK2",
4423 ] {
4424 for changed in [false, true] {
4425 let mut cdp = url_backend(url);
4426 if changed {
4427 cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://seven.sliccy.ai/","title":"clean"}}}));
4428 }
4429 let info = json!({"targetId":"T","type":"page","url":url,"title":url});
4430 for method in ["Target.targetCreated", "Target.targetInfoChanged", "Target.attachedToTarget"] {
4431 let mark = cdp.events().len();
4432 cdp.absorb(json!({"method":method,"params":{"sessionId":"A","targetInfo":info}}));
4433 for event in &cdp.events()[mark..] {
4434 assert!(!event.to_string().contains("MARK"), "{method} {changed} {event}");
4435 for key in ["url", "title"] {
4436 assert_eq!(event["params"]["targetInfo"][key], "https://seven.sliccy.ai", "{method} {key} {url}");
4437 }
4438 }
4439 }
4440 for method in ["Target.getTargets", "Target.getTargetInfo"] {
4441 let params = if method == "Target.getTargets" { json!({}) } else { json!({"targetId":"T"}) };
4442 let out = cdp.call(method, params, None).unwrap();
4443 assert!(!out.to_string().contains("MARK"), "{method} {out}");
4444 let info = if method == "Target.getTargets" { &out["targetInfos"][0] } else { &out["targetInfo"] };
4445 for key in ["url", "title"] {
4446 assert_eq!(info[key], "https://seven.sliccy.ai", "{method} {key} {url}");
4447 }
4448 }
4449 cdp.absorb(json!({"method":"Target.detachedFromTarget","params":{"sessionId":"A","targetId":"T"}}));
4450 assert!(!cdp.events().last().unwrap().to_string().contains("MARK"));
4451 assert!(cdp.target_protected("T", false).unwrap());
4452 }
4453 }
4454 }
4455
4456 fn target_fragment_markers() -> [&'static str; 5] {
4457 [
4458 "https://seven.sliccy.ai/#tailscale=MARK1",
4459 "https://seven.sliccy.ai/#proxy=http%3A%2F%2F127.0.0.1%3A9%2F&key=MARK2",
4460 "https://seven.sliccy.ai/auth/callback#access_token=MARK",
4461 "https://seven.sliccy.ai/path-secret?query-secret=MARK",
4462 "https://seven.sliccy.ai/path-secret?query-secret=MARK#access_token=MARK",
4463 ]
4464 }
4465
4466 #[test]
4467 fn target_info_fragment_markers_are_redacted_in_responses_and_events() {
4468 for url in target_fragment_markers() {
4469 for changed in [false, true] {
4470 let mut cdp = url_backend(url);
4471 if changed {
4472 cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://seven.sliccy.ai/","title":"clean"}}}));
4473 }
4474 let info = json!({"targetId":"T","type":"page","url":url,"title":url,"openerId":"OPENER","openerFrameId":"FRAME"});
4475 for method in ["Target.targetCreated", "Target.targetInfoChanged", "Target.attachedToTarget"] {
4476 let mark = cdp.events().len();
4477 cdp.absorb(json!({"method":method,"params":{"sessionId":"A","targetInfo":info}}));
4478 let event = cdp.events()[mark..].iter().find(|event| event["method"] == method).unwrap();
4479 for key in ["url", "title"] {
4480 assert_eq!(event["params"]["targetInfo"][key], "https://seven.sliccy.ai", "{method} {key} {url} {changed}");
4481 }
4482 assert_eq!(event["params"]["targetInfo"]["openerId"], "OPENER");
4483 assert_eq!(event["params"]["targetInfo"]["openerFrameId"], "FRAME");
4484 assert!(!event.to_string().contains("MARK"));
4485 }
4486 for method in ["Target.getTargets", "Target.getTargetInfo"] {
4487 let params = if method == "Target.getTargets" { json!({}) } else { json!({"targetId":"T"}) };
4488 let out = cdp.call(method, params, None).unwrap();
4489 let info = if method == "Target.getTargets" { &out["targetInfos"][0] } else { &out["targetInfo"] };
4490 for key in ["url", "title"] {
4491 assert_eq!(info[key], "https://seven.sliccy.ai", "{method} {key} {url}");
4492 }
4493 assert!(!out.to_string().contains("MARK"));
4494 }
4495 for event in [
4496 json!({"method":"Target.detachedFromTarget","params":{"sessionId":"A","targetId":"T"}}),
4497 json!({"method":"Target.detachedFromTarget","params":{"sessionId":"B"}}),
4498 json!({"method":"Page.frameAttached","sessionId":"S","params":{"frameId":"CHILD","parentFrameId":"FRAME"}}),
4499 ] {
4500 cdp.absorb(event);
4501 assert!(!cdp.events().last().unwrap().to_string().contains("MARK"));
4502 }
4503 }
4504 }
4505 }
4506
4507 #[test]
4508 fn target_info_nonflattened_and_browser_attach_paths_remain_refused() {
4509 for (method, params) in [
4510 ("Target.attachToTarget", json!({"targetId":"T","flatten":false})),
4511 ("Target.attachToBrowserTarget", json!({})),
4512 ("Target.setAutoAttach", json!({"autoAttach":true,"flatten":true})),
4513 ("Target.autoAttachRelated", json!({"targetId":"T","waitForDebuggerOnStart":false})),
4514 ("Target.sendMessageToTarget", json!({"targetId":"T","message":"MARK"})),
4515 ] {
4516 let mut cdp = url_backend("https://example.test/");
4517 assert!(cdp.call(method, params, None).is_err(), "{method}");
4518 }
4519 }
4520
4521 #[test]
4522 fn target_info_wrapped_nonflattened_events_never_disclose_markers() {
4523 for url in target_fragment_markers() {
4524 let mut cdp = url_backend("https://example.test/");
4525 let mark = cdp.events().len();
4526 let nested = json!({"method":"Target.attachedToTarget","params":{"sessionId":"CHILD","targetInfo":{"targetId":"T","type":"page","url":url,"title":url}}});
4527 cdp.absorb(json!({"method":"Target.receivedMessageFromTarget","params":{"sessionId":"A","targetId":"T","message":nested.to_string()}}));
4528 assert_eq!(cdp.events().len(), mark, "{url}");
4529 }
4530 }
4531
4532 #[test]
4533 fn allowed_blank_commit_refreshes_stale_protected_discovery_before_driving() {
4534 let mut cdp = url_backend("about:blank");
4535 cdp.session_targets.insert("S".into(), "T".into());
4536 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://example.test/","securityOrigin":"https://example.test"}}}));
4537 error_commit(&mut cdp, "https://seven.sliccy.ai/doc#access_token=MARK", "://");
4538 cdp.absorb(json!({"method":"Target.targetInfoChanged","params":{"targetInfo":{"targetId":"T","type":"page","url":"https://seven.sliccy.ai/doc#access_token=MARK","title":"MARK"}}}));
4539 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"about:blank","securityOrigin":"https://example.test"}}}));
4540 observe_document(&mut cdp);
4541 assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.readyState"}), Some("S")).is_ok());
4542 assert!(!cdp.session_blocked("S"));
4543 assert!(!cdp.is_protected_target("T"));
4544 }
4545
4546 #[test]
4547 fn permitted_error_document_redacts_history_and_refuses_runtime_without_losing_ids() {
4548 struct HistoryResponses(UrlBackend);
4549 impl Transport for HistoryResponses {
4550 fn send_text(&mut self, text: &str) -> Result<(), String> {
4551 let call: Value = serde_json::from_str(text).unwrap();
4552 let protected = self.0.url.clone();
4553 let output = match call["method"].as_str().unwrap() {
4554 "Page.getNavigationHistory" => Some(json!({"currentIndex":1,"entries":[
4555 {"id":701,"url":"https://ordinary.example/ready","title":"ordinary control","userTypedURL":"https://ordinary.example/ready"},
4556 {"id":702,"url":protected,"title":protected,"userTypedURL":protected}
4557 ]})),
4558 "Runtime.evaluate" => Some(json!({"result":{"type":"object","value":{"url":protected,"text":protected}}})),
4559 _ => None,
4560 };
4561 if let Some(output) = output {
4562 self.0.inbox.push_back(json!({"id":call["id"],"result":output}).to_string());
4563 Ok(())
4564 } else {
4565 self.0.send_text(text)
4566 }
4567 }
4568 fn recv_text(&mut self, until: Instant) -> Result<Option<String>, String> {
4569 self.0.recv_text(until)
4570 }
4571 }
4572 for attempted in [
4573 "https://seven.sliccy.ai/private/HISTORY_MARK?key=HISTORY_MARK#access_token=HISTORY_MARK",
4574 "https://vault.sliccy.ai/private/HISTORY_MARK?key=HISTORY_MARK#access_token=HISTORY_MARK",
4575 ] {
4576 let mut cdp = Cdp::from_transport(Box::new(HistoryResponses(UrlBackend { url:attempted.into(), inbox:VecDeque::new() })));
4577 cdp.session_targets.insert("S".into(), "T".into());
4578 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://ordinary.example/ready","securityOrigin":"https://ordinary.example"}}}));
4579 error_commit(&mut cdp, attempted, "://");
4580 assert!(!cdp.target_protected("T", false).unwrap());
4581 assert!(!cdp.session_blocked("S"));
4582 observe_document(&mut cdp);
4583 let origin = url::Url::parse(attempted).unwrap().origin().ascii_serialization();
4584 let history = cdp.call("Page.getNavigationHistory", json!({}), Some("S")).unwrap();
4585 assert_eq!(history["currentIndex"], 1);
4586 assert_eq!(history["entries"][0]["id"], 701);
4587 assert_eq!(history["entries"][0]["url"], "https://ordinary.example/ready");
4588 assert_eq!(history["entries"][0]["title"], "ordinary control");
4589 assert_eq!(history["entries"][1]["id"], 702);
4590 for key in ["url", "title", "userTypedURL"] {
4591 assert_eq!(history["entries"][1][key], origin, "{key}");
4592 }
4593 assert!(!history.to_string().contains("HISTORY_MARK"));
4594 let sent = cdp.next_id;
4595 for expression in ["location.href", "Array.from(document.documentElement.innerHTML, c => c.codePointAt(0))"] {
4596 assert!(cdp.call("Runtime.evaluate", json!({"expression":expression,"returnByValue":true}), Some("S")).is_err(), "{expression}");
4597 }
4598 assert_eq!(cdp.next_id, sent + 2);
4599 assert!(cdp.call("Page.reload", json!({}), Some("S")).is_ok());
4600 assert!(!cdp.session_blocked("S"));
4601 }
4602 }
4603
4604 #[test]
4605 fn internal_guard_loss_refuses_direct_commands_even_with_stale_ordinary_discovery() {
4606 for error in [false, true] {
4607 let mut cdp = error_page_client();
4608 if error {
4609 error_commit(&mut cdp, "https://seven.sliccy.ai/path?secret#fragment", "://");
4610 }
4611 assert_eq!(cdp.targets["T"]["url"], "https://example.test/");
4612 assert!(!cdp.session_blocked("S"));
4613 cdp.guards.insert("T".into(), "G".into());
4614 cdp.guard_sessions.insert("G".into());
4615 cdp.absorb(json!({"method":"Target.detachedFromTarget","params":{"sessionId":"G","targetId":"T"}}));
4616 let sent = cdp.next_id;
4617 let result = cdp.call("Runtime.evaluate", json!({"expression":"location.href = 'https://seven.sliccy.ai/'"}), Some("S"));
4618 assert!(result.is_err(), "guard loss must refuse before any discovery refresh");
4619 assert_eq!(cdp.next_id, sent, "no Runtime or discovery command may reach the backend");
4620 assert!(cdp.session_blocked("S"));
4621 assert!(cdp.abandoned.contains("T"));
4622 assert!(cdp.is_protected_target("T"));
4623 assert!(cdp.call("Target.attachToTarget", json!({"targetId":"T","flatten":true}), None).is_err());
4624 }
4625 }
4626
4627 #[test]
4628 fn caller_and_unknown_detach_do_not_abandon_a_healthy_guard() {
4629 let mut cdp = error_page_client_url("https://example.test/");
4630 cdp.guards.insert("T".into(), "G".into());
4631 cdp.guard_sessions.insert("G".into());
4632 for detached in ["UNKNOWN", "OTHER"] {
4633 cdp.session_targets.insert("OTHER".into(), "T".into());
4634 cdp.absorb(json!({"method":"Target.detachedFromTarget","params":{"sessionId":detached,"targetId":"T"}}));
4635 assert_eq!(cdp.guards["T"], "G");
4636 assert!(cdp.documents.contains_key("T"));
4637 assert!(!cdp.abandoned.contains("T"));
4638 assert!(!cdp.session_blocked("S"));
4639 observe_document(&mut cdp);
4640 assert!(cdp.call("Runtime.evaluate", json!({"expression":"document.readyState"}), Some("S")).is_ok());
4641 }
4642 }
4643
4644 const UNPARSEABLE: &str = "slicc: refused: slicc can't parse this URL, so it could be a protected page\n";
4645
4646 fn malformed_urls() -> Vec<String> {
4647 let mut urls = [
4648 "http://exa%20mple.sliccy.ai/",
4649 "http://exa mple.sliccy.ai/",
4650 "http://\u{200d}.sliccy.ai/",
4651 "https://exa%20mple.example/",
4652 "http://xn--1ug.sliccy.ai/",
4653 "http://xn--a.sliccy.ai/",
4654 "view-source:http://exa%20mple.sliccy.ai/",
4655 "blob:http://exa%20mple.sliccy.ai/id",
4656 "filesystem:http://exa%20mple.sliccy.ai/temporary/x",
4657 ].map(str::to_string).to_vec();
4658 urls.push(format!("{}https://example.test/", "view-source:".repeat(9)));
4659 urls
4660 }
4661
4662 struct UrlBackend {
4663 url: String,
4664 inbox: VecDeque<String>,
4665 }
4666
4667 impl Transport for UrlBackend {
4668 fn send_text(&mut self, text: &str) -> Result<(), String> {
4669 let message: Value = serde_json::from_str(text).unwrap();
4670 let info = json!({"targetId":"T","type":"page","url":self.url,"title":"SECRET"});
4671 let result = match message["method"].as_str().unwrap() {
4672 "Target.getTargetInfo" => json!({"targetInfo":info}),
4673 "Target.getTargets" => json!({"targetInfos":[info]}),
4674 "Target.attachToTarget" => json!({"sessionId":"S"}),
4675 "Target.createTarget" => json!({"targetId":"T"}),
4676 "Page.getNavigationHistory" => json!({"entries":[{"id":1,"url":self.url}]}),
4677 _ => json!({}),
4678 };
4679 self.inbox.push_back(json!({"id":message["id"],"result":result}).to_string());
4680 Ok(())
4681 }
4682
4683 fn recv_text(&mut self, _deadline: Instant) -> Result<Option<String>, String> {
4684 Ok(self.inbox.pop_front())
4685 }
4686 }
4687
4688 fn url_backend(url: &str) -> Cdp {
4689 let mut cdp = Cdp::from_transport(Box::new(UrlBackend { url: url.into(), inbox: VecDeque::new() }));
4690 cdp.fixture_session("S");
4691 cdp
4692 }
4693
4694 #[test]
4695 fn issue_401_command_destinations_fail_closed() {
4696 let mut failures = Vec::new();
4697 for url in malformed_urls() {
4698 for method in ["Target.createTarget", "Page.navigate", "Network.loadNetworkResource", "Fetch.continueRequest", "Network.continueInterceptedRequest", "PWA.install", "PWA.launch"] {
4699 let mut cdp = url_backend("about:blank");
4700 cdp.hand("S", "R");
4701 cdp.requests.insert("R".into(), false);
4702 let params = if method.starts_with("PWA.") {
4703 json!({"manifestId":url,"installUrlOrBundleUrl":url})
4704 } else {
4705 json!({"url":url,"requestId":"R","interceptionId":"R"})
4706 };
4707 let result = cdp.call(method, params, Some("S"));
4708 if !result.as_ref().err().is_some_and(|error| error.starts_with("slicc: refused:")) {
4709 failures.push(format!("{method} {url}: {result:?}"));
4710 }
4711 }
4712 }
4713 assert!(failures.is_empty(), "{}", failures.join("\n"));
4714 }
4715
4716 #[test]
4717 fn issue_401_normalized_protected_hosts_are_guarded() {
4718 for url in [
4719 "http://vault%2Esliccy.ai/",
4720 "http://vau%6Ct.sliccy.ai/",
4721 "http://VAULT.sliccy.ai/",
4722 "http://vault.sliccy.ai./",
4723 "http://seven.sliccy.ai./",
4724 ] {
4725 for (method, params) in [
4726 ("Target.createTarget", json!({"url":url})),
4727 ("Page.navigate", json!({"url":url})),
4728 ("Network.setCookie", json!({"url":url,"name":"x","value":"y"})),
4729 ("Network.deleteCookies", json!({"url":url,"name":"x"})),
4730 ("Network.setCookies", json!({"cookies":[{"url":url,"name":"x","value":"y"}]})),
4731 ("Storage.setCookies", json!({"cookies":[{"url":url,"name":"x","value":"y"}]})),
4732 ] {
4733 assert!(url_backend("about:blank").call(method, params, Some("S")).unwrap_err().starts_with("slicc: refused:"), "{method} {url}");
4734 }
4735 let mut cdp = url_backend(url);
4736 let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
4737 assert_ne!(listed["targetInfos"][0]["url"], url);
4738 assert!(cdp.is_protected_target("T"));
4739 assert!(url_backend(url).call("Target.attachToTarget", json!({"targetId":"T","flatten":true}), None).is_err());
4740 }
4741 }
4742
4743 #[test]
4744 fn issue_401_refusal_text_is_exact() {
4745 for method in ["Page.navigate", "Target.createTarget", "Network.setCookie", "Network.getCookies"] {
4746 let url = "http://exa%20mple.sliccy.ai/";
4747 let params = if method == "Network.getCookies" { json!({"urls":[url]}) } else { json!({"url":url,"name":"x","value":"y"}) };
4748 assert_eq!(url_backend("about:blank").call(method, params, Some("S")).unwrap_err(), UNPARSEABLE, "{method}");
4749 }
4750 }
4751
4752 #[test]
4753 fn issue_401_reported_targets_and_history_fail_closed() {
4754 for url in malformed_urls() {
4755 let mut cdp = url_backend(&url);
4756 let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
4757 assert!(listed["targetInfos"].as_array().unwrap().is_empty(), "{url}");
4758 assert!(cdp.call("Target.attachToTarget", json!({"targetId":"T","flatten":true}), None).unwrap_err().starts_with("slicc: refused:"), "{url}");
4759 assert!(url_backend(&url).call("Target.attachToTarget", json!({"targetId":"T","flatten":true}), None).is_err(), "{url}");
4760 assert!(url_backend(&url).call("Page.navigateToHistoryEntry", json!({"entryId":1}), Some("S")).is_err(), "{url}");
4761 }
4762 }
4763
4764 #[test]
4765 fn cookie_readers_keep_filtering_parseable_mixed_url_lists() {
4766 assert!(url_backend("about:blank").call("Network.getCookies", json!({"urls":["https://seven.sliccy.ai/", "https://example.test/"]}), Some("S")).is_ok());
4767 assert_eq!(url_backend("about:blank").call("Network.getCookies", json!({"urls":["http://exa%20mple.sliccy.ai/", "https://example.test/"]}), Some("S")).unwrap_err(), UNPARSEABLE);
4768 }
4769
4770 #[test]
4771 fn issue_401_cookie_command_urls_fail_closed() {
4772 let mut failures = Vec::new();
4773 for url in malformed_urls() {
4774 for (method, params) in [
4775 ("Network.setCookie", json!({"url":url,"name":"x","value":"y"})),
4776 ("Network.deleteCookies", json!({"url":url,"name":"x"})),
4777 ("Network.getCookies", json!({"urls":["https://example.test/",url]})),
4778 ("Network.setCookies", json!({"cookies":[{"url":url,"name":"x","value":"y"}]})),
4779 ("Storage.setCookies", json!({"cookies":[{"url":url,"name":"x","value":"y"}]})),
4780 ] {
4781 if url_backend("about:blank").call(method, params, Some("S")).is_ok() {
4782 failures.push(format!("{method} {url}"));
4783 }
4784 }
4785 }
4786 assert!(failures.is_empty(), "{}", failures.join("\n"));
4787 }
4788
4789 #[test]
4790 fn issue_401_loaded_urls_are_never_exposed() {
4791 for url in malformed_urls() {
4792 for event in [
4793 json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"https://example.test/","securityOrigin":url}}}),
4794 json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":url,"securityOrigin":"null"}}}),
4795 json!({"method":"Runtime.executionContextCreated","sessionId":"S","params":{"context":{"id":1,"origin":url,"auxData":{"isDefault":true}}}}),
4796 json!({"method":"Runtime.executionContextCreated","sessionId":"S","params":{"context":{"id":1,"origin":url,"auxData":{"isDefault":false}}}}),
4797 json!({"method":"Network.requestWillBeSent","sessionId":"S","params":{"requestId":"R","request":{"url":url}}}),
4798 json!({"method":"Network.webSocketCreated","sessionId":"S","params":{"requestId":"R","url":url.replacen("http:","ws:",1)}}),
4799 json!({"method":"ServiceWorker.workerVersionUpdated","params":{"versions":[{"scriptURL":url,"status":"activated"}]}}),
4800 ] {
4801 let mut cdp = url_backend("about:blank");
4802 cdp.absorb(event.clone());
4803 assert!(cdp.events().is_empty(), "{event}: {:?}", cdp.events());
4804 if event["method"] == "Page.frameNavigated" || event.pointer("/params/context/auxData/isDefault") == Some(&json!(true)) {
4805 assert!(cdp.blocked.contains("S"), "{event}");
4806 }
4807 }
4808 for kind in ["page", "service_worker", "shared_worker", "iframe"] {
4809 let mut cdp = url_backend("about:blank");
4810 cdp.absorb(json!({"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"T","type":kind,"url":url,"title":"SECRET"}}}));
4811 assert!(cdp.is_protected_target("T"), "{kind} {url}");
4812 assert!(cdp.events().is_empty(), "{kind} {url}");
4813 }
4814 }
4815 for origin in ["", "null", "://"] {
4816 let mut cdp = url_backend("about:blank");
4817 cdp.absorb(json!({"method":"Page.frameNavigated","sessionId":"S","params":{"frame":{"id":"F","url":"about:blank","securityOrigin":origin}}}));
4818 assert!(!cdp.blocked.contains("S"));
4819 assert_eq!(cdp.events().len(), 1);
4820 }
4821 }
4822
4823 #[test]
4824 fn issue_401_exemptions_and_read_only_keys_remain_open() {
4825 for url in ["", "null", "://"] {
4826 assert!(url_backend("about:blank").call("Target.createTarget", json!({"url":url}), None).is_ok(), "{url}");
4827 assert!(url_backend("about:blank").call("Page.navigate", json!({"url":url}), Some("S")).is_ok(), "{url}");
4828 }
4829 for url in malformed_urls() {
4830 let mut cdp = url_backend("about:blank");
4831 assert!(cdp.notify("Slicc.action", json!({"url":url,"phase":"start"}), None).is_ok(), "{url}");
4832 assert!(cdp.call("Storage.getUsageAndQuota", json!({"origin":url}), None).is_err(), "{url}");
4833 assert!(cdp.call("Storage.clearDataForStorageKey", json!({"storageKey":format!("{url}^0{url}")}), None).is_err(), "{url}");
4834 }
4835 }
4836
4837 fn cleanup_answer(message: &Value) -> Vec<Value> {
4838 let result = match message["method"].as_str() {
4839 Some("Target.createTarget") => json!({ "targetId": "T" }),
4840 Some("Target.getTargetInfo") => json!({ "targetInfo": {
4841 "targetId": "T", "type": "page", "url": "about:blank"
4842 } }),
4843 Some("Target.getTargets") => json!({ "targetInfos": [
4844 { "targetId": "T", "type": "page", "url": "about:blank" }
4845 ] }),
4846 Some("Target.attachToTarget") => json!({ "sessionId": "S" }),
4847 Some("Network.setBypassServiceWorker") => return Vec::new(),
4848 Some("Target.closeTarget") => return vec![
4849 json!({ "method": "Target.targetDestroyed", "params": { "targetId": "OTHER" } }),
4850 json!({ "id": message["id"], "result": { "success": true } }),
4851 ],
4852 _ => json!({}),
4853 };
4854 vec![json!({ "id": message["id"], "result": result })]
4855 }
4856
4857 fn cleanup(delay: Option<Duration>, answer: fn(&Value) -> Vec<Value>) -> Cdp {
4858 Cdp::from_transport(Box::new(Cleanup {
4859 inner: Pending { inbox: VecDeque::new(), answer },
4860 destroy_after: delay,
4861 destroyed_at: None,
4862 closed: false,
4863 }))
4864 }
4865
4866 #[test]
4867 fn cleanup_waits_for_the_refused_targets_late_destruction() {
4868 let mut cdp = cleanup(Some(Duration::from_millis(40)), cleanup_answer);
4869 let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4870 assert_eq!(error, "slicc: refused: slicc can't keep seven's origin out of this target: Network.setBypassServiceWorker timed out\n");
4871 assert!(cdp.events().iter().any(|event| event["method"] == "Target.targetDestroyed" && event["params"]["targetId"] == "T"));
4872 assert!(cdp.is_protected_target("T"));
4873 }
4874
4875 #[test]
4876 fn cleanup_confirms_closure_without_browser_events() {
4877 let mut cdp = cleanup(None, |message| {
4878 if message["method"] == "Target.getTargets" {
4879 return vec![json!({ "id": message["id"], "result": { "targetInfos": [
4880 { "targetId": "OTHER", "type": "page", "url": "about:blank" }
4881 ] } })];
4882 }
4883 cleanup_answer(message).into_iter().filter(|reply| reply.get("method").is_none()).collect()
4884 });
4885 let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4886 assert_eq!(error, "slicc: refused: slicc can't keep seven's origin out of this target: Network.setBypassServiceWorker timed out\n");
4887 assert!(!cdp.events().iter().any(|event| event["method"] == "Target.targetDestroyed"));
4888 assert!(cdp.is_protected_target("T"));
4889 }
4890
4891 #[test]
4892 fn cleanup_reports_a_missing_destruction_event_without_allowing_the_target() {
4893 let mut cdp = cleanup(None, cleanup_answer);
4894 let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4895 assert!(error.starts_with("slicc: refused:"), "{error}");
4896 assert!(error.contains("Network.setBypassServiceWorker timed out"), "{error}");
4897 assert!(error.contains("Target.targetDestroyed timed out"), "{error}");
4898 assert!(cdp.is_protected_target("T"));
4899 }
4900
4901 #[test]
4902 fn cleanup_reports_a_close_that_did_not_succeed() {
4903 let mut cdp = cleanup(None, |message| {
4904 let mut replies = cleanup_answer(message);
4905 if message["method"] == "Target.closeTarget" {
4906 replies.last_mut().unwrap()["result"] = json!({ "success": false });
4907 }
4908 replies
4909 });
4910 let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4911 assert!(error.contains("Network.setBypassServiceWorker timed out"), "{error}");
4912 assert!(error.contains("cleanup: Target.closeTarget did not confirm success"), "{error}");
4913 assert!(cdp.is_protected_target("T"));
4914 }
4915
4916 #[test]
4917 fn cleanup_does_not_accept_a_stale_destruction_event() {
4918 let mut cdp = cleanup(None, cleanup_answer);
4919 cdp.absorb(json!({ "method": "Target.targetDestroyed", "params": { "targetId": "T" } }));
4920 let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4921 assert!(error.contains("Target.targetDestroyed timed out"), "{error}");
4922 assert!(cdp.is_protected_target("T"));
4923 }
4924
4925 #[test]
4926 fn cleanup_accepts_destruction_before_the_close_reply() {
4927 let mut cdp = cleanup(None, |message| {
4928 let mut replies = cleanup_answer(message);
4929 if message["method"] == "Target.closeTarget" {
4930 replies.insert(0, json!({ "method": "Target.targetDestroyed", "params": { "targetId": "T" } }));
4931 }
4932 replies
4933 });
4934 let error = cdp.call("Target.createTarget", json!({ "url": "about:blank" }), None).unwrap_err();
4935 assert!(error.contains("Network.setBypassServiceWorker timed out"), "{error}");
4936 assert!(!error.contains("cleanup:"), "{error}");
4937 }
4938
4939 #[test]
4940 fn privileged_target_types_and_extension_workers_are_guarded() {
4941 let mut cdp = pending(|message| {
4942 let info = match message["params"]["targetId"].as_str() {
4943 Some("UI") => json!({"targetId": "UI", "type": "browser_ui", "url": "https://example.test/"}),
4944 Some("W") => json!({"targetId": "W", "type": "service_worker", "url": "chrome-extension://abc/worker.js"}),
4945 Some("B") => json!({"targetId": "B", "type": "page", "url": "about:blank", "openerId": "UI"}),
4946 _ => json!({"targetId": "P", "type": "page", "url": "https://example.test/"}),
4947 };
4948 let result = match message["method"].as_str() {
4949 Some("Target.getTargetInfo") => json!({"targetInfo": info}),
4950 Some("Target.getTargets") => json!({"targetInfos": [
4951 {"targetId": "UI", "type": "browser_ui", "url": "https://example.test/"},
4952 {"targetId": "P", "type": "page", "url": "https://example.test/"}
4953 ]}),
4954 _ => json!({}),
4955 };
4956 vec![json!({"id": message["id"], "result": result})]
4957 });
4958 assert!(cdp.target_protected("B", false).unwrap());
4959 assert!(cdp.target_protected("UI", false).unwrap());
4960 assert!(cdp.target_protected("W", false).unwrap());
4961 assert!(!cdp.target_protected("P", false).unwrap());
4962 let listed = cdp.call("Target.getTargets", json!({}), None).unwrap();
4963 assert_eq!(listed["targetInfos"].as_array().unwrap().len(), 1);
4964 assert_eq!(listed["targetInfos"][0]["targetId"], "P");
4965 for kind in ["page", "service_worker", "background_page", "other", "iframe"] {
4966 assert!(cdp.info_protected(&json!({
4967 "targetId": "E", "type": kind, "url": "chrome-extension://abc/options.html"
4968 }), &[]));
4969 }
4970 assert!(cdp.info_protected(&json!({
4971 "targetId": "UI", "type": "browser_ui", "url": "about:blank"
4972 }), &[]));
4973 }
4974
4975 fn evaluate(cdp: &mut Cdp) -> (Result<Value, String>, Duration) {
4976 let started = Instant::now();
4977 let result = cdp.call_for(
4978 "Runtime.evaluate",
4979 json!({ "expression": "1" }),
4980 Some("S"),
4981 Duration::from_secs(30),
4982 );
4983 (result, started.elapsed())
4984 }
4985
4986 #[test]
4987 fn a_call_fails_with_the_refusal_once_the_guard_blocks_its_session() {
4988 let mut cdp = pending(|message| match message["method"].as_str() {
4989 Some("Runtime.evaluate") => vec![json!({
4990 "method": "Page.frameNavigated",
4991 "sessionId": "S",
4992 "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/os/", "securityOrigin": "https://seven.sliccy.ai" } }
4993 })],
4994 _ => vec![],
4995 });
4996 let (result, spent) = evaluate(&mut cdp);
4997 assert_eq!(result.unwrap_err(), format!("{}\n", cdp.guard().refusal()));
4998 assert!(spent < Duration::from_secs(5), "{spent:?}");
4999 assert!(cdp.session_blocked("S"));
5000 }
5001
5002 #[test]
5003 fn a_call_fails_with_the_refusal_once_its_session_detaches() {
5004 let mut cdp = pending(|_| {
5005 vec![
5006 json!({ "method": "Target.detachedFromTarget", "params": { "sessionId": "other" } }),
5007 json!({ "method": "Target.detachedFromTarget", "params": { "sessionId": "S" } }),
5008 ]
5009 });
5010 let (result, spent) = evaluate(&mut cdp);
5011 assert_eq!(result.unwrap_err(), format!("{}\n", cdp.guard().refusal()));
5012 assert!(spent < Duration::from_secs(5), "{spent:?}");
5013 }
5014
5015 #[test]
5016 fn unrelated_events_do_not_end_a_call() {
5017 let mut cdp = pending(|message| {
5018 vec![
5019 json!({ "method": "Target.detachedFromTarget", "params": { "sessionId": "other" } }),
5020 json!({ "id": message["id"], "result": { "result": { "value": 1 } } }),
5021 ]
5022 });
5023 let (result, _) = evaluate(&mut cdp);
5024 assert_eq!(result.unwrap(), json!({ "result": { "value": 1 } }));
5025 }
5026
5027 #[test]
5028 fn a_harmless_call_on_an_already_blocked_session_still_waits_for_its_reply() {
5029 let mut cdp = pending(|message| match message["method"].as_str() {
5030 Some("Runtime.evaluate") => vec![json!({
5031 "method": "Page.frameNavigated",
5032 "sessionId": "S",
5033 "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/os/" } }
5034 })],
5035 Some("Fetch.disable") => vec![
5036 json!({ "method": "Page.lifecycleEvent", "sessionId": "S", "params": { "name": "load" } }),
5037 json!({ "id": message["id"], "result": {} }),
5038 ],
5039 _ => vec![],
5040 });
5041 let _ = evaluate(&mut cdp);
5042 assert!(cdp.session_blocked("S"));
5043 let disabled = cdp.call_for("Fetch.disable", json!({}), Some("S"), Duration::from_secs(30));
5044 assert_eq!(disabled.unwrap(), json!({}));
5045 }
5046
5047 #[test]
5048 fn a_finished_document_does_not_wait_for_load() {
5049 assert_eq!(probe_is_settled(Ok(Some("complete"))).unwrap(), true);
5050 assert_eq!(probe_is_settled(Ok(None)).unwrap(), true);
5051 assert_eq!(
5052 probe_is_settled(Err("Runtime.evaluate timed out\n")).unwrap(),
5053 true
5054 );
5055 assert_eq!(
5056 probe_is_settled(Err("Runtime.evaluate: 'Runtime.evaluate' wasn't found\n")).unwrap(),
5057 true
5058 );
5059 }
5060
5061 #[test]
5062 fn a_blank_load_does_not_settle_the_next_navigation() {
5063 let session = "sess";
5064 let blank_load = json!({
5065 "method": "Page.loadEventFired",
5066 "sessionId": session,
5067 "params": {}
5068 });
5069 let blank_frame = json!({
5070 "method": "Page.frameNavigated",
5071 "sessionId": session,
5072 "params": { "frame": { "url": "about:blank" } }
5073 });
5074 let commit = json!({
5075 "method": "Page.frameNavigated",
5076 "sessionId": session,
5077 "params": { "frame": { "url": "http://a.test/boot" } }
5078 });
5079 let load = json!({
5080 "method": "Page.loadEventFired",
5081 "sessionId": session,
5082 "params": {}
5083 });
5084 let raced = vec![blank_frame, blank_load.clone(), commit.clone()];
5085 assert!(!super::navigation_settled(&raced, session, 0));
5086 let ready = vec![blank_load.clone(), commit.clone(), load.clone()];
5087 assert!(super::navigation_settled(&ready, session, 0));
5088 let stale = vec![commit, load, blank_load];
5089 assert!(!super::navigation_settled(&stale, session, 2));
5090 }
5091
5092 #[test]
5093 fn a_loading_document_still_waits() {
5094 assert_eq!(probe_is_settled(Ok(Some("loading"))).unwrap(), false);
5095 assert_eq!(probe_is_settled(Ok(Some("interactive"))).unwrap(), false);
5096 }
5097
5098 #[test]
5099 fn a_closed_socket_is_not_treated_as_loaded() {
5100 for err in [
5101 "websocket closed 1011 the browser went away\n",
5102 "websocket connection closed\n",
5103 "websocket write failed: broken pipe\n",
5104 "websocket read failed: connection reset\n",
5105 "CDP response was not JSON: eof\n",
5106 ] {
5107 let reported = probe_is_settled(Err(err)).unwrap_err();
5108 assert_eq!(reported, err);
5109 }
5110 }
5111
5112 #[test]
5113 fn action_start_and_end_keep_the_panel_fields() {
5114 let click = ActionNote {
5115 kind: "click".to_string(),
5116 tab: Some("1234".to_string()),
5117 target: Some("button \"Sign in\"".to_string()),
5118 agent: Some("cone:cone-1".to_string()),
5119 ..ActionNote::default()
5120 };
5121 assert_eq!(
5122 click.start_params(),
5123 json!({
5124 "phase": "start",
5125 "kind": "click",
5126 "tab": "1234",
5127 "target": "button \"Sign in\"",
5128 "agent": "cone:cone-1"
5129 })
5130 );
5131 let failed = click.end_params(
5132 false,
5133 "No snapshot available. Run \"snapshot\" first.\nignored\n",
5134 );
5135 assert_eq!(
5136 failed,
5137 json!({
5138 "phase": "end",
5139 "ok": false,
5140 "error": "no snapshot",
5141 "tab": "1234"
5142 })
5143 );
5144 assert!(failed.get("agent").is_none());
5145 assert!(failed.get("kind").is_none());
5146 assert!(failed.get("target").is_none());
5147 let open = ActionNote {
5148 kind: "open".to_string(),
5149 url: Some("http://a.test/".to_string()),
5150 ..ActionNote::default()
5151 };
5152 assert!(open.start_params().get("tab").is_none());
5153 assert_eq!(open.start_params()["url"], "http://a.test/");
5154 let mut opened = open.clone();
5155 opened.tab = Some("TAB1".to_string());
5156 assert_eq!(opened.end_params(true, "")["tab"], "TAB1");
5157 assert!(opened.end_params(true, "").get("error").is_none());
5158 }
5159
5160 #[test]
5161 fn action_params_omit_typed_text_eval_source_and_request_secrets() {
5162 let secret = "secret-text";
5163 let fill = ActionNote {
5164 kind: "fill".to_string(),
5165 tab: Some("T".to_string()),
5166 target: Some("textbox \"Email\"".to_string()),
5167 length: Some(secret.chars().count() as u64),
5168 ..ActionNote::default()
5169 };
5170 let fill_params = fill.start_params();
5171 assert_eq!(fill_params["length"], 11);
5172 assert!(!fill_params.to_string().contains(secret));
5173 assert!(fill_params.get("value").is_none());
5174 let typed = "héllo";
5175 let type_note = ActionNote {
5176 kind: "type".to_string(),
5177 length: Some(typed.chars().count() as u64),
5178 ..ActionNote::default()
5179 };
5180 assert_eq!(type_note.start_params()["length"], 5);
5181 assert!(!type_note.start_params().to_string().contains(typed));
5182 let source = "document.cookie";
5183 let eval_note = ActionNote {
5184 kind: "eval".to_string(),
5185 tab: Some("T".to_string()),
5186 ..ActionNote::default()
5187 };
5188 let eval_params = eval_note.start_params();
5189 assert!(!eval_params.to_string().contains(source));
5190 assert!(eval_params.get("expression").is_none());
5191 let request = ActionNote {
5192 kind: "request".to_string(),
5193 tab: Some("T1".to_string()),
5194 method: Some("POST".to_string()),
5195 url: Some("https://app.example/api/me".to_string()),
5196 ..ActionNote::default()
5197 };
5198 let request_params = request.start_params();
5199 assert_eq!(
5200 request_params,
5201 json!({
5202 "phase": "start",
5203 "kind": "request",
5204 "tab": "T1",
5205 "method": "POST",
5206 "url": "https://app.example/api/me"
5207 })
5208 );
5209 assert!(!request_params.to_string().contains("Authorization"));
5210 assert!(!request_params.to_string().contains(secret));
5211 let press = ActionNote {
5212 kind: "press".to_string(),
5213 key: Some("Enter".to_string()),
5214 ..ActionNote::default()
5215 };
5216 assert_eq!(press.start_params()["key"], "Enter");
5217 assert!(ActionNote::default().start_params().get("agent").is_none());
5218 assert_eq!(first_stderr_line("only-line"), Some("only-line"));
5219 assert_eq!(first_stderr_line("\n"), None);
5220 }
5221
5222 #[test]
5223 fn action_urls_and_errors_drop_page_secrets() {
5224 let secret = "https://user:pass@app.example/form?q=QUERYSECRET&tok=x#FRAGSECRET";
5225 let open = ActionNote {
5226 kind: "open".to_string(),
5227 url: Some(secret.to_string()),
5228 ..ActionNote::default()
5229 };
5230 let started = open.start_params();
5231 assert_eq!(started["url"], "https://app.example/form");
5232 assert_clean(&started);
5233 let goto = ActionNote {
5234 kind: "goto".to_string(),
5235 url: Some(secret.to_string()),
5236 ..ActionNote::default()
5237 };
5238 assert_eq!(goto.start_params()["url"], "https://app.example/form");
5239 let posted = ActionNote {
5240 kind: "request".to_string(),
5241 method: Some("POST".to_string()),
5242 url: Some(secret.to_string()),
5243 ..ActionNote::default()
5244 };
5245 let request = posted.start_params();
5246 assert_eq!(request["method"], "POST");
5247 assert_eq!(request["url"], "https://app.example/form");
5248 assert_clean(&request);
5249 let gotten = ActionNote {
5250 kind: "request".to_string(),
5251 method: Some("GET".to_string()),
5252 url: Some("http://user:p%40ss@[::1]:8443/a/b?x=1#y".to_string()),
5253 ..ActionNote::default()
5254 };
5255 assert_eq!(gotten.start_params()["url"], "http://[::1]:8443/a/b");
5256 let relative = ActionNote {
5257 kind: "request".to_string(),
5258 method: Some("GET".to_string()),
5259 url: Some("//user:pass@app.example/path?q=QUERYSECRET#FRAGSECRET".to_string()),
5260 ..ActionNote::default()
5261 };
5262 let relative_params = relative.start_params();
5263 assert_eq!(relative_params["url"], "//app.example/path");
5264 assert_clean(&relative_params);
5265 assert_eq!(
5266 ActionNote {
5267 kind: "open".to_string(),
5268 url: Some("about:blank".to_string()),
5269 ..ActionNote::default()
5270 }
5271 .start_params()["url"],
5272 "about:blank"
5273 );
5274 let opaque = [
5275 (
5276 "goto",
5277 "data:text/html,<title>DATASECRET</title>",
5278 Some("data:"),
5279 "DATASECRET",
5280 ),
5281 (
5282 "open",
5283 "data:text/html,DATASECRET2",
5284 Some("data:"),
5285 "DATASECRET2",
5286 ),
5287 (
5288 "goto",
5289 "javascript:void('JSSECRET')",
5290 Some("javascript:"),
5291 "JSSECRET",
5292 ),
5293 (
5294 "open",
5295 "blob:https://example.com/BLOBSECRET",
5296 Some("blob:"),
5297 "BLOBSECRET",
5298 ),
5299 ("goto", "about:blank?q=BLANKSECRET", Some("about:blank"), "BLANKSECRET"),
5300 ("open", "ABOUT:blank?q=BLANKSECRET", Some("about:blank"), "BLANKSECRET"),
5301 ("goto", "About:blank", Some("about:blank"), "BLANKSECRET"),
5302 (
5303 "open",
5304 "DATA:text/html,DATASECRET",
5305 Some("data:"),
5306 "DATASECRET",
5307 ),
5308 (
5309 "open",
5310 "about:srcdoc,ABOUTSECRET",
5311 Some("about:"),
5312 "ABOUTSECRET",
5313 ),
5314 ("goto", "file:/tmp/PATHSECRET", Some("file:"), "PATHSECRET"),
5315 (
5316 "open",
5317 "https:example.com/PATHSECRET",
5318 Some("https:"),
5319 "PATHSECRET",
5320 ),
5321 ("goto", "example.com/PATHSECRET", None, "PATHSECRET"),
5322 ("open", "not a url PATHSECRET", None, "PATHSECRET"),
5323 ("goto", "HOSTSECRET3:8080/path", None, "HOSTSECRET3"),
5324 ("open", "host:8080/x", None, "host:"),
5325 (
5326 "goto",
5327 "USERSECRET5:PASSSECRET5@127.0.0.1/form",
5328 None,
5329 "USERSECRET5",
5330 ),
5331 (
5332 "open",
5333 "USERSECRET5:PASSSECRET5@127.0.0.1/form",
5334 None,
5335 "PASSSECRET5",
5336 ),
5337 ("goto", "user:pw@host/x", None, "user:"),
5338 ("open", "foo:bar", None, "foo:"),
5339 ("goto", "foo://example.com/FOOSECRET", None, "FOOSECRET"),
5340 (
5341 "open",
5342 "view-source:https://example.com/VIEWSECRET",
5343 Some("view-source:"),
5344 "VIEWSECRET",
5345 ),
5346 ];
5347 for (kind, raw, expect, secret) in opaque {
5348 let note = ActionNote {
5349 kind: kind.to_string(),
5350 url: Some(raw.to_string()),
5351 ..ActionNote::default()
5352 };
5353 let params = note.start_params();
5354 match expect {
5355 Some(url) => assert_eq!(params["url"], url, "{raw}"),
5356 None => assert!(params.get("url").is_none(), "{raw} {params}"),
5357 }
5358 assert!(!params.to_string().contains(secret), "{raw} {params}");
5359 }
5360 let eval_failed = ActionNote {
5361 kind: "eval".to_string(),
5362 tab: Some("T".to_string()),
5363 ..ActionNote::default()
5364 };
5365 let eval_end = eval_failed.end_params(false, "Error: EVALSECRET-in-error\n");
5366 assert_eq!(eval_end["ok"], false);
5367 assert!(eval_end.get("error").is_none());
5368 assert_clean(&eval_end);
5369 let cases = [
5370 (
5371 "click",
5372 "Unknown ref \"e1\". Available: e2\n",
5373 "element not found",
5374 ),
5375 (
5376 "screenshot",
5377 "No snapshot available. Run \"snapshot\" first.\n",
5378 "no snapshot",
5379 ),
5380 (
5381 "screenshot",
5382 "screenshot: could not resolve element e1 to a visible box — the snapshot is likely stale\n",
5383 "no snapshot",
5384 ),
5385 (
5386 "click",
5387 "Error: --tab <targetId> is required. Run 'playwright-cli tab-list' to get tab IDs.\n",
5388 "unknown tab",
5389 ),
5390 (
5391 "goto",
5392 "navigate: refused https://app.example/form?q=QUERYSECRET#FRAGSECRET\n",
5393 "navigation failed",
5394 ),
5395 ("click", "Page.navigate timed out\n", "timeout"),
5396 (
5397 "open",
5398 "websocket closed 1011 the browser went away\n",
5399 "connection lost",
5400 ),
5401 (
5402 "request",
5403 "curlwright: no open tab is on https://app.example — pass --tab.\nhttps://other.example/dash?q=QUERYSECRET\n",
5404 "not allowed",
5405 ),
5406 (
5407 "request",
5408 "curlwright: (22) The requested URL returned error: 500\n",
5409 "failed",
5410 ),
5411 ];
5412 for (kind, stderr, code) in cases {
5413 let note = ActionNote {
5414 kind: kind.to_string(),
5415 ..ActionNote::default()
5416 };
5417 let ended = note.end_params(false, stderr);
5418 assert_eq!(ended["error"], code, "{stderr}");
5419 assert_clean(&ended);
5420 }
5421 }
5422
5423 fn assert_clean(value: &serde_json::Value) {
5424 let rendered = value.to_string();
5425 for secret in [
5426 "QUERYSECRET",
5427 "FRAGSECRET",
5428 "EVALSECRET",
5429 "user:pass",
5430 "tok=x",
5431 "p%40ss",
5432 ] {
5433 assert!(!rendered.contains(secret), "{rendered}");
5434 }
5435 }
5436}