Skip to main content

runtime_foxdriver/
dialog.rs

1//! JS dialog (`alert`/`confirm`/`prompt`/`beforeunload`) and page-initiated
2//! download capture via WebDriver BiDi `browsingContext.*` events.
3//!
4//! Firefox surfaces user prompts and downloads as local BiDi events. This module
5//! records them into a shared, cheaply-cloneable [`DialogLog`] (mirroring
6//! [`crate::network::NetworkLog`]) so the agent can:
7//!
8//! - **Confirm alert-based XSS**: the `alert()` message is captured from
9//!   `userPromptOpened` even when the prompt is auto-handled, so a payload that
10//!   pops `alert(document.domain)` is *proven* fired without the automation
11//!   hanging on the modal.
12//! - **Read `confirm`/`prompt` text** and answer them via
13//!   [`crate::Page::handle_user_prompt`] (when launched with the `ignore`
14//!   prompt-handler so the prompt stays open).
15//! - **Inspect page-initiated downloads**: suggested filename (path-traversal /
16//!   exfil probes) and source URL (without a file landing on disk).
17//!
18//! The log never blocks the browser: under the BiDi default prompt handler
19//! (`dismiss and notify`) the events still fire, so recording is side-effect
20//! free. Auto-handling policy lives one layer up (the bridge), keeping this a
21//! pure observation primitive.
22
23use std::sync::Arc;
24use tokio::sync::RwLock;
25
26use rustenium_bidi_definitions::browsing_context::events::{
27    DownloadEnd, DownloadWillBegin, UserPromptClosed, UserPromptOpened,
28};
29use rustenium_bidi_definitions::browsing_context::types::{
30    DownloadCanceledParamsDownloadCompleteParamsUnion as DownloadUnion, UserPromptType,
31};
32use rustenium_bidi_definitions::session::types::UserPromptHandlerType;
33use rustenium_bidi_definitions::Event;
34
35/// Upper bound on retained dialogs/downloads so a hostile page that spams
36/// `alert()` in a loop cannot drive unbounded memory growth (Law 7). Oldest
37/// entries are dropped first; the most recent, what the agent cares about
38/// always survive.
39const MAX_ENTRIES: usize = 1000;
40
41/// One captured JS user prompt.
42#[derive(Debug, Clone, PartialEq, serde::Serialize)]
43pub struct CapturedDialog {
44    /// Browsing context (tab/iframe) the prompt fired in.
45    pub context: String,
46    /// `alert` | `confirm` | `prompt` | `beforeunload`.
47    pub kind: String,
48    /// The dialog's message text (the XSS evidence for `alert(...)`).
49    pub message: String,
50    /// Default value pre-filled in a `prompt()` box, if any.
51    pub default_value: Option<String>,
52    /// The handler Firefox reported it will apply (`accept`/`dismiss`/`ignore`/
53    /// `dismiss and notify`).
54    pub handler: String,
55    /// `Some(true/false)` once the prompt closed (accepted/dismissed); `None`
56    /// while it is still open (only reachable under the `ignore` handler).
57    pub accepted: Option<bool>,
58    /// Text submitted when the prompt was answered, if any.
59    pub user_text: Option<String>,
60}
61
62/// One page-initiated download.
63#[derive(Debug, Clone, PartialEq, serde::Serialize)]
64pub struct CapturedDownload {
65    /// Browsing context that initiated the download.
66    pub context: String,
67    /// Server-suggested filename (inspect for path traversal / exfil).
68    pub suggested_filename: String,
69    /// Source URL of the download.
70    pub url: String,
71    /// `will-begin` | `complete` | `canceled`.
72    pub status: String,
73    /// Local path the file was written to, when the download completed.
74    pub filepath: Option<String>,
75}
76
77#[derive(Default)]
78struct Inner {
79    dialogs: Vec<CapturedDialog>,
80    downloads: Vec<CapturedDownload>,
81}
82
83impl Inner {
84    fn push_dialog(&mut self, d: CapturedDialog) {
85        self.dialogs.push(d);
86        if self.dialogs.len() > MAX_ENTRIES {
87            let overflow = self.dialogs.len() - MAX_ENTRIES;
88            self.dialogs.drain(0..overflow);
89        }
90    }
91
92    fn push_download(&mut self, d: CapturedDownload) {
93        self.downloads.push(d);
94        if self.downloads.len() > MAX_ENTRIES {
95            let overflow = self.downloads.len() - MAX_ENTRIES;
96            self.downloads.drain(0..overflow);
97        }
98    }
99}
100
101/// Shared, cloneable handle to the dialog + download capture buffer.
102#[derive(Clone, Default)]
103pub struct DialogLog {
104    inner: Arc<RwLock<Inner>>,
105}
106
107/// Map the typed `UserPromptType` to its stable wire string.
108fn prompt_kind(t: &UserPromptType) -> &'static str {
109    match t {
110        UserPromptType::Alert => "alert",
111        UserPromptType::Beforeunload => "beforeunload",
112        UserPromptType::Confirm => "confirm",
113        UserPromptType::Prompt => "prompt",
114    }
115}
116
117/// Map the typed handler to its stable wire string.
118fn handler_str(h: &UserPromptHandlerType) -> &'static str {
119    match h {
120        UserPromptHandlerType::Accept => "accept",
121        UserPromptHandlerType::Dismiss => "dismiss",
122        UserPromptHandlerType::Ignore => "ignore",
123        UserPromptHandlerType::DismissAndNotify => "dismiss and notify",
124    }
125}
126
127impl DialogLog {
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// All captured dialogs, oldest first.
133    pub async fn dialogs(&self) -> Vec<CapturedDialog> {
134        self.inner.read().await.dialogs.clone()
135    }
136
137    /// All captured downloads, oldest first.
138    pub async fn downloads(&self) -> Vec<CapturedDownload> {
139        self.inner.read().await.downloads.clone()
140    }
141
142    /// Dialogs that are still open (no close event yet), the set the agent can
143    /// answer with [`crate::Page::handle_user_prompt`].
144    pub async fn open_dialogs(&self) -> Vec<CapturedDialog> {
145        self.inner
146            .read()
147            .await
148            .dialogs
149            .iter()
150            .filter(|d| d.accepted.is_none())
151            .cloned()
152            .collect()
153    }
154
155    /// The most recently opened dialog, if any.
156    pub async fn last_dialog(&self) -> Option<CapturedDialog> {
157        self.inner.read().await.dialogs.last().cloned()
158    }
159
160    /// Number of captured dialogs.
161    pub async fn dialog_count(&self) -> usize {
162        self.inner.read().await.dialogs.len()
163    }
164
165    /// Drop all recorded dialogs and downloads.
166    pub async fn clear(&self) {
167        let mut inner = self.inner.write().await;
168        inner.dialogs.clear();
169        inner.downloads.clear();
170    }
171
172    /// Record a `userPromptOpened` event.
173    pub async fn ingest_opened(&self, evt: &UserPromptOpened) {
174        let p = &evt.params;
175        let dialog = CapturedDialog {
176            context: p.context.inner().to_string(),
177            kind: prompt_kind(&p.r#type).to_string(),
178            message: p.message.clone(),
179            default_value: p.default_value.clone(),
180            handler: handler_str(&p.handler).to_string(),
181            accepted: None,
182            user_text: None,
183        };
184        self.inner.write().await.push_dialog(dialog);
185    }
186
187    /// Record a `userPromptClosed` event, finalizing the matching open dialog
188    /// (most-recent open prompt in the same context). Falls back to a standalone
189    /// record if no open prompt is found (e.g. log started mid-prompt).
190    pub async fn ingest_closed(&self, evt: &UserPromptClosed) {
191        let p = &evt.params;
192        let ctx = p.context.inner().to_string();
193        let mut inner = self.inner.write().await;
194        if let Some(d) = inner
195            .dialogs
196            .iter_mut()
197            .rev()
198            .find(|d| d.context == ctx && d.accepted.is_none())
199        {
200            d.accepted = Some(p.accepted);
201            d.user_text = p.user_text.clone();
202            return;
203        }
204        inner.push_dialog(CapturedDialog {
205            context: ctx,
206            kind: prompt_kind(&p.r#type).to_string(),
207            message: String::new(),
208            default_value: None,
209            handler: String::new(),
210            accepted: Some(p.accepted),
211            user_text: p.user_text.clone(),
212        });
213    }
214
215    /// Record a `downloadWillBegin` event.
216    pub async fn ingest_download_begin(&self, evt: &DownloadWillBegin) {
217        let p = &evt.params;
218        self.inner.write().await.push_download(CapturedDownload {
219            context: p.base_navigation_info.context.inner().to_string(),
220            suggested_filename: p.suggested_filename.clone(),
221            url: p.base_navigation_info.url.clone(),
222            status: "will-begin".to_string(),
223            filepath: None,
224        });
225    }
226
227    /// Record a `downloadEnd` event, finalizing the matching in-flight download
228    /// (most-recent `will-begin` for the same URL/context).
229    pub async fn ingest_download_end(&self, evt: &DownloadEnd) {
230        let (ctx, url, status, filepath) = match &evt
231            .params
232            .download_canceled_params_download_complete_params_union
233        {
234            DownloadUnion::DownloadCompleteParams(c) => (
235                c.base_navigation_info.context.inner().to_string(),
236                c.base_navigation_info.url.clone(),
237                "complete".to_string(),
238                c.filepath.clone(),
239            ),
240            DownloadUnion::DownloadCanceledParams(c) => (
241                c.base_navigation_info.context.inner().to_string(),
242                c.base_navigation_info.url.clone(),
243                "canceled".to_string(),
244                None,
245            ),
246        };
247        let mut inner = self.inner.write().await;
248        if let Some(d) = inner
249            .downloads
250            .iter_mut()
251            .rev()
252            .find(|d| d.context == ctx && d.url == url && d.status == "will-begin")
253        {
254            d.status = status;
255            d.filepath = filepath;
256            return;
257        }
258        inner.push_download(CapturedDownload {
259            context: ctx,
260            suggested_filename: String::new(),
261            url,
262            status,
263            filepath,
264        });
265    }
266}
267
268/// Build the event handler that feeds `browsingContext.*` dialog and download
269/// events into `log`. Mirrors [`crate::network::make_network_handler`].
270pub fn make_dialog_handler(
271    log: DialogLog,
272) -> impl FnMut(Event) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
273    use rustenium_bidi_definitions::browsing_context::events::BrowsingContextEvent as BCE;
274    move |evt| {
275        let log = log.clone();
276        Box::pin(async move {
277            if let Event::BrowsingContext(bce) = evt {
278                match bce {
279                    BCE::UserPromptOpened(e) => log.ingest_opened(&e).await,
280                    BCE::UserPromptClosed(e) => log.ingest_closed(&e).await,
281                    BCE::DownloadWillBegin(e) => log.ingest_download_begin(&e).await,
282                    BCE::DownloadEnd(e) => log.ingest_download_end(&e).await,
283                    _ => {}
284                }
285            }
286        })
287    }
288}
289
290/// BiDi event identifiers this module subscribes to.
291pub const DIALOG_EVENTS: &[&str] = &[
292    "browsingContext.userPromptOpened",
293    "browsingContext.userPromptClosed",
294    "browsingContext.downloadWillBegin",
295    "browsingContext.downloadEnd",
296];
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use rustenium_bidi_definitions::browsing_context::events::{
302        UserPromptClosedMethod, UserPromptClosedParams, UserPromptOpenedMethod,
303        UserPromptOpenedParams,
304    };
305    use rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
306
307    fn opened(ctx: &str, kind: UserPromptType, message: &str) -> UserPromptOpened {
308        UserPromptOpened {
309            method: UserPromptOpenedMethod::UserPromptOpened,
310            params: UserPromptOpenedParams {
311                context: BrowsingContext::new(ctx),
312                handler: UserPromptHandlerType::Ignore,
313                message: message.to_string(),
314                r#type: kind,
315                default_value: None,
316            },
317        }
318    }
319
320    fn closed(
321        ctx: &str,
322        kind: UserPromptType,
323        accepted: bool,
324        text: Option<&str>,
325    ) -> UserPromptClosed {
326        UserPromptClosed {
327            method: UserPromptClosedMethod::UserPromptClosed,
328            params: UserPromptClosedParams {
329                context: BrowsingContext::new(ctx),
330                accepted,
331                r#type: kind,
332                user_text: text.map(str::to_string),
333            },
334        }
335    }
336
337    #[tokio::test]
338    async fn captures_alert_message_for_xss_evidence() {
339        let log = DialogLog::new();
340        log.ingest_opened(&opened("ctx-1", UserPromptType::Alert, "1"))
341            .await;
342        let dialogs = log.dialogs().await;
343        assert_eq!(dialogs.len(), 1);
344        assert_eq!(dialogs[0].kind, "alert");
345        assert_eq!(dialogs[0].message, "1");
346        assert_eq!(dialogs[0].handler, "ignore");
347        assert_eq!(dialogs[0].accepted, None);
348    }
349
350    #[tokio::test]
351    async fn close_finalizes_matching_open_dialog() {
352        let log = DialogLog::new();
353        log.ingest_opened(&opened("ctx-1", UserPromptType::Prompt, "name?"))
354            .await;
355        assert_eq!(log.open_dialogs().await.len(), 1);
356        log.ingest_closed(&closed(
357            "ctx-1",
358            UserPromptType::Prompt,
359            true,
360            Some("admin"),
361        ))
362        .await;
363        let dialogs = log.dialogs().await;
364        assert_eq!(dialogs.len(), 1, "close updates, does not append");
365        assert_eq!(dialogs[0].accepted, Some(true));
366        assert_eq!(dialogs[0].user_text.as_deref(), Some("admin"));
367        assert!(log.open_dialogs().await.is_empty());
368    }
369
370    #[tokio::test]
371    async fn close_without_open_pushes_standalone() {
372        let log = DialogLog::new();
373        log.ingest_closed(&closed("ctx-9", UserPromptType::Confirm, false, None))
374            .await;
375        let dialogs = log.dialogs().await;
376        assert_eq!(dialogs.len(), 1);
377        assert_eq!(dialogs[0].accepted, Some(false));
378    }
379
380    #[tokio::test]
381    async fn dialogs_are_bounded() {
382        let log = DialogLog::new();
383        for i in 0..(MAX_ENTRIES + 50) {
384            log.ingest_opened(&opened("ctx", UserPromptType::Alert, &i.to_string()))
385                .await;
386        }
387        assert_eq!(log.dialog_count().await, MAX_ENTRIES);
388        // Oldest dropped: the most recent message survives.
389        let last = log.last_dialog().await.unwrap();
390        assert_eq!(last.message, (MAX_ENTRIES + 49).to_string());
391    }
392
393    #[test]
394    fn prompt_kind_maps_all_variants() {
395        assert_eq!(prompt_kind(&UserPromptType::Alert), "alert");
396        assert_eq!(prompt_kind(&UserPromptType::Beforeunload), "beforeunload");
397        assert_eq!(prompt_kind(&UserPromptType::Confirm), "confirm");
398        assert_eq!(prompt_kind(&UserPromptType::Prompt), "prompt");
399    }
400
401    #[test]
402    fn handler_maps_all_variants() {
403        assert_eq!(handler_str(&UserPromptHandlerType::Accept), "accept");
404        assert_eq!(handler_str(&UserPromptHandlerType::Dismiss), "dismiss");
405        assert_eq!(handler_str(&UserPromptHandlerType::Ignore), "ignore");
406        assert_eq!(
407            handler_str(&UserPromptHandlerType::DismissAndNotify),
408            "dismiss and notify"
409        );
410    }
411}