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) =
231            match &evt.params.download_canceled_params_download_complete_params_union {
232                DownloadUnion::DownloadCompleteParams(c) => (
233                    c.base_navigation_info.context.inner().to_string(),
234                    c.base_navigation_info.url.clone(),
235                    "complete".to_string(),
236                    c.filepath.clone(),
237                ),
238                DownloadUnion::DownloadCanceledParams(c) => (
239                    c.base_navigation_info.context.inner().to_string(),
240                    c.base_navigation_info.url.clone(),
241                    "canceled".to_string(),
242                    None,
243                ),
244            };
245        let mut inner = self.inner.write().await;
246        if let Some(d) = inner
247            .downloads
248            .iter_mut()
249            .rev()
250            .find(|d| d.context == ctx && d.url == url && d.status == "will-begin")
251        {
252            d.status = status;
253            d.filepath = filepath;
254            return;
255        }
256        inner.push_download(CapturedDownload {
257            context: ctx,
258            suggested_filename: String::new(),
259            url,
260            status,
261            filepath,
262        });
263    }
264}
265
266/// Build the event handler that feeds `browsingContext.*` dialog and download
267/// events into `log`. Mirrors [`crate::network::make_network_handler`].
268pub fn make_dialog_handler(
269    log: DialogLog,
270) -> impl FnMut(Event) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
271    use rustenium_bidi_definitions::browsing_context::events::BrowsingContextEvent as BCE;
272    move |evt| {
273        let log = log.clone();
274        Box::pin(async move {
275            if let Event::BrowsingContext(bce) = evt {
276                match bce {
277                    BCE::UserPromptOpened(e) => log.ingest_opened(&e).await,
278                    BCE::UserPromptClosed(e) => log.ingest_closed(&e).await,
279                    BCE::DownloadWillBegin(e) => log.ingest_download_begin(&e).await,
280                    BCE::DownloadEnd(e) => log.ingest_download_end(&e).await,
281                    _ => {}
282                }
283            }
284        })
285    }
286}
287
288/// BiDi event identifiers this module subscribes to.
289pub const DIALOG_EVENTS: &[&str] = &[
290    "browsingContext.userPromptOpened",
291    "browsingContext.userPromptClosed",
292    "browsingContext.downloadWillBegin",
293    "browsingContext.downloadEnd",
294];
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use rustenium_bidi_definitions::browsing_context::events::{
300        UserPromptClosedMethod, UserPromptClosedParams, UserPromptOpenedMethod,
301        UserPromptOpenedParams,
302    };
303    use rustenium_bidi_definitions::browsing_context::types::BrowsingContext;
304
305    fn opened(ctx: &str, kind: UserPromptType, message: &str) -> UserPromptOpened {
306        UserPromptOpened {
307            method: UserPromptOpenedMethod::UserPromptOpened,
308            params: UserPromptOpenedParams {
309                context: BrowsingContext::new(ctx),
310                handler: UserPromptHandlerType::Ignore,
311                message: message.to_string(),
312                r#type: kind,
313                default_value: None,
314            },
315        }
316    }
317
318    fn closed(ctx: &str, kind: UserPromptType, accepted: bool, text: Option<&str>) -> UserPromptClosed {
319        UserPromptClosed {
320            method: UserPromptClosedMethod::UserPromptClosed,
321            params: UserPromptClosedParams {
322                context: BrowsingContext::new(ctx),
323                accepted,
324                r#type: kind,
325                user_text: text.map(str::to_string),
326            },
327        }
328    }
329
330    #[tokio::test]
331    async fn captures_alert_message_for_xss_evidence() {
332        let log = DialogLog::new();
333        log.ingest_opened(&opened("ctx-1", UserPromptType::Alert, "1"))
334            .await;
335        let dialogs = log.dialogs().await;
336        assert_eq!(dialogs.len(), 1);
337        assert_eq!(dialogs[0].kind, "alert");
338        assert_eq!(dialogs[0].message, "1");
339        assert_eq!(dialogs[0].handler, "ignore");
340        assert_eq!(dialogs[0].accepted, None);
341    }
342
343    #[tokio::test]
344    async fn close_finalizes_matching_open_dialog() {
345        let log = DialogLog::new();
346        log.ingest_opened(&opened("ctx-1", UserPromptType::Prompt, "name?"))
347            .await;
348        assert_eq!(log.open_dialogs().await.len(), 1);
349        log.ingest_closed(&closed("ctx-1", UserPromptType::Prompt, true, Some("admin")))
350            .await;
351        let dialogs = log.dialogs().await;
352        assert_eq!(dialogs.len(), 1, "close updates, does not append");
353        assert_eq!(dialogs[0].accepted, Some(true));
354        assert_eq!(dialogs[0].user_text.as_deref(), Some("admin"));
355        assert!(log.open_dialogs().await.is_empty());
356    }
357
358    #[tokio::test]
359    async fn close_without_open_pushes_standalone() {
360        let log = DialogLog::new();
361        log.ingest_closed(&closed("ctx-9", UserPromptType::Confirm, false, None))
362            .await;
363        let dialogs = log.dialogs().await;
364        assert_eq!(dialogs.len(), 1);
365        assert_eq!(dialogs[0].accepted, Some(false));
366    }
367
368    #[tokio::test]
369    async fn dialogs_are_bounded() {
370        let log = DialogLog::new();
371        for i in 0..(MAX_ENTRIES + 50) {
372            log.ingest_opened(&opened("ctx", UserPromptType::Alert, &i.to_string()))
373                .await;
374        }
375        assert_eq!(log.dialog_count().await, MAX_ENTRIES);
376        // Oldest dropped: the most recent message survives.
377        let last = log.last_dialog().await.unwrap();
378        assert_eq!(last.message, (MAX_ENTRIES + 49).to_string());
379    }
380
381    #[test]
382    fn prompt_kind_maps_all_variants() {
383        assert_eq!(prompt_kind(&UserPromptType::Alert), "alert");
384        assert_eq!(prompt_kind(&UserPromptType::Beforeunload), "beforeunload");
385        assert_eq!(prompt_kind(&UserPromptType::Confirm), "confirm");
386        assert_eq!(prompt_kind(&UserPromptType::Prompt), "prompt");
387    }
388
389    #[test]
390    fn handler_maps_all_variants() {
391        assert_eq!(handler_str(&UserPromptHandlerType::Accept), "accept");
392        assert_eq!(handler_str(&UserPromptHandlerType::Dismiss), "dismiss");
393        assert_eq!(handler_str(&UserPromptHandlerType::Ignore), "ignore");
394        assert_eq!(
395            handler_str(&UserPromptHandlerType::DismissAndNotify),
396            "dismiss and notify"
397        );
398    }
399}