1use crate::cdp::{self, Cdp};
2use crate::net::{base64_decode, base64_encode};
3use crate::protected::Protected;
4use serde_json::{json, Value};
5use std::ops::{Deref, DerefMut};
6use std::time::Duration;
7
8pub const SIGNED_IN_HTML: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>Signed in</title></head><body style=\"font-family:system-ui,sans-serif;margin:4rem auto;max-width:28rem;text-align:center\"><h1>Signed in</h1><p>You can close this tab.</p></body></html>";
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Stage {
12 Request,
13 Response,
14}
15
16impl Stage {
17 fn as_str(self) -> &'static str {
18 match self {
19 Stage::Request => "Request",
20 Stage::Response => "Response",
21 }
22 }
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct Pattern {
27 pub url: String,
28 pub stage: Stage,
29 pub resource_type: Option<String>,
30}
31
32impl Pattern {
33 pub fn request(url: impl Into<String>) -> Self {
34 Self {
35 url: url.into(),
36 stage: Stage::Request,
37 resource_type: None,
38 }
39 }
40
41 pub fn response(url: impl Into<String>) -> Self {
42 Self {
43 url: url.into(),
44 stage: Stage::Response,
45 resource_type: None,
46 }
47 }
48
49 pub fn resource_type(mut self, kind: impl Into<String>) -> Self {
50 self.resource_type = Some(kind.into());
51 self
52 }
53
54 fn to_json(&self) -> Value {
55 let mut value = json!({ "urlPattern": self.url, "requestStage": self.stage.as_str() });
56 if let Some(kind) = &self.resource_type {
57 value["resourceType"] = json!(kind);
58 }
59 value
60 }
61}
62
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
64pub struct Paused {
65 pub session_id: String,
66 pub request_id: String,
67 pub frame_id: String,
68 pub resource_type: String,
69 pub network_id: Option<String>,
70 pub method: String,
71 pub url: String,
72 pub url_fragment: Option<String>,
73 pub headers: Vec<(String, String)>,
74 pub post_data: Option<Vec<u8>>,
75 pub status: Option<u16>,
76 pub status_text: Option<String>,
77 pub response_headers: Option<Vec<(String, String)>>,
78 pub error_reason: Option<String>,
79}
80
81impl Paused {
82 pub fn from_event(event: &Value) -> Option<Self> {
83 if event.get("method").and_then(|v| v.as_str()) != Some("Fetch.requestPaused") {
84 return None;
85 }
86 let params = event.get("params")?;
87 let request = params.get("request")?;
88 let text =
89 |value: &Value, key: &str| value.get(key).and_then(|v| v.as_str()).map(str::to_string);
90 let post_data = match request.get("postData").and_then(|v| v.as_str()) {
91 Some(data) => Some(data.as_bytes().to_vec()),
92 None => request
93 .get("postDataEntries")
94 .and_then(|v| v.as_array())
95 .map(|entries| {
96 let mut bytes = Vec::new();
97 for entry in entries {
98 if let Some(chunk) = entry.get("bytes").and_then(|v| v.as_str()) {
99 bytes.extend(base64_decode(chunk).unwrap_or_default());
100 }
101 }
102 bytes
103 }),
104 };
105 let status = params
106 .get("responseStatusCode")
107 .and_then(|v| v.as_u64())
108 .and_then(|code| u16::try_from(code).ok());
109 let response_headers = params
110 .get("responseHeaders")
111 .and_then(|v| v.as_array())
112 .map(|items| {
113 items
114 .iter()
115 .filter_map(|item| Some((text(item, "name")?, text(item, "value")?)))
116 .collect()
117 });
118 Some(Self {
119 session_id: text(event, "sessionId").unwrap_or_default(),
120 request_id: text(params, "requestId")?,
121 frame_id: text(params, "frameId").unwrap_or_default(),
122 resource_type: text(params, "resourceType").unwrap_or_default(),
123 network_id: text(params, "networkId"),
124 method: text(request, "method").unwrap_or_else(|| "GET".to_string()),
125 url: text(request, "url")?,
126 url_fragment: text(request, "urlFragment").filter(|fragment| !fragment.is_empty()),
127 headers: request
128 .get("headers")
129 .and_then(|v| v.as_object())
130 .map(|object| {
131 object
132 .iter()
133 .map(|(name, value)| {
134 (name.clone(), value.as_str().unwrap_or("").to_string())
135 })
136 .collect()
137 })
138 .unwrap_or_default(),
139 post_data,
140 status,
141 status_text: text(params, "responseStatusText"),
142 response_headers,
143 error_reason: text(params, "responseErrorReason"),
144 })
145 }
146
147 pub fn is_response(&self) -> bool {
148 self.status.is_some() || self.error_reason.is_some()
149 }
150
151 pub fn full_url(&self) -> String {
152 match &self.url_fragment {
153 Some(fragment) => format!("{}{fragment}", self.url),
154 None => self.url.clone(),
155 }
156 }
157
158 pub fn header(&self, name: &str) -> Option<&str> {
159 find(&self.headers, name)
160 }
161
162 pub fn response_header(&self, name: &str) -> Option<&str> {
163 find(self.response_headers.as_deref()?, name)
164 }
165
166 pub fn location(&self) -> Option<String> {
167 let raw = self.response_header("location")?;
168 let base = url::Url::parse(&self.url).ok()?;
169 Some(
170 base.join(raw)
171 .map(|url| url.to_string())
172 .unwrap_or_else(|_| raw.to_string()),
173 )
174 }
175}
176
177fn find<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
178 headers
179 .iter()
180 .find(|(have, _)| have.eq_ignore_ascii_case(name))
181 .map(|(_, value)| value.as_str())
182}
183
184#[derive(Clone, Debug, Default, PartialEq, Eq)]
185pub struct Overrides {
186 pub url: Option<String>,
187 pub method: Option<String>,
188 pub post_data: Option<Vec<u8>>,
189 pub headers: Option<Vec<(String, String)>>,
190}
191
192#[derive(Clone, Debug, Default, PartialEq, Eq)]
193pub struct ResponseOverrides {
194 pub status: Option<u16>,
195 pub status_text: Option<String>,
196 pub headers: Option<Vec<(String, String)>>,
197}
198
199#[derive(Clone, Debug, PartialEq, Eq)]
200pub struct Fulfill {
201 pub status: u16,
202 pub status_text: Option<String>,
203 pub headers: Vec<(String, String)>,
204 pub body: Vec<u8>,
205}
206
207impl Fulfill {
208 pub fn new(status: u16) -> Self {
209 Self {
210 status,
211 status_text: None,
212 headers: Vec::new(),
213 body: Vec::new(),
214 }
215 }
216
217 pub fn html(status: u16, body: impl Into<Vec<u8>>) -> Self {
218 Self::new(status)
219 .header("Content-Type", "text/html; charset=utf-8")
220 .body(body)
221 }
222
223 pub fn signed_in() -> Self {
224 Self::html(200, SIGNED_IN_HTML).header("Cache-Control", "no-store")
225 }
226
227 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
228 self.headers.push((name.into(), value.into()));
229 self
230 }
231
232 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
233 self.body = body.into();
234 self
235 }
236}
237
238#[derive(Clone, Copy, Debug, PartialEq, Eq)]
239pub enum ErrorReason {
240 Failed,
241 Aborted,
242 TimedOut,
243 AccessDenied,
244 ConnectionClosed,
245 ConnectionReset,
246 ConnectionRefused,
247 ConnectionAborted,
248 ConnectionFailed,
249 NameNotResolved,
250 InternetDisconnected,
251 AddressUnreachable,
252 BlockedByClient,
253 BlockedByResponse,
254}
255
256impl ErrorReason {
257 pub fn as_str(self) -> &'static str {
258 match self {
259 ErrorReason::Failed => "Failed",
260 ErrorReason::Aborted => "Aborted",
261 ErrorReason::TimedOut => "TimedOut",
262 ErrorReason::AccessDenied => "AccessDenied",
263 ErrorReason::ConnectionClosed => "ConnectionClosed",
264 ErrorReason::ConnectionReset => "ConnectionReset",
265 ErrorReason::ConnectionRefused => "ConnectionRefused",
266 ErrorReason::ConnectionAborted => "ConnectionAborted",
267 ErrorReason::ConnectionFailed => "ConnectionFailed",
268 ErrorReason::NameNotResolved => "NameNotResolved",
269 ErrorReason::InternetDisconnected => "InternetDisconnected",
270 ErrorReason::AddressUnreachable => "AddressUnreachable",
271 ErrorReason::BlockedByClient => "BlockedByClient",
272 ErrorReason::BlockedByResponse => "BlockedByResponse",
273 }
274 }
275
276 pub fn parse(text: &str) -> Option<Self> {
277 let all = [
278 ErrorReason::Failed,
279 ErrorReason::Aborted,
280 ErrorReason::TimedOut,
281 ErrorReason::AccessDenied,
282 ErrorReason::ConnectionClosed,
283 ErrorReason::ConnectionReset,
284 ErrorReason::ConnectionRefused,
285 ErrorReason::ConnectionAborted,
286 ErrorReason::ConnectionFailed,
287 ErrorReason::NameNotResolved,
288 ErrorReason::InternetDisconnected,
289 ErrorReason::AddressUnreachable,
290 ErrorReason::BlockedByClient,
291 ErrorReason::BlockedByResponse,
292 ];
293 all.into_iter()
294 .find(|reason| reason.as_str().eq_ignore_ascii_case(text))
295 }
296}
297
298#[derive(Clone, Debug, PartialEq, Eq)]
299pub enum Decision {
300 Continue(Overrides),
301 ContinueResponse(ResponseOverrides),
302 Fulfill(Fulfill),
303 Fail(ErrorReason),
304}
305
306impl Default for Decision {
307 fn default() -> Self {
308 Decision::Continue(Overrides::default())
309 }
310}
311
312#[derive(Clone, Debug, PartialEq, Eq)]
313pub struct Navigation {
314 pub session_id: String,
315 pub frame_id: String,
316 pub main_frame: bool,
317 pub url: String,
318}
319
320pub fn reaches_protected(guard: &Protected, paused: &Paused) -> bool {
321 guard.url(&paused.full_url()) || paused.location().is_some_and(|url| guard.url(&url))
322}
323
324pub fn navigation(guard: &Protected, event: &Value) -> Option<Navigation> {
325 if event.get("method").and_then(|v| v.as_str()) != Some("Page.frameNavigated") {
326 return None;
327 }
328 let frame = event.pointer("/params/frame")?;
329 let base = frame.get("url").and_then(|v| v.as_str())?;
330 let fragment = frame
331 .get("urlFragment")
332 .and_then(|v| v.as_str())
333 .unwrap_or("");
334 let url = format!("{base}{fragment}");
335 if guard.url(&url) || guard.url(base) {
336 return None;
337 }
338 Some(Navigation {
339 session_id: event
340 .get("sessionId")
341 .and_then(|v| v.as_str())
342 .unwrap_or("")
343 .to_string(),
344 frame_id: frame
345 .get("id")
346 .and_then(|v| v.as_str())
347 .unwrap_or("")
348 .to_string(),
349 main_frame: frame.get("parentId").is_none(),
350 url,
351 })
352}
353
354fn header_list(headers: &[(String, String)]) -> Value {
355 Value::Array(
356 headers
357 .iter()
358 .map(|(name, value)| json!({ "name": name, "value": value }))
359 .collect(),
360 )
361}
362
363fn refresh_target(value: &str) -> &str {
364 let rest = value.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
365 let rest = rest.trim_start_matches(|ch: char| ch.is_ascii_digit() || ch == '.');
366 let rest = rest.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
367 let rest = rest
368 .strip_prefix(';')
369 .or_else(|| rest.strip_prefix(','))
370 .unwrap_or(rest);
371 let rest = rest.trim_start_matches(|ch: char| ch.is_ascii_whitespace());
372 let rest = match rest.get(..3) {
373 Some(head) if head.eq_ignore_ascii_case("url") => {
374 let after = rest[3..].trim_start_matches(|ch: char| ch.is_ascii_whitespace());
375 match after.strip_prefix('=') {
376 Some(value) => value.trim_start_matches(|ch: char| ch.is_ascii_whitespace()),
377 None => rest,
378 }
379 }
380 _ => rest,
381 };
382 let rest = rest.trim_end_matches(|ch: char| ch.is_ascii_whitespace());
383 match rest.chars().next() {
384 Some(quote @ ('\'' | '"')) => {
385 let inner = &rest[1..];
386 inner.split(quote).next().unwrap_or(inner)
387 }
388 _ => rest,
389 }
390}
391
392fn redirects_to_protected(guard: &Protected, base: &str, headers: &[(String, String)]) -> bool {
393 let base = url::Url::parse(base).ok();
394 let reaches = |target: &str| {
395 let resolved = base
396 .as_ref()
397 .and_then(|base| base.join(target).ok())
398 .map(|url| url.to_string())
399 .unwrap_or_else(|| target.to_string());
400 guard.url(&resolved) || guard.url(target)
401 };
402 headers.iter().any(|(name, value)| {
403 if name.eq_ignore_ascii_case("location") {
404 reaches(value) || guard.pattern(value)
405 } else if name.eq_ignore_ascii_case("refresh") {
406 reaches(refresh_target(value)) || guard.pattern(value)
407 } else {
408 false
409 }
410 })
411}
412
413pub fn command(
414 guard: &Protected,
415 paused: &Paused,
416 decision: &Decision,
417) -> Result<(&'static str, Value), String> {
418 let id = paused.request_id.as_str();
419 match decision {
420 Decision::Continue(overrides) => {
421 let mut params = json!({ "requestId": id });
422 if let Some(url) = &overrides.url {
423 if guard.url(url) {
424 return Err(format!("{}\n", guard.refusal()));
425 }
426 params["url"] = json!(url);
427 }
428 if let Some(method) = &overrides.method {
429 params["method"] = json!(method);
430 }
431 if let Some(data) = &overrides.post_data {
432 params["postData"] = json!(base64_encode(data));
433 }
434 if let Some(headers) = &overrides.headers {
435 params["headers"] = header_list(headers);
436 }
437 Ok(("Fetch.continueRequest", params))
438 }
439 Decision::ContinueResponse(overrides) => {
440 if !paused.is_response() {
441 return Err(
442 "Fetch.continueResponse: the request is not at the response stage\n".into(),
443 );
444 }
445 let mut params = json!({ "requestId": id });
446 if let Some(status) = overrides.status {
447 params["responseCode"] = json!(status);
448 }
449 if let Some(text) = &overrides.status_text {
450 params["responsePhrase"] = json!(text);
451 }
452 if let Some(headers) = &overrides.headers {
453 if redirects_to_protected(guard, &paused.url, headers) {
454 return Err(format!("{}\n", guard.refusal()));
455 }
456 params["responseHeaders"] = header_list(headers);
457 }
458 Ok(("Fetch.continueResponse", params))
459 }
460 Decision::Fulfill(fulfill) => {
461 if redirects_to_protected(guard, &paused.url, &fulfill.headers) {
462 return Err(format!("{}\n", guard.refusal()));
463 }
464 let mut params = json!({
465 "requestId": id,
466 "responseCode": fulfill.status,
467 "responseHeaders": header_list(&fulfill.headers),
468 "body": base64_encode(&fulfill.body),
469 });
470 if let Some(text) = &fulfill.status_text {
471 params["responsePhrase"] = json!(text);
472 }
473 Ok(("Fetch.fulfillRequest", params))
474 }
475 Decision::Fail(reason) => Ok((
476 "Fetch.failRequest",
477 json!({ "requestId": id, "errorReason": reason.as_str() }),
478 )),
479 }
480}
481
482fn need_session(session_id: &str) -> Result<(), String> {
483 if session_id.is_empty() {
484 return Err("Fetch needs a target session: browser-wide interception is refused\n".into());
485 }
486 Ok(())
487}
488
489pub fn enable(cdp: &mut Cdp, session_id: &str, patterns: &[Pattern]) -> Result<(), String> {
490 need_session(session_id)?;
491 if patterns.is_empty() {
492 return Err("Fetch.enable needs at least one pattern\n".into());
493 }
494 if let Some(pattern) = patterns
495 .iter()
496 .find(|pattern| cdp.guard().pattern(&pattern.url))
497 {
498 return Err(format!("{} ({})\n", cdp.guard().refusal(), pattern.url));
499 }
500 let list: Vec<Value> = patterns.iter().map(Pattern::to_json).collect();
501 cdp.call(
502 "Fetch.enable",
503 json!({ "patterns": list, "handleAuthRequests": false }),
504 Some(session_id),
505 )?;
506 Ok(())
507}
508
509pub fn disable(cdp: &mut Cdp, session_id: &str) {
510 cdp.forget_paused(session_id);
511 if session_id.is_empty() {
512 return;
513 }
514 let _ = cdp.call_for(
515 "Fetch.disable",
516 json!({}),
517 Some(session_id),
518 Duration::from_secs(5),
519 );
520}
521
522pub fn decide(cdp: &mut Cdp, paused: &Paused, decision: &Decision) -> Result<(), String> {
523 let (method, params) = command(cdp.guard(), paused, decision)?;
524 cdp.call(method, params, Some(&paused.session_id))?;
525 Ok(())
526}
527
528pub fn continue_request(
529 cdp: &mut Cdp,
530 paused: &Paused,
531 overrides: Overrides,
532) -> Result<(), String> {
533 decide(cdp, paused, &Decision::Continue(overrides))
534}
535
536pub fn continue_response(
537 cdp: &mut Cdp,
538 paused: &Paused,
539 overrides: ResponseOverrides,
540) -> Result<(), String> {
541 decide(cdp, paused, &Decision::ContinueResponse(overrides))
542}
543
544pub fn fulfill(cdp: &mut Cdp, paused: &Paused, response: Fulfill) -> Result<(), String> {
545 decide(cdp, paused, &Decision::Fulfill(response))
546}
547
548pub fn fail(cdp: &mut Cdp, paused: &Paused, reason: ErrorReason) -> Result<(), String> {
549 decide(cdp, paused, &Decision::Fail(reason))
550}
551
552pub fn response_body(cdp: &mut Cdp, paused: &Paused) -> Result<Vec<u8>, String> {
553 if !paused.is_response() {
554 return Err("Fetch.getResponseBody: the request is not at the response stage\n".into());
555 }
556 let result = cdp.call(
557 "Fetch.getResponseBody",
558 json!({ "requestId": paused.request_id }),
559 Some(&paused.session_id),
560 )?;
561 let body = result.get("body").and_then(|v| v.as_str()).unwrap_or("");
562 if result.get("base64Encoded").and_then(|v| v.as_bool()) == Some(true) {
563 base64_decode(body)
564 } else {
565 Ok(body.as_bytes().to_vec())
566 }
567}
568
569pub fn target_protected(cdp: &mut Cdp, target_id: &str) -> Result<bool, String> {
570 cdp.target_protected(target_id, false)
571}
572
573pub fn refuse_target(cdp: &mut Cdp, target_id: &str) -> Result<(), String> {
574 if target_protected(cdp, target_id)? {
575 return Err(format!("{}\n", cdp.guard().refusal()));
576 }
577 Ok(())
578}
579
580pub struct Interception<'a> {
581 cdp: &'a mut Cdp,
582 session_id: String,
583 owned: bool,
584 done: bool,
585}
586
587impl<'a> Interception<'a> {
588 pub fn attach(cdp: &'a mut Cdp, target_id: &str, patterns: &[Pattern]) -> Result<Self, String> {
589 refuse_target(cdp, target_id)?;
590 let session_id = cdp::attach(cdp, target_id)?;
591 if let Err(message) = enable(cdp, &session_id, patterns) {
592 cdp::detach(cdp, &session_id);
593 return Err(message);
594 }
595 Ok(Self {
596 cdp,
597 session_id,
598 owned: true,
599 done: false,
600 })
601 }
602
603 pub fn on_session(
604 cdp: &'a mut Cdp,
605 target_id: &str,
606 session_id: &str,
607 patterns: &[Pattern],
608 ) -> Result<Self, String> {
609 refuse_target(cdp, target_id)?;
610 enable(cdp, session_id, patterns)?;
611 Ok(Self {
612 cdp,
613 session_id: session_id.to_string(),
614 owned: false,
615 done: false,
616 })
617 }
618
619 pub fn session_id(&self) -> &str {
620 &self.session_id
621 }
622
623 pub fn next(&mut self, timeout: Duration) -> Result<Option<Paused>, String> {
624 let session = self.session_id.clone();
625 self.cdp.next_paused(Some(&session), timeout)
626 }
627
628 pub fn decide(&mut self, paused: &Paused, decision: &Decision) -> Result<(), String> {
629 decide(self.cdp, paused, decision)
630 }
631
632 pub fn response_body(&mut self, paused: &Paused) -> Result<Vec<u8>, String> {
633 response_body(self.cdp, paused)
634 }
635
636 pub fn finish(mut self) {
637 self.close();
638 }
639
640 fn close(&mut self) {
641 if self.done {
642 return;
643 }
644 self.done = true;
645 disable(self.cdp, &self.session_id);
646 if self.owned {
647 cdp::detach(self.cdp, &self.session_id);
648 }
649 }
650}
651
652impl Deref for Interception<'_> {
653 type Target = Cdp;
654
655 fn deref(&self) -> &Cdp {
656 self.cdp
657 }
658}
659
660impl DerefMut for Interception<'_> {
661 fn deref_mut(&mut self) -> &mut Cdp {
662 self.cdp
663 }
664}
665
666impl Drop for Interception<'_> {
667 fn drop(&mut self) {
668 self.close();
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use crate::cdp::Transport;
676 use std::cell::RefCell;
677 use std::collections::VecDeque;
678 use std::rc::Rc;
679 use std::time::Instant;
680
681 type Reply = Box<dyn FnMut(&Value) -> Vec<Value>>;
682
683 struct Fake {
684 log: Rc<RefCell<Vec<Value>>>,
685 inbox: VecDeque<String>,
686 reply: Reply,
687 closed: Option<String>,
688 }
689
690 impl Transport for Fake {
691 fn send_text(&mut self, text: &str) -> Result<(), String> {
692 if let Some(closed) = &self.closed {
693 return Err(closed.clone());
694 }
695 let value: Value = serde_json::from_str(text).unwrap();
696 self.log.borrow_mut().push(value.clone());
697 for out in (self.reply)(&value) {
698 self.inbox.push_back(out.to_string());
699 }
700 Ok(())
701 }
702
703 fn recv_text(&mut self, _deadline: Instant) -> Result<Option<String>, String> {
704 if let Some(closed) = &self.closed {
705 return Err(closed.clone());
706 }
707 let next = self.inbox.pop_front();
708 if let Some(text) = &next {
709 if let Some(reason) = serde_json::from_str::<Value>(text)
710 .ok()
711 .and_then(|value| value.get("close").and_then(|v| v.as_str()).map(str::to_string))
712 {
713 self.closed = Some(reason.clone());
714 return Err(reason);
715 }
716 }
717 Ok(next)
718 }
719 }
720
721 fn fake(inbox: Vec<Value>, reply: Reply) -> (Cdp, Rc<RefCell<Vec<Value>>>) {
722 let log = Rc::new(RefCell::new(Vec::new()));
723 let transport = Fake {
724 log: log.clone(),
725 inbox: inbox.into_iter().map(|value| value.to_string()).collect(),
726 reply,
727 closed: None,
728 };
729 (Cdp::from_transport(Box::new(transport)), log)
730 }
731
732 fn ok(message: &Value) -> Value {
733 json!({ "id": message["id"], "result": {} })
734 }
735
736 fn methods(log: &Rc<RefCell<Vec<Value>>>) -> Vec<String> {
737 log.borrow()
738 .iter()
739 .map(|item| item["method"].as_str().unwrap_or("").to_string())
740 .collect()
741 }
742
743 fn paused_event(session: &str, id: &str, url: &str) -> Value {
744 json!({
745 "method": "Fetch.requestPaused",
746 "sessionId": session,
747 "params": {
748 "requestId": id,
749 "frameId": "F1",
750 "resourceType": "Document",
751 "networkId": "N1",
752 "request": {
753 "url": url,
754 "method": "GET",
755 "headers": { "Accept": "text/html", "Cookie": "a=b" }
756 }
757 }
758 })
759 }
760
761 #[test]
762 fn a_paused_request_and_response_are_read() {
763 let mut event = paused_event("S", "R1", "https://idp.example/cb?code=abc");
764 event["params"]["request"]["urlFragment"] = json!("#access_token=xyz");
765 event["params"]["request"]["method"] = json!("POST");
766 event["params"]["request"]["postDataEntries"] =
767 json!([{ "bytes": base64_encode(b"a=1&") }, { "bytes": base64_encode(b"b=2") }]);
768 let paused = Paused::from_event(&event).unwrap();
769 assert_eq!(paused.session_id, "S");
770 assert_eq!(paused.request_id, "R1");
771 assert_eq!(paused.method, "POST");
772 assert_eq!(
773 paused.full_url(),
774 "https://idp.example/cb?code=abc#access_token=xyz"
775 );
776 assert_eq!(paused.header("cookie"), Some("a=b"));
777 assert_eq!(paused.post_data.as_deref(), Some(&b"a=1&b=2"[..]));
778 assert!(!paused.is_response());
779 assert_eq!(paused.location(), None);
780 let mut response = paused_event("S", "R2", "https://idp.example/authorize");
781 response["params"]["responseStatusCode"] = json!(302);
782 response["params"]["responseStatusText"] = json!("Found");
783 response["params"]["responseHeaders"] =
784 json!([{ "name": "Location", "value": "/done#id_token=t&state=s" }]);
785 let paused = Paused::from_event(&response).unwrap();
786 assert!(paused.is_response());
787 assert_eq!(paused.status, Some(302));
788 assert_eq!(
789 paused.response_header("location"),
790 Some("/done#id_token=t&state=s")
791 );
792 assert_eq!(
793 paused.location().as_deref(),
794 Some("https://idp.example/done#id_token=t&state=s")
795 );
796 assert!(Paused::from_event(&json!({ "method": "Page.loadEventFired" })).is_none());
797 }
798
799 #[test]
800 fn a_decider_answers_a_pause_that_arrives_inside_a_call() {
801 let navigate = Rc::new(RefCell::new(None));
802 let held = navigate.clone();
803 let (mut cdp, log) = fake(
804 Vec::new(),
805 Box::new(move |message| match message["method"].as_str().unwrap() {
806 "Page.navigate" => {
807 *held.borrow_mut() = Some(message["id"].clone());
808 vec![paused_event("S", "R1", "https://app.example/api/users")]
809 }
810 "Fetch.fulfillRequest" => vec![
811 ok(message),
812 json!({ "id": held.borrow().clone().unwrap(), "result": { "frameId": "F1", "loaderId": "L" } }),
813 ],
814 _ => vec![ok(message)],
815 }),
816 );
817 cdp.set_decider(
818 "S",
819 Some(Box::new(|paused: &Paused| {
820 assert_eq!(paused.url, "https://app.example/api/users");
821 Decision::Fulfill(
822 Fulfill::new(201)
823 .header("Content-Type", "application/json")
824 .body("[]"),
825 )
826 })),
827 );
828 let result = cdp
829 .call(
830 "Page.navigate",
831 json!({ "url": "https://app.example/" }),
832 Some("S"),
833 )
834 .unwrap();
835 assert_eq!(result["loaderId"], "L");
836 let sent = log.borrow();
837 let fulfil = sent
838 .iter()
839 .find(|item| item["method"] == "Fetch.fulfillRequest")
840 .unwrap();
841 assert_eq!(fulfil["sessionId"], "S");
842 assert_eq!(fulfil["params"]["requestId"], "R1");
843 assert_eq!(fulfil["params"]["responseCode"], 201);
844 assert_eq!(fulfil["params"]["body"], base64_encode(b"[]"));
845 assert_eq!(
846 fulfil["params"]["responseHeaders"],
847 json!([{ "name": "Content-Type", "value": "application/json" }])
848 );
849 assert!(cdp.events().is_empty());
850 assert!(cdp.take_fetch_errors().is_empty());
851 }
852
853 #[test]
854 fn a_failed_decision_falls_back_to_continue() {
855 let (mut cdp, log) = fake(
856 vec![paused_event("S", "R1", "https://app.example/x")],
857 Box::new(|message| match message["method"].as_str().unwrap() {
858 "Fetch.fulfillRequest" => {
859 vec![json!({ "id": message["id"], "error": { "message": "Invalid header" } })]
860 }
861 _ => vec![ok(message)],
862 }),
863 );
864 cdp.set_decider(
865 "S",
866 Some(Box::new(|_: &Paused| {
867 Decision::Fulfill(Fulfill::new(200).header("Bad\n", "x"))
868 })),
869 );
870 cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
871 assert!(cdp
872 .pump(Instant::now() + Duration::from_millis(10))
873 .unwrap());
874 assert_eq!(
875 methods(&log),
876 [
877 "Runtime.evaluate",
878 "Fetch.fulfillRequest",
879 "Fetch.continueRequest"
880 ]
881 );
882 assert_eq!(cdp.take_fetch_errors(), ["Fetch: Invalid header\n"]);
883 }
884
885 #[test]
886 fn seven_and_vault_requests_are_never_handed_out() {
887 let mut location = paused_event("S", "R3", "https://idp.example/authorize");
888 location["params"]["responseStatusCode"] = json!(302);
889 location["params"]["responseHeaders"] =
890 json!([{ "name": "location", "value": "https://vault.sliccy.ai/cb#token=t" }]);
891 let mut xhr = paused_event("S", "R2", "blob:https://vault.sliccy.ai/0b9e");
892 xhr["params"]["resourceType"] = json!("XHR");
893 let (mut cdp, log) = fake(
894 vec![
895 paused_event("S", "R1", "https://seven.sliccy.ai/api"),
896 xhr,
897 location,
898 paused_event("S", "R4", "https://app.example/ok"),
899 ],
900 Box::new(|message| vec![ok(message)]),
901 );
902 let got = cdp
903 .next_paused(Some("S"), Duration::from_millis(50))
904 .unwrap()
905 .unwrap();
906 assert_eq!(got.request_id, "R4");
907 assert!(cdp
908 .next_paused(Some("S"), Duration::from_millis(10))
909 .unwrap()
910 .is_none());
911 let sent = log.borrow();
912 let continued: Vec<&str> = sent
913 .iter()
914 .filter(|item| item["method"] == "Fetch.continueRequest")
915 .map(|item| item["params"]["requestId"].as_str().unwrap())
916 .collect();
917 assert_eq!(continued, ["R2"]);
918 let failed: Vec<(&str, &str)> = sent
919 .iter()
920 .filter(|item| item["method"] == "Fetch.failRequest")
921 .map(|item| {
922 (
923 item["params"]["requestId"].as_str().unwrap(),
924 item["params"]["errorReason"].as_str().unwrap(),
925 )
926 })
927 .collect();
928 assert_eq!(failed, [("R1", "BlockedByClient"), ("R3", "BlockedByClient")]);
929 assert!(sent.iter().all(|item| item["params"].get("url").is_none()));
930 assert!(cdp.events().is_empty());
931 }
932
933 #[test]
934 fn a_decider_never_sees_a_protected_request() {
935 let (mut cdp, log) = fake(
936 vec![paused_event(
937 "S",
938 "R1",
939 "https://www.sliccy.ai/auth/callback?code=c",
940 )],
941 Box::new(|message| vec![ok(message)]),
942 );
943 cdp.set_decider(
944 "S",
945 Some(Box::new(|_: &Paused| {
946 panic!("a protected request reached the decider")
947 })),
948 );
949 cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
950 assert_eq!(methods(&log), ["Runtime.evaluate", "Fetch.failRequest"]);
951 }
952
953 fn auth_event(session: &str, id: &str, url: &str) -> Value {
954 json!({
955 "method": "Fetch.authRequired",
956 "sessionId": session,
957 "params": {
958 "requestId": id,
959 "frameId": "F1",
960 "resourceType": "Fetch",
961 "request": { "url": url, "method": "GET", "headers": {} },
962 "authChallenge": { "source": "Server", "origin": url, "scheme": "basic", "realm": "r" }
963 }
964 })
965 }
966
967 fn refused(result: Result<Value, String>) -> bool {
968 result.is_err_and(|message| message == crate::cdp::UNSEEN)
969 }
970
971 #[test]
972 fn an_issued_id_is_good_on_its_session_until_it_is_answered() {
973 let mut xhr = paused_event("S", "R1", "https://app.example/api");
974 xhr["params"]["resourceType"] = json!("XHR");
975 let mut response = xhr.clone();
976 response["params"]["responseStatusCode"] = json!(200);
977 let (mut cdp, log) = fake(vec![xhr], Box::new(|message| vec![ok(message)]));
978 let got = cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().unwrap();
979 assert_eq!(got.request_id, "R1");
980 assert!(refused(cdp.call("Fetch.fulfillRequest", json!({ "requestId": "R2", "responseCode": 200 }), Some("S"))));
981 assert!(refused(cdp.call("Fetch.continueRequest", json!({ "requestId": "R1" }), Some("T"))));
982 assert!(refused(cdp.call("Fetch.continueRequest", json!({}), Some("S"))));
983 cdp.call("Fetch.getResponseBody", json!({ "requestId": "R1" }), Some("S")).unwrap();
984 cdp.call("Fetch.continueRequest", json!({ "requestId": "R1" }), Some("S")).unwrap();
985 for (method, params) in [
986 ("Fetch.failRequest", json!({ "requestId": "R1", "errorReason": "Failed" })),
987 ("Fetch.fulfillRequest", json!({ "requestId": "R1", "responseCode": 200 })),
988 ("Fetch.continueResponse", json!({ "requestId": "R1" })),
989 ("Fetch.continueWithAuth", json!({ "requestId": "R1", "authChallengeResponse": { "response": "Default" } })),
990 ] {
991 assert!(refused(cdp.call(method, params, Some("S"))), "{method}");
992 }
993 assert!(refused(cdp.notify("Fetch.continueRequest", json!({ "requestId": "R1" }), Some("S")).map(|_| Value::Null)));
994 assert_eq!(methods(&log), ["Fetch.getResponseBody", "Fetch.continueRequest"]);
995 let (mut cdp, log) = fake(vec![response], Box::new(|message| vec![ok(message)]));
996 let again = cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().unwrap();
997 assert!(again.is_response());
998 cdp.call("Fetch.continueResponse", json!({ "requestId": "R1" }), Some("S")).unwrap();
999 assert!(refused(cdp.call("Fetch.continueResponse", json!({ "requestId": "R1" }), Some("S"))));
1000 assert_eq!(methods(&log), ["Fetch.continueResponse"]);
1001 }
1002
1003 #[test]
1004 fn an_answer_chrome_rejects_leaves_the_id_issued() {
1005 let reject = |message: &Value| {
1006 if message["params"]["responseCode"] == 0 {
1007 vec![json!({ "id": message["id"], "error": { "message": "Invalid http status code" } })]
1008 } else {
1009 vec![ok(message)]
1010 }
1011 };
1012 let (mut cdp, log) = fake(
1013 vec![
1014 paused_event("S", "R1", "https://app.example/a"),
1015 paused_event("S", "R2", "https://app.example/b"),
1016 ],
1017 Box::new(reject),
1018 );
1019 while cdp.next_paused(Some("S"), Duration::from_millis(10)).unwrap().is_some() {}
1020 let bad = json!({ "requestId": "R1", "responseCode": 0 });
1021 let good = json!({ "requestId": "R1", "responseCode": 200 });
1022 assert!(cdp.call("Fetch.fulfillRequest", bad.clone(), Some("S")).unwrap_err().contains("Invalid http status"));
1023 cdp.call("Fetch.fulfillRequest", good.clone(), Some("S")).unwrap();
1024 assert!(refused(cdp.call("Fetch.fulfillRequest", good, Some("S"))));
1025 let bad = json!({ "requestId": "R2", "responseCode": 0 });
1026 cdp.notify("Fetch.fulfillRequest", bad, Some("S")).unwrap();
1027 cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
1028 cdp.notify("Fetch.continueRequest", json!({ "requestId": "R2" }), Some("S")).unwrap();
1029 cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
1030 assert!(refused(cdp.call("Fetch.continueRequest", json!({ "requestId": "R2" }), Some("S"))));
1031 assert_eq!(
1032 methods(&log),
1033 [
1034 "Fetch.fulfillRequest",
1035 "Fetch.fulfillRequest",
1036 "Fetch.fulfillRequest",
1037 "Runtime.evaluate",
1038 "Fetch.continueRequest",
1039 "Runtime.evaluate"
1040 ]
1041 );
1042 }
1043
1044 #[test]
1045 fn an_id_a_decider_answered_is_not_issued_again() {
1046 let (mut cdp, log) = fake(
1047 vec![paused_event("S", "R1", "https://app.example/x")],
1048 Box::new(|message| vec![ok(message)]),
1049 );
1050 cdp.set_decider("S", Some(Box::new(|_: &Paused| Decision::default())));
1051 cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap();
1052 assert!(refused(cdp.call("Fetch.fulfillRequest", json!({ "requestId": "R1", "responseCode": 200 }), Some("S"))));
1053 assert_eq!(methods(&log), ["Runtime.evaluate", "Fetch.continueRequest"]);
1054 }
1055
1056 #[test]
1057 fn a_protected_id_is_never_issued_even_when_guessed() {
1058 let mut xhr = paused_event("S", "R1", "https://seven.sliccy.ai/data");
1059 xhr["params"]["resourceType"] = json!("XHR");
1060 let (mut cdp, log) = fake(vec![xhr], Box::new(|message| vec![ok(message)]));
1061 assert!(cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().is_none());
1062 for method in [
1063 "Fetch.fulfillRequest",
1064 "Fetch.failRequest",
1065 "Fetch.continueRequest",
1066 "Fetch.continueWithAuth",
1067 "Fetch.continueResponse",
1068 "Fetch.getResponseBody",
1069 "Fetch.takeResponseBodyAsStream",
1070 "Network.continueInterceptedRequest",
1071 "Network.getResponseBodyForInterception",
1072 ] {
1073 assert!(refused(cdp.call(method, json!({ "requestId": "R1", "interceptionId": "R1" }), Some("S"))), "{method}");
1074 }
1075 assert_eq!(methods(&log), ["Fetch.continueRequest"]);
1076 let old = cdp
1077 .call("Network.setRequestInterception", json!({ "patterns": [{ "urlPattern": "*" }] }), Some("S"))
1078 .unwrap_err();
1079 assert!(old.starts_with("slicc: refused"), "{old}");
1080 assert_eq!(methods(&log), ["Fetch.continueRequest"]);
1081 }
1082
1083 #[test]
1084 fn a_protected_auth_challenge_gets_the_default_and_its_id_is_refused() {
1085 let (mut cdp, log) = fake(
1086 vec![
1087 auth_event("S", "A1", "https://vault.sliccy.ai/auth"),
1088 auth_event("S", "A2", "https://app.example/auth"),
1089 ],
1090 Box::new(|message| vec![ok(message)]),
1091 );
1092 while cdp.pump(Instant::now() + Duration::from_millis(10)).unwrap() {}
1093 let sent = log.borrow().clone();
1094 assert_eq!(sent.len(), 1);
1095 assert_eq!(sent[0]["method"], "Fetch.continueWithAuth");
1096 assert_eq!(sent[0]["params"]["requestId"], "A1");
1097 assert_eq!(sent[0]["params"]["authChallengeResponse"], json!({ "response": "Default" }));
1098 let delivered: Vec<&Value> = cdp.events().iter().collect();
1099 assert_eq!(delivered.len(), 1);
1100 assert_eq!(delivered[0]["params"]["requestId"], "A2");
1101 let answer = json!({ "response": "ProvideCredentials", "username": "u", "password": "p" });
1102 assert!(refused(cdp.call("Fetch.continueWithAuth", json!({ "requestId": "A1", "authChallengeResponse": answer }), Some("S"))));
1103 cdp.call("Fetch.continueWithAuth", json!({ "requestId": "A2", "authChallengeResponse": answer }), Some("S")).unwrap();
1104 assert!(refused(cdp.call("Fetch.continueWithAuth", json!({ "requestId": "A2", "authChallengeResponse": answer }), Some("S"))));
1105 assert_eq!(methods(&log), ["Fetch.continueWithAuth", "Fetch.continueWithAuth"]);
1106 }
1107
1108 fn panic_decider() -> Option<crate::cdp::Decider> {
1109 Some(Box::new(|paused: &Paused| {
1110 panic!("the agent saw {} paused", paused.url)
1111 }))
1112 }
1113
1114 #[test]
1115 fn a_tab_that_commits_seven_stops_intercepting_and_hands_out_nothing() {
1116 let mut leak = paused_event("S", "R1", "https://api.model.example/v1/chat");
1117 leak["params"]["resourceType"] = json!("Fetch");
1118 let (mut cdp, log) = fake(
1119 vec![
1120 json!({
1121 "method": "Page.frameNavigated",
1122 "sessionId": "S",
1123 "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/os/", "securityOrigin": "https://seven.sliccy.ai" } }
1124 }),
1125 leak,
1126 json!({
1127 "method": "Network.requestWillBeSent",
1128 "sessionId": "S",
1129 "params": { "requestId": "N1", "documentURL": "https://seven.sliccy.ai/os/", "request": { "url": "https://api.model.example/v1/chat", "headers": { "Authorization": "Bearer KEY" } } }
1130 }),
1131 ],
1132 Box::new(|message| vec![ok(message)]),
1133 );
1134 cdp.set_decider("S", panic_decider());
1135 while cdp.pump(Instant::now() + Duration::from_millis(10)).unwrap() {}
1136 assert!(cdp.session_blocked("S"));
1137 assert_eq!(
1138 methods(&log),
1139 ["Fetch.disable", "Target.detachFromTarget", "Fetch.continueRequest"]
1140 );
1141 assert!(log.borrow()[2]["params"].get("headers").is_none());
1142 assert!(cdp.events().is_empty(), "{:?}", cdp.events());
1143 assert!(cdp.call("Runtime.evaluate", json!({}), Some("S")).unwrap_err().starts_with("slicc: refused"));
1144 }
1145
1146 #[test]
1147 fn a_worker_attaches_without_a_page_guard_and_a_page_without_page_domains_still_does() {
1148 let (mut cdp, log) = fake(
1149 Vec::new(),
1150 Box::new(|message| match message["method"].as_str().unwrap() {
1151 "Target.getTargetInfo" => {
1152 let kind = if message["params"]["targetId"] == "W" { "service_worker" } else { "page" };
1153 vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": message["params"]["targetId"], "type": kind, "url": "https://app.example/sw.js" } } })]
1154 }
1155 "Target.attachToTarget" => {
1156 vec![json!({ "id": message["id"], "result": { "sessionId": format!("S{}", message["id"]) } })]
1157 }
1158 "Page.enable" | "Runtime.enable" => vec![json!({ "id": message["id"], "error": { "message": "'Page.enable' wasn't found" } })],
1159 _ => vec![ok(message)],
1160 }),
1161 );
1162 crate::cdp::attach(&mut cdp, "W").unwrap();
1163 assert_eq!(methods(&log), ["Target.getTargetInfo", "Target.attachToTarget"]);
1164 crate::cdp::attach(&mut cdp, "P").unwrap();
1165 assert!(methods(&log).contains(&"Network.setBypassServiceWorker".to_string()));
1166 }
1167
1168 #[test]
1169 fn a_takeover_close_frame_wins_over_the_guard_refusal() {
1170 let close = "websocket closed 1011 another page took over slicc-node's /cdp (superseded-by-new-cdp-client)\n";
1171 let (mut cdp, _log) = fake(
1172 Vec::new(),
1173 Box::new(move |message| match message["method"].as_str().unwrap() {
1174 "Target.getTargetInfo" => vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T", "type": "page", "url": "https://app.example/" } } })],
1175 "Target.attachToTarget" => vec![json!({ "id": message["id"], "result": { "sessionId": "S" } })],
1176 "Target.setDiscoverTargets" => vec![json!({ "close": close })],
1177 _ => vec![ok(message)],
1178 }),
1179 );
1180 assert_eq!(crate::cdp::attach(&mut cdp, "T").unwrap_err(), close);
1181 assert_eq!(
1182 cdp.call("Runtime.evaluate", json!({ "expression": "1" }), Some("S")).unwrap_err(),
1183 close
1184 );
1185 let (mut live, _log) = fake(
1186 Vec::new(),
1187 Box::new(|message| match message["method"].as_str().unwrap() {
1188 "Target.getTargetInfo" => vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T", "type": "page", "url": "https://app.example/" } } })],
1189 "Target.attachToTarget" => vec![json!({ "id": message["id"], "result": { "sessionId": format!("S{}", message["id"]) } })],
1190 "Fetch.enable" => vec![json!({ "id": message["id"], "error": { "message": "Fetch is not available" } })],
1191 _ => vec![ok(message)],
1192 }),
1193 );
1194 assert_eq!(crate::cdp::attach(&mut live, "T").unwrap_err(), crate::cdp::UNGUARDED);
1195 }
1196
1197 #[test]
1198 fn a_target_that_landed_on_the_vault_is_reported_protected() {
1199 let (mut cdp, _log) = fake(
1200 Vec::new(),
1201 Box::new(|message| vec![json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T", "type": "page", "url": "https://vault.sliccy.ai/landing" } } })]),
1202 );
1203 assert!(!cdp.is_protected_target("T"));
1204 assert!(cdp.target_protected("T", false).unwrap());
1205 assert!(cdp.is_protected_target("T"));
1206 assert!(cdp.call("Target.attachToTarget", json!({ "targetId": "T", "flatten": true }), None).is_err());
1207 }
1208
1209 #[test]
1210 fn requests_from_a_protected_frame_or_referrer_are_continued_unseen() {
1211 let mut framed = paused_event("S", "R1", "https://api.example/a");
1212 framed["params"]["frameId"] = json!("CHILD");
1213 framed["params"]["resourceType"] = json!("XHR");
1214 let mut referred = paused_event("S", "R2", "https://api.example/b");
1215 referred["params"]["frameId"] = json!("OTHER");
1216 referred["params"]["resourceType"] = json!("XHR");
1217 referred["params"]["request"]["headers"] = json!({ "Referer": "https://vault.sliccy.ai/unlock" });
1218 let mut plain = paused_event("S", "R3", "https://api.example/c");
1219 plain["params"]["frameId"] = json!("OTHER");
1220 plain["params"]["resourceType"] = json!("XHR");
1221 let (mut cdp, log) = fake(
1222 vec![
1223 json!({
1224 "method": "Page.frameNavigated",
1225 "sessionId": "S",
1226 "params": { "frame": { "id": "CHILD", "parentId": "MAIN", "url": "https://vault.sliccy.ai/frame" } }
1227 }),
1228 framed,
1229 referred,
1230 plain,
1231 ],
1232 Box::new(|message| vec![ok(message)]),
1233 );
1234 let got = cdp.next_paused(Some("S"), Duration::from_millis(50)).unwrap().unwrap();
1235 assert_eq!(got.request_id, "R3");
1236 let continued: Vec<String> = log
1237 .borrow()
1238 .iter()
1239 .filter(|item| item["method"] == "Fetch.continueRequest")
1240 .map(|item| item["params"]["requestId"].as_str().unwrap().to_string())
1241 .collect();
1242 assert_eq!(continued, ["R1", "R2"]);
1243 assert!(cdp.call("Fetch.getResponseBody", json!({ "requestId": "R1" }), Some("S")).is_err());
1244 }
1245
1246 #[test]
1247 fn enable_refuses_protected_patterns_and_browser_wide_interception() {
1248 let (mut cdp, log) = fake(Vec::new(), Box::new(|message| vec![ok(message)]));
1249 let refused = enable(
1250 &mut cdp,
1251 "S",
1252 &[Pattern::request("https://vault.sliccy.ai/*")],
1253 )
1254 .unwrap_err();
1255 assert!(
1256 refused.starts_with("slicc: refused: target is on seven's origin"),
1257 "{refused}"
1258 );
1259 let refused = enable(&mut cdp, "S", &[Pattern::response("*://*.sliccy.ai/*")]).unwrap_err();
1260 assert!(refused.starts_with("slicc: refused"), "{refused}");
1261 assert!(enable(&mut cdp, "", &[Pattern::request("*")])
1262 .unwrap_err()
1263 .contains("browser-wide"));
1264 assert!(enable(&mut cdp, "S", &[]).is_err());
1265 assert!(log.borrow().is_empty());
1266 enable(
1267 &mut cdp,
1268 "S",
1269 &[
1270 Pattern::request("*"),
1271 Pattern::response("https://idp.example/*").resource_type("Document"),
1272 ],
1273 )
1274 .unwrap();
1275 let sent = log.borrow();
1276 assert_eq!(sent[0]["method"], "Fetch.enable");
1277 assert_eq!(sent[0]["sessionId"], "S");
1278 assert_eq!(
1279 sent[0]["params"]["patterns"],
1280 json!([
1281 { "urlPattern": "*", "requestStage": "Request" },
1282 { "urlPattern": "https://idp.example/*", "requestStage": "Response", "resourceType": "Document" }
1283 ])
1284 );
1285 }
1286
1287 #[test]
1288 fn decisions_cannot_send_a_tab_to_seven() {
1289 let guard = Protected::default();
1290 let paused = Paused::from_event(&paused_event("S", "R1", "https://app.example/")).unwrap();
1291 let fulfil =
1292 Decision::Fulfill(Fulfill::new(302).header("Location", "https://seven.sliccy.ai/"));
1293 assert!(command(&guard, &paused, &fulfil)
1294 .unwrap_err()
1295 .starts_with("slicc: refused"));
1296 let refresh = Decision::Fulfill(
1297 Fulfill::html(200, "x").header("Refresh", "0; url=https://vault.sliccy.ai/"),
1298 );
1299 assert!(command(&guard, &paused, &refresh).is_err());
1300 let rewrite = Decision::Continue(Overrides {
1301 url: Some("https://vault.sliccy.ai/".into()),
1302 ..Overrides::default()
1303 });
1304 assert!(command(&guard, &paused, &rewrite).is_err());
1305 let fine = Decision::Fulfill(Fulfill::new(302).header("Location", "/next"));
1306 assert_eq!(
1307 command(&guard, &paused, &fine).unwrap().0,
1308 "Fetch.fulfillRequest"
1309 );
1310 let (method, params) = command(
1311 &guard,
1312 &paused,
1313 &Decision::Fail(ErrorReason::BlockedByClient),
1314 )
1315 .unwrap();
1316 assert_eq!(method, "Fetch.failRequest");
1317 assert_eq!(params["errorReason"], "BlockedByClient");
1318 let (method, params) = command(
1319 &guard,
1320 &paused,
1321 &Decision::Continue(Overrides {
1322 method: Some("POST".into()),
1323 post_data: Some(b"x=1".to_vec()),
1324 headers: Some(vec![("X-A".into(), "1".into())]),
1325 ..Overrides::default()
1326 }),
1327 )
1328 .unwrap();
1329 assert_eq!(method, "Fetch.continueRequest");
1330 assert_eq!(params["postData"], base64_encode(b"x=1"));
1331 assert_eq!(params["headers"], json!([{ "name": "X-A", "value": "1" }]));
1332 assert!(command(
1333 &guard,
1334 &paused,
1335 &Decision::ContinueResponse(ResponseOverrides::default())
1336 )
1337 .is_err());
1338 assert_eq!(ErrorReason::parse("timedout"), Some(ErrorReason::TimedOut));
1339 assert_eq!(ErrorReason::parse("nope"), None);
1340 }
1341
1342 #[test]
1343 fn signed_in_page_is_small_html() {
1344 let page = Fulfill::signed_in();
1345 assert_eq!(page.status, 200);
1346 assert!(page
1347 .headers
1348 .iter()
1349 .any(|(name, value)| name == "Content-Type" && value.starts_with("text/html")));
1350 assert!(String::from_utf8(page.body)
1351 .unwrap()
1352 .contains("You can close this tab"));
1353 }
1354
1355 #[test]
1356 fn an_interception_reads_a_response_and_cleans_up_on_drop() {
1357 let mut response = paused_event("S1", "R1", "https://idp.example/authorize");
1358 response["params"]["responseStatusCode"] = json!(200);
1359 let queued = Rc::new(RefCell::new(Some(response)));
1360 let attaches = Rc::new(RefCell::new(0));
1361 let (mut cdp, log) = fake(
1362 Vec::new(),
1363 Box::new(move |message| match message["method"].as_str().unwrap() {
1364 "Target.getTargetInfo" => vec![
1365 json!({ "id": message["id"], "result": { "targetInfo": { "targetId": "T1", "url": "about:blank" } } }),
1366 ],
1367 "Target.attachToTarget" => {
1368 *attaches.borrow_mut() += 1;
1369 let session = if *attaches.borrow() == 1 { "S1" } else { "G1" };
1370 vec![json!({ "id": message["id"], "result": { "sessionId": session } })]
1371 }
1372 "Fetch.enable" if message["sessionId"] == "S1" => {
1373 vec![ok(message), queued.borrow_mut().take().unwrap()]
1374 }
1375 "Fetch.getResponseBody" => vec![
1376 json!({ "id": message["id"], "result": { "body": base64_encode(b"<p>hi</p>"), "base64Encoded": true } }),
1377 ],
1378 _ => vec![ok(message)],
1379 }),
1380 );
1381 {
1382 let mut interception = Interception::attach(
1383 &mut cdp,
1384 "T1",
1385 &[Pattern::response("https://idp.example/*")],
1386 )
1387 .unwrap();
1388 assert_eq!(interception.session_id(), "S1");
1389 let paused = interception
1390 .next(Duration::from_millis(50))
1391 .unwrap()
1392 .unwrap();
1393 assert_eq!(interception.response_body(&paused).unwrap(), b"<p>hi</p>");
1394 interception
1395 .decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1396 .unwrap();
1397 }
1398 assert_eq!(
1399 methods(&log),
1400 [
1401 "Target.getTargetInfo",
1402 "Target.getTargetInfo",
1403 "Target.attachToTarget",
1404 "Target.setDiscoverTargets",
1405 "Target.attachToTarget",
1406 "Fetch.enable",
1407 "Network.enable",
1408 "Network.setBypassServiceWorker",
1409 "Page.enable",
1410 "Runtime.enable",
1411 "Fetch.enable",
1412 "Fetch.getResponseBody",
1413 "Fetch.fulfillRequest",
1414 "Fetch.disable",
1415 "Target.detachFromTarget"
1416 ]
1417 );
1418 }
1419
1420 #[test]
1421 fn an_interception_refuses_a_seven_tab_or_its_blank_popup() {
1422 let (mut cdp, log) = fake(
1423 Vec::new(),
1424 Box::new(|message| {
1425 let url = match message["params"]["targetId"].as_str() {
1426 Some("SEVEN") => "https://lars.sliccy.ai/",
1427 Some("POPUP") => "about:blank",
1428 _ => "",
1429 };
1430 let opener = if message["params"]["targetId"] == "POPUP" {
1431 json!("SEVEN")
1432 } else {
1433 Value::Null
1434 };
1435 vec![
1436 json!({ "id": message["id"], "result": { "targetInfo": { "url": url, "openerId": opener } } }),
1437 ]
1438 }),
1439 );
1440 for target in ["SEVEN", "POPUP"] {
1441 let refused = Interception::attach(&mut cdp, target, &[Pattern::request("*")])
1442 .err()
1443 .unwrap();
1444 assert!(refused.starts_with("slicc: refused"), "{target}: {refused}");
1445 }
1446 assert!(methods(&log)
1447 .iter()
1448 .all(|method| method == "Target.getTargetInfo"));
1449 }
1450
1451 #[test]
1452 fn navigation_keeps_the_fragment_and_hides_seven() {
1453 let guard = Protected::default();
1454 let event = json!({
1455 "method": "Page.frameNavigated",
1456 "sessionId": "S",
1457 "params": { "frame": { "id": "F", "url": "https://app.example/cb", "urlFragment": "#access_token=t" } }
1458 });
1459 let found = navigation(&guard, &event).unwrap();
1460 assert_eq!(found.url, "https://app.example/cb#access_token=t");
1461 assert!(found.main_frame);
1462 let seven = json!({
1463 "method": "Page.frameNavigated",
1464 "sessionId": "S",
1465 "params": { "frame": { "id": "F", "url": "https://seven.sliccy.ai/auth/callback", "urlFragment": "#t" } }
1466 });
1467 assert!(navigation(&guard, &seven).is_none());
1468 }
1469
1470 fn live(name: &str) -> String {
1471 std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set"))
1472 }
1473
1474 fn encoded(text: &str) -> String {
1475 url::form_urlencoded::byte_serialize(text.as_bytes()).collect()
1476 }
1477
1478 fn evaluate(cdp: &mut Cdp, session: &str, expression: &str) -> Value {
1479 cdp.call(
1480 "Runtime.evaluate",
1481 json!({ "expression": expression, "returnByValue": true }),
1482 Some(session),
1483 )
1484 .unwrap()
1485 .pointer("/result/value")
1486 .cloned()
1487 .unwrap_or(Value::Null)
1488 }
1489
1490 fn poll(cdp: &mut Cdp, session: &str, expression: &str, want: &str) {
1491 let deadline = Instant::now() + Duration::from_secs(10);
1492 let mut last = Value::Null;
1493 while Instant::now() < deadline {
1494 last = evaluate(cdp, session, expression);
1495 if last == json!(want) {
1496 return;
1497 }
1498 let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1499 }
1500 panic!("{expression} stayed {last}, wanted {want}");
1501 }
1502
1503 fn committed(cdp: &mut Cdp, session: &str, url: &str) {
1504 let deadline = Instant::now() + Duration::from_secs(10);
1505 let mut last = String::new();
1506 while Instant::now() < deadline {
1507 last = match cdp.call("Page.getNavigationHistory", json!({}), Some(session)) {
1508 Ok(history) => {
1509 let index = history["currentIndex"].as_u64().unwrap_or(0) as usize;
1510 let current = history["entries"][index]["url"].as_str().unwrap_or("");
1511 if current == url {
1512 return;
1513 }
1514 current.to_string()
1515 }
1516 Err(error) => error.trim().to_string(),
1517 };
1518 let _ = cdp.pump(Instant::now() + Duration::from_millis(100));
1519 }
1520 panic!("the browser never committed {url}: {last}");
1521 }
1522
1523 fn new_tab(cdp: &mut Cdp, url: &str) -> String {
1524 cdp.call(
1525 "Target.createTarget",
1526 json!({ "url": url, "background": true }),
1527 None,
1528 )
1529 .unwrap()["targetId"]
1530 .as_str()
1531 .unwrap()
1532 .to_string()
1533 }
1534
1535 fn unpaused(cdp: &mut Cdp, session: &str, app: &str, api: &str) -> String {
1536 let page = cdp
1537 .call_for(
1538 "Runtime.evaluate",
1539 json!({ "expression": "JSON.stringify({ href: location.href, readyState: document.readyState, got: window.__got ?? null })", "returnByValue": true }),
1540 Some(session),
1541 Duration::from_secs(5),
1542 )
1543 .map(|value| value.pointer("/result/value").cloned().unwrap_or(Value::Null).to_string())
1544 .unwrap_or_else(|error| format!("evaluate failed: {}", error.trim()));
1545 let path = url::form_urlencoded::byte_serialize(b"/api/headers").collect::<String>();
1546 let hits = crate::net::http_probe(&format!("{app}/hits?path={path}"), "text/plain")
1547 .map(|response| String::from_utf8_lossy(&response.body).into_owned())
1548 .unwrap_or_else(|error| format!("unknown ({})", error.trim()));
1549 while cdp
1550 .pump(std::time::Instant::now() + Duration::from_millis(200))
1551 .unwrap_or(false)
1552 {}
1553 let trail: Vec<String> = cdp
1554 .paused_trail
1555 .iter()
1556 .filter(|(_, url, _)| url.starts_with(api))
1557 .map(|(id, url, reason)| {
1558 let whose = if id == session { "this session" } else { "another session" };
1559 format!("{url} on {whose}: {reason}")
1560 })
1561 .collect();
1562 format!(
1563 "page {page}; fixture /api/headers hits {hits}; Fetch.requestPaused for it: {}",
1564 if trail.is_empty() { "none".to_string() } else { trail.join(", ") }
1565 )
1566 }
1567
1568 fn intercepting(seen: &mut Interception<'_>, session: &str, api: &str, app: &str) {
1569 let mut active = false;
1570 for probe in 1..=10 {
1571 let url = format!("{api}?probe={probe}");
1572 start_script(seen, session, &format!("fetch({}).catch(() => null)", json!(url)));
1573 let until = Instant::now() + Duration::from_secs(2);
1574 while let Some(paused) = seen
1575 .next(until.saturating_duration_since(Instant::now()))
1576 .unwrap()
1577 {
1578 assert!(paused.url.contains("?probe="), "{} paused before the real request", paused.url);
1579 if paused.is_response() {
1580 seen.decide(&paused, &Decision::ContinueResponse(ResponseOverrides::default()))
1581 .unwrap();
1582 if active {
1583 break;
1584 }
1585 } else {
1586 active = true;
1587 seen.decide(&paused, &Decision::Continue(Overrides::default()))
1588 .unwrap();
1589 }
1590 }
1591 if active {
1592 while let Some(paused) = seen.next(Duration::from_millis(300)).unwrap() {
1593 assert!(paused.url.contains("?probe="), "{} paused before the real request", paused.url);
1594 let decision = if paused.is_response() {
1595 Decision::ContinueResponse(ResponseOverrides::default())
1596 } else {
1597 Decision::Continue(Overrides::default())
1598 };
1599 seen.decide(&paused, &decision).unwrap();
1600 }
1601 return;
1602 }
1603 }
1604 panic!("interception never became active: {}", unpaused(seen, session, app, api));
1605 }
1606
1607 fn start_script(cdp: &mut Cdp, session: &str, expression: &str) {
1608 let _ = cdp.notify(
1609 "Runtime.evaluate",
1610 json!({ "expression": expression, "returnByValue": true }),
1611 Some(session),
1612 );
1613 }
1614
1615 #[test]
1616 #[ignore = "needs SLICC_FETCH_LIVE_WS from test/integration/fetch.test.mjs"]
1617 fn live_chromium_interception() {
1618 let ws = live("SLICC_FETCH_LIVE_WS");
1619 let app = live("SLICC_FETCH_LIVE_APP");
1620 let idp = live("SLICC_FETCH_LIVE_IDP");
1621 let seven = live("SLICC_FETCH_LIVE_SEVEN");
1622 let mut cdp = Cdp::connect(&ws).unwrap();
1623 let tab = new_tab(&mut cdp, "about:blank");
1624
1625 let implicit = format!("{app}/cb#access_token=T&state=S");
1626 {
1627 let mut seen = Interception::attach(
1628 &mut cdp,
1629 &tab,
1630 &[Pattern::response(format!("{idp}/authorize*"))],
1631 )
1632 .unwrap();
1633 let session = seen.session_id().to_string();
1634 seen.call("Page.enable", json!({}), Some(&session)).unwrap();
1635 let start = format!("{idp}/authorize?to={}", encoded(&implicit));
1636 let _ = seen.notify("Page.navigate", json!({ "url": start }), Some(&session));
1637 let paused = seen
1638 .next(Duration::from_secs(10))
1639 .unwrap()
1640 .expect("the redirect paused");
1641 assert!(paused.is_response());
1642 assert_eq!(paused.status, Some(302));
1643 assert_eq!(paused.location().as_deref(), Some(implicit.as_str()));
1644 seen.decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1645 .unwrap();
1646 poll(&mut seen, &session, "document.title", "Signed in");
1647 poll(
1648 &mut seen,
1649 &session,
1650 "location.href",
1651 &format!("{idp}/authorize?to={}", encoded(&implicit)),
1652 );
1653 }
1654
1655 let code = format!("{app}/cb?code=C&state=S");
1656 {
1657 let mut seen =
1658 Interception::attach(&mut cdp, &tab, &[Pattern::request(format!("{app}/cb*"))])
1659 .unwrap();
1660 let session = seen.session_id().to_string();
1661 seen.call("Page.enable", json!({}), Some(&session)).unwrap();
1662 let start = format!("{idp}/authorize?to={}", encoded(&code));
1663 let _ = seen.notify("Page.navigate", json!({ "url": start }), Some(&session));
1664 let paused = seen
1665 .next(Duration::from_secs(10))
1666 .unwrap()
1667 .expect("the callback paused");
1668 assert!(!paused.is_response());
1669 assert_eq!(paused.url, code);
1670 assert_eq!(paused.method, "GET");
1671 assert_eq!(paused.resource_type, "Document");
1672 seen.decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1673 .unwrap();
1674 poll(&mut seen, &session, "document.title", "Signed in");
1675 }
1676
1677 {
1678 let session = cdp::attach(&mut cdp, &tab).unwrap();
1679 cdp.call("Page.enable", json!({}), Some(&session)).unwrap();
1680 let mark = cdp.events().len();
1681 let landing = format!("{app}/landing#id_token=J");
1682 let start = format!("{idp}/js-redirect?to={}", encoded(&landing));
1683 let _ = cdp.notify("Page.navigate", json!({ "url": start }), Some(&session));
1684 let guard = cdp.guard().clone();
1685 let event = cdp
1686 .wait_event(mark, Duration::from_secs(10), |event| {
1687 navigation(&guard, event).is_some_and(|found| found.url.contains("/landing"))
1688 })
1689 .unwrap()
1690 .expect("the script redirect navigated");
1691 let found = navigation(&guard, &event).unwrap();
1692 assert_eq!(found.url, landing);
1693 assert!(found.main_frame);
1694 assert_eq!(found.session_id, session);
1695 committed(&mut cdp, &session, &landing);
1696 cdp::detach(&mut cdp, &session);
1697 }
1698
1699 {
1700 let api = format!("{app}/api/headers");
1701 let mut seen = Interception::attach(
1702 &mut cdp,
1703 &tab,
1704 &[
1705 Pattern::request(format!("{api}*")),
1706 Pattern::response(format!("{api}*")),
1707 ],
1708 )
1709 .unwrap();
1710 let session = seen.session_id().to_string();
1711 intercepting(&mut seen, &session, &api, &app);
1712 start_script(
1713 &mut seen,
1714 &session,
1715 &format!(
1716 "fetch({}).then((r) => r.text()).then((t) => {{ window.__got = t; }})",
1717 json!(api)
1718 ),
1719 );
1720 let request = match seen.next(Duration::from_secs(10)).unwrap() {
1721 Some(request) => request,
1722 None => panic!("the request paused: {}", unpaused(&mut seen, &session, &app, &api)),
1723 };
1724 assert!(!request.is_response());
1725 let mut headers = request.headers.clone();
1726 headers.push(("X-Added".into(), "1".into()));
1727 seen.decide(
1728 &request,
1729 &Decision::Continue(Overrides {
1730 headers: Some(headers),
1731 ..Overrides::default()
1732 }),
1733 )
1734 .unwrap();
1735 let response = seen
1736 .next(Duration::from_secs(10))
1737 .unwrap()
1738 .expect("the response paused");
1739 assert_eq!(response.status, Some(200));
1740 let body = String::from_utf8(seen.response_body(&response).unwrap()).unwrap();
1741 assert_eq!(body, "x-added=1 x-drop=none");
1742 seen.decide(
1743 &response,
1744 &Decision::ContinueResponse(ResponseOverrides::default()),
1745 )
1746 .unwrap();
1747 poll(&mut seen, &session, "window.__got || ''", &body);
1748 }
1749
1750 {
1751 let mut seen =
1752 Interception::attach(&mut cdp, &tab, &[Pattern::request("*/blocked*")]).unwrap();
1753 let session = seen.session_id().to_string();
1754 start_script(&mut seen, &session, "fetch('/blocked').then(() => 'ok', () => 'failed').then((t) => { window.__blocked = t; })");
1755 let paused = seen
1756 .next(Duration::from_secs(10))
1757 .unwrap()
1758 .expect("the blocked request paused");
1759 seen.decide(&paused, &Decision::Fail(ErrorReason::BlockedByClient))
1760 .unwrap();
1761 poll(&mut seen, &session, "window.__blocked || ''", "failed");
1762 }
1763
1764 {
1765 let mut seen = Interception::attach(&mut cdp, &tab, &[Pattern::request("*")]).unwrap();
1766 let session = seen.session_id().to_string();
1767 start_script(
1768 &mut seen,
1769 &session,
1770 &format!("fetch({}).then((r) => r.text(), (e) => 'error:' + e).then((t) => {{ window.__seven = t; }})", json!(format!("{seven}/ping"))),
1771 );
1772 assert!(seen.next(Duration::from_secs(2)).unwrap().is_none());
1773 poll(&mut seen, &session, "window.__seven || ''", "pong");
1774 let refused = enable(
1775 &mut seen,
1776 &session,
1777 &[Pattern::request(format!("{seven}/*"))],
1778 )
1779 .unwrap_err();
1780 assert!(refused.starts_with("slicc: refused"), "{refused}");
1781 }
1782
1783 let refused = cdp
1784 .call("Target.createTarget", json!({ "url": format!("{seven}/landing") }), None)
1785 .unwrap_err();
1786 assert!(refused.starts_with("slicc: refused"), "{refused}");
1787 let seven_tab = crate::raw::Raw::open(&ws).create(&format!("{seven}/landing"));
1788 let deadline = Instant::now() + Duration::from_secs(10);
1789 loop {
1790 match Interception::attach(&mut cdp, &seven_tab, &[Pattern::request("*")]) {
1791 Err(refused) => {
1792 assert!(refused.starts_with("slicc: refused"), "{refused}");
1793 break;
1794 }
1795 Ok(seen) => {
1796 seen.finish();
1797 assert!(Instant::now() < deadline, "a seven tab was intercepted");
1798 std::thread::sleep(Duration::from_millis(200));
1799 }
1800 }
1801 }
1802 for target in [tab, seven_tab] {
1803 let _ = cdp.call("Target.closeTarget", json!({ "targetId": target }), None);
1804 }
1805 }
1806}