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 new_tab(cdp: &mut Cdp, url: &str) -> String {
1504 cdp.call(
1505 "Target.createTarget",
1506 json!({ "url": url, "background": true }),
1507 None,
1508 )
1509 .unwrap()["targetId"]
1510 .as_str()
1511 .unwrap()
1512 .to_string()
1513 }
1514
1515 fn start_script(cdp: &mut Cdp, session: &str, expression: &str) {
1516 let _ = cdp.notify(
1517 "Runtime.evaluate",
1518 json!({ "expression": expression, "returnByValue": true }),
1519 Some(session),
1520 );
1521 }
1522
1523 #[test]
1524 #[ignore = "needs SLICC_FETCH_LIVE_WS from test/integration/fetch.test.mjs"]
1525 fn live_chromium_interception() {
1526 let ws = live("SLICC_FETCH_LIVE_WS");
1527 let app = live("SLICC_FETCH_LIVE_APP");
1528 let idp = live("SLICC_FETCH_LIVE_IDP");
1529 let seven = live("SLICC_FETCH_LIVE_SEVEN");
1530 let mut cdp = Cdp::connect(&ws).unwrap();
1531 let tab = new_tab(&mut cdp, "about:blank");
1532
1533 let implicit = format!("{app}/cb#access_token=T&state=S");
1534 {
1535 let mut seen = Interception::attach(
1536 &mut cdp,
1537 &tab,
1538 &[Pattern::response(format!("{idp}/authorize*"))],
1539 )
1540 .unwrap();
1541 let session = seen.session_id().to_string();
1542 seen.call("Page.enable", json!({}), Some(&session)).unwrap();
1543 let start = format!("{idp}/authorize?to={}", encoded(&implicit));
1544 let _ = seen.notify("Page.navigate", json!({ "url": start }), Some(&session));
1545 let paused = seen
1546 .next(Duration::from_secs(10))
1547 .unwrap()
1548 .expect("the redirect paused");
1549 assert!(paused.is_response());
1550 assert_eq!(paused.status, Some(302));
1551 assert_eq!(paused.location().as_deref(), Some(implicit.as_str()));
1552 seen.decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1553 .unwrap();
1554 poll(&mut seen, &session, "document.title", "Signed in");
1555 poll(
1556 &mut seen,
1557 &session,
1558 "location.href",
1559 &format!("{idp}/authorize?to={}", encoded(&implicit)),
1560 );
1561 }
1562
1563 let code = format!("{app}/cb?code=C&state=S");
1564 {
1565 let mut seen =
1566 Interception::attach(&mut cdp, &tab, &[Pattern::request(format!("{app}/cb*"))])
1567 .unwrap();
1568 let session = seen.session_id().to_string();
1569 seen.call("Page.enable", json!({}), Some(&session)).unwrap();
1570 let start = format!("{idp}/authorize?to={}", encoded(&code));
1571 let _ = seen.notify("Page.navigate", json!({ "url": start }), Some(&session));
1572 let paused = seen
1573 .next(Duration::from_secs(10))
1574 .unwrap()
1575 .expect("the callback paused");
1576 assert!(!paused.is_response());
1577 assert_eq!(paused.url, code);
1578 assert_eq!(paused.method, "GET");
1579 assert_eq!(paused.resource_type, "Document");
1580 seen.decide(&paused, &Decision::Fulfill(Fulfill::signed_in()))
1581 .unwrap();
1582 poll(&mut seen, &session, "document.title", "Signed in");
1583 }
1584
1585 {
1586 let session = cdp::attach(&mut cdp, &tab).unwrap();
1587 cdp.call("Page.enable", json!({}), Some(&session)).unwrap();
1588 let mark = cdp.events().len();
1589 let landing = format!("{app}/landing#id_token=J");
1590 let start = format!("{idp}/js-redirect?to={}", encoded(&landing));
1591 let _ = cdp.notify("Page.navigate", json!({ "url": start }), Some(&session));
1592 let guard = cdp.guard().clone();
1593 let event = cdp
1594 .wait_event(mark, Duration::from_secs(10), |event| {
1595 navigation(&guard, event).is_some_and(|found| found.url.contains("/landing"))
1596 })
1597 .unwrap()
1598 .expect("the script redirect navigated");
1599 let found = navigation(&guard, &event).unwrap();
1600 assert_eq!(found.url, landing);
1601 assert!(found.main_frame);
1602 assert_eq!(found.session_id, session);
1603 cdp::detach(&mut cdp, &session);
1604 }
1605
1606 {
1607 let api = format!("{app}/api/headers");
1608 let mut seen = Interception::attach(
1609 &mut cdp,
1610 &tab,
1611 &[
1612 Pattern::request(format!("{api}*")),
1613 Pattern::response(format!("{api}*")),
1614 ],
1615 )
1616 .unwrap();
1617 let session = seen.session_id().to_string();
1618 start_script(
1619 &mut seen,
1620 &session,
1621 &format!(
1622 "fetch({}).then((r) => r.text()).then((t) => {{ window.__got = t; }})",
1623 json!(api)
1624 ),
1625 );
1626 let request = seen
1627 .next(Duration::from_secs(10))
1628 .unwrap()
1629 .expect("the request paused");
1630 assert!(!request.is_response());
1631 let mut headers = request.headers.clone();
1632 headers.push(("X-Added".into(), "1".into()));
1633 seen.decide(
1634 &request,
1635 &Decision::Continue(Overrides {
1636 headers: Some(headers),
1637 ..Overrides::default()
1638 }),
1639 )
1640 .unwrap();
1641 let response = seen
1642 .next(Duration::from_secs(10))
1643 .unwrap()
1644 .expect("the response paused");
1645 assert_eq!(response.status, Some(200));
1646 let body = String::from_utf8(seen.response_body(&response).unwrap()).unwrap();
1647 assert_eq!(body, "x-added=1 x-drop=none");
1648 seen.decide(
1649 &response,
1650 &Decision::ContinueResponse(ResponseOverrides::default()),
1651 )
1652 .unwrap();
1653 poll(&mut seen, &session, "window.__got || ''", &body);
1654 }
1655
1656 {
1657 let mut seen =
1658 Interception::attach(&mut cdp, &tab, &[Pattern::request("*/blocked*")]).unwrap();
1659 let session = seen.session_id().to_string();
1660 start_script(&mut seen, &session, "fetch('/blocked').then(() => 'ok', () => 'failed').then((t) => { window.__blocked = t; })");
1661 let paused = seen
1662 .next(Duration::from_secs(10))
1663 .unwrap()
1664 .expect("the blocked request paused");
1665 seen.decide(&paused, &Decision::Fail(ErrorReason::BlockedByClient))
1666 .unwrap();
1667 poll(&mut seen, &session, "window.__blocked || ''", "failed");
1668 }
1669
1670 {
1671 let mut seen = Interception::attach(&mut cdp, &tab, &[Pattern::request("*")]).unwrap();
1672 let session = seen.session_id().to_string();
1673 start_script(
1674 &mut seen,
1675 &session,
1676 &format!("fetch({}).then((r) => r.text(), (e) => 'error:' + e).then((t) => {{ window.__seven = t; }})", json!(format!("{seven}/ping"))),
1677 );
1678 assert!(seen.next(Duration::from_secs(2)).unwrap().is_none());
1679 poll(&mut seen, &session, "window.__seven || ''", "pong");
1680 let refused = enable(
1681 &mut seen,
1682 &session,
1683 &[Pattern::request(format!("{seven}/*"))],
1684 )
1685 .unwrap_err();
1686 assert!(refused.starts_with("slicc: refused"), "{refused}");
1687 }
1688
1689 let refused = cdp
1690 .call("Target.createTarget", json!({ "url": format!("{seven}/landing") }), None)
1691 .unwrap_err();
1692 assert!(refused.starts_with("slicc: refused"), "{refused}");
1693 let seven_tab = crate::raw::Raw::open(&ws).create(&format!("{seven}/landing"));
1694 let deadline = Instant::now() + Duration::from_secs(10);
1695 loop {
1696 match Interception::attach(&mut cdp, &seven_tab, &[Pattern::request("*")]) {
1697 Err(refused) => {
1698 assert!(refused.starts_with("slicc: refused"), "{refused}");
1699 break;
1700 }
1701 Ok(seen) => {
1702 seen.finish();
1703 assert!(Instant::now() < deadline, "a seven tab was intercepted");
1704 std::thread::sleep(Duration::from_millis(200));
1705 }
1706 }
1707 }
1708 for target in [tab, seven_tab] {
1709 let _ = cdp.call("Target.closeTarget", json!({ "targetId": target }), None);
1710 }
1711 }
1712}