Skip to main content

jsdet_browser/
bridge.rs

1/// The browser bridge — implements jsdet_core::Bridge with a full
2/// fake browser environment for URL detonation.
3///
4use std::sync::Mutex;
5
6use jsdet_core::bridge::Bridge;
7use jsdet_core::observation::Value;
8
9use crate::canvas;
10use crate::css::ComputedStyle;
11use crate::dom::{Dom, NodeId};
12use crate::navigator;
13use crate::network::{self, ResponseRegistry};
14use crate::persona::Persona;
15use crate::storage::{CookieJar, WebStorage};
16use crate::window::{History, Location};
17
18/// Full browser bridge for URL detonation.
19pub struct BrowserBridge {
20    /// The DOM tree — shared mutable state.
21    dom: Mutex<Dom>,
22    /// Current page URL.
23    location: Mutex<Location>,
24    /// Browser history.
25    #[allow(dead_code)]
26    history: Mutex<History>,
27    /// Browser persona (UA, platform, etc.).
28    persona: Persona,
29    /// localStorage.
30    local_storage: WebStorage,
31    /// sessionStorage.
32    session_storage: WebStorage,
33    /// Cookie jar.
34    cookies: CookieJar,
35    /// Fake network response registry.
36    responses: ResponseRegistry,
37    /// Collected redirect targets from window.location.assign / window.open.
38    redirect_targets: Mutex<Vec<String>>,
39    /// Collected window.open URLs.
40    popup_urls: Mutex<Vec<String>>,
41}
42
43impl BrowserBridge {
44    /// Create a bridge for detonating JS from the given URL.
45    pub fn new(url: &str, html: &str, persona: Persona) -> Self {
46        Self {
47            dom: Mutex::new(Dom::parse(html)),
48            location: Mutex::new(Location::from_url(url)),
49            history: Mutex::new(History::new(url)),
50            persona,
51            local_storage: WebStorage::new(5000),
52            session_storage: WebStorage::new(5000),
53            cookies: CookieJar::new(),
54            responses: ResponseRegistry::new(),
55            redirect_targets: Mutex::new(Vec::new()),
56            popup_urls: Mutex::new(Vec::new()),
57        }
58    }
59
60    /// Set a fake response for a URL.
61    pub fn add_response(&mut self, url: &str, response: network::FakeResponse) {
62        self.responses.add_exact(url, response);
63    }
64
65    /// Pre-populate a cookie.
66    pub fn add_cookie(&self, name: &str, value: &str, domain: &str) {
67        self.cookies.add(name, value, domain);
68    }
69
70    /// Get collected redirect targets.
71    pub fn redirect_targets(&self) -> Vec<String> {
72        self.redirect_targets
73            .lock()
74            .map(|v| v.clone())
75            .unwrap_or_default()
76    }
77
78    /// Get collected popup URLs.
79    pub fn popup_urls(&self) -> Vec<String> {
80        self.popup_urls
81            .lock()
82            .map(|v| v.clone())
83            .unwrap_or_default()
84    }
85
86    fn handle_document(&self, method: &str, args: &[Value]) -> Result<Value, String> {
87        match method {
88            "createElement" => {
89                let tag = args.first().and_then(|v| v.as_str()).unwrap_or("div");
90                let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
91                let id = dom.create_element(tag);
92                Ok(Value::Int(id.0 as i64))
93            }
94            "getElementById" => {
95                let id = args.first().and_then(|v| v.as_str()).unwrap_or("");
96                let dom = self.dom.lock().map_err(|e| e.to_string())?;
97                match dom.get_element_by_id(id) {
98                    Some(node_id) => Ok(Value::Int(node_id.0 as i64)),
99                    None => Ok(Value::Null),
100                }
101            }
102            "querySelector" => {
103                let selector = args.first().and_then(|v| v.as_str()).unwrap_or("");
104                let dom = self.dom.lock().map_err(|e| e.to_string())?;
105                match dom.query_selector(selector) {
106                    Some(node_id) => Ok(Value::Int(node_id.0 as i64)),
107                    None => Ok(Value::Null),
108                }
109            }
110            "querySelectorAll" => {
111                let selector = args.first().and_then(|v| v.as_str()).unwrap_or("");
112                let dom = self.dom.lock().map_err(|e| e.to_string())?;
113                let results = dom.query_selector_all(selector);
114                let ids: Vec<i64> = results.iter().map(|n| n.0 as i64).collect();
115                Ok(Value::json(serde_json::to_string(&ids).unwrap_or_default()))
116            }
117            "write" | "writeln" => {
118                // document.write — content is recorded as an ApiCall observation
119                // by the sandbox bridge_call wrapper. The bridge handler just
120                // needs to return success so JS doesn't throw.
121                Ok(Value::Undefined)
122            }
123            _ => Err(format!("document.{method} is not defined")),
124        }
125    }
126
127    fn handle_element(&self, method: &str, args: &[Value]) -> Result<Value, String> {
128        let node_id = args
129            .first()
130            .and_then(|v| match v {
131                Value::Int(n) => Some(NodeId(*n as u32)),
132                _ => None,
133            })
134            .ok_or("missing node ID")?;
135
136        match method {
137            "getAttribute" => {
138                let name = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
139                let dom = self.dom.lock().map_err(|e| e.to_string())?;
140                match dom.get_attribute(node_id, name) {
141                    Some(v) => Ok(Value::string(v.to_string())),
142                    None => Ok(Value::Null),
143                }
144            }
145            "setAttribute" => {
146                let name = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
147                let value = args.get(2).and_then(|v| v.as_str()).unwrap_or("");
148                let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
149                dom.set_attribute(node_id, name, value);
150                Ok(Value::Undefined)
151            }
152            "removeAttribute" => {
153                let name = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
154                let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
155                dom.remove_attribute(node_id, name);
156                Ok(Value::Undefined)
157            }
158            "appendChild" => {
159                let child_id = args
160                    .get(1)
161                    .and_then(|v| match v {
162                        Value::Int(n) => Some(NodeId(*n as u32)),
163                        _ => None,
164                    })
165                    .ok_or("missing child ID")?;
166                let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
167                dom.append_child(node_id, child_id);
168                Ok(Value::Int(child_id.0 as i64))
169            }
170            "innerHTML" => {
171                let dom = self.dom.lock().map_err(|e| e.to_string())?;
172                Ok(Value::string(dom.inner_html(node_id)))
173            }
174            "setInnerHTML" => {
175                let html = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
176                let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
177                dom.set_inner_html(node_id, html);
178                Ok(Value::Undefined)
179            }
180            _ => Err(format!("element.{method} is not defined")),
181        }
182    }
183
184    fn handle_window(&self, method: &str, args: &[Value]) -> Result<Value, String> {
185        match method {
186            "open" => {
187                let url = args
188                    .first()
189                    .and_then(|v| v.as_str())
190                    .unwrap_or("")
191                    .to_string();
192                if !url.is_empty()
193                    && let Ok(mut urls) = self.popup_urls.lock()
194                {
195                    urls.push(url);
196                }
197                Ok(Value::Null) // window reference
198            }
199            "alert" | "confirm" | "prompt" => Ok(Value::Undefined),
200            "postMessage" => Ok(Value::Undefined),
201            _ => Err(format!("window.{method} is not defined")),
202        }
203    }
204
205    fn handle_fetch(&self, args: &[Value]) -> Result<Value, String> {
206        let url = args.first().and_then(|v| v.as_str()).unwrap_or("");
207        let method = args.get(1).and_then(|v| v.as_str()).unwrap_or("GET");
208        Ok(network::handle_fetch(url, method, &self.responses))
209    }
210
211    fn handle_storage(&self, area: &str, method: &str, args: &[Value]) -> Result<Value, String> {
212        let storage = match area {
213            "localStorage" => &self.local_storage,
214            "sessionStorage" => &self.session_storage,
215            _ => return Err(format!("{area} is not defined")),
216        };
217
218        match method {
219            "getItem" => {
220                let key = args.first().and_then(|v| v.as_str()).unwrap_or("");
221                match storage.get_item(key) {
222                    Some(v) => Ok(Value::string(v)),
223                    None => Ok(Value::Null),
224                }
225            }
226            "setItem" => {
227                let key = args.first().and_then(|v| v.as_str()).unwrap_or("");
228                let value = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
229                storage.set_item(key, value)?;
230                Ok(Value::Undefined)
231            }
232            "removeItem" => {
233                let key = args.first().and_then(|v| v.as_str()).unwrap_or("");
234                storage.remove_item(key);
235                Ok(Value::Undefined)
236            }
237            "clear" => {
238                storage.clear();
239                Ok(Value::Undefined)
240            }
241            "length" => Ok(Value::Int(storage.length() as i64)),
242            "key" => {
243                let index = args
244                    .first()
245                    .and_then(|v| match v {
246                        Value::Int(n) => Some(*n as usize),
247                        _ => None,
248                    })
249                    .unwrap_or(0);
250                match storage.key(index) {
251                    Some(k) => Ok(Value::string(k)),
252                    None => Ok(Value::Null),
253                }
254            }
255            _ => Err(format!("{area}.{method} is not defined")),
256        }
257    }
258}
259
260impl Bridge for BrowserBridge {
261    fn call(&self, api: &str, args: &[Value]) -> Result<Value, String> {
262        let parts: Vec<&str> = api.splitn(3, '.').collect();
263
264        match parts.as_slice() {
265            ["document", method] => self.handle_document(method, args),
266            ["element", method] => self.handle_element(method, args),
267            ["window", method] => self.handle_window(method, args),
268            ["fetch"] => self.handle_fetch(args),
269            ["localStorage", method] => self.handle_storage("localStorage", method, args),
270            ["sessionStorage", method] => self.handle_storage("sessionStorage", method, args),
271            ["getComputedStyle"] => {
272                let node_id = args
273                    .first()
274                    .and_then(|v| match v {
275                        Value::Int(n) => Some(NodeId(*n as u32)),
276                        _ => None,
277                    })
278                    .unwrap_or(NodeId::ROOT);
279                let dom = self.dom.lock().map_err(|e| e.to_string())?;
280                let style = ComputedStyle::for_node(&dom, node_id);
281                Ok(Value::json(style.to_json()))
282            }
283            ["canvas", "toDataURL"] => Ok(Value::string(canvas::handle_to_data_url(
284                &self.persona.user_agent,
285            ))),
286            ["webgl", "getParameter"] => {
287                let param = args.first().and_then(|v| v.as_str()).unwrap_or("");
288                match canvas::handle_webgl_parameter(&self.persona.user_agent, param) {
289                    Some(v) => Ok(Value::string(v)),
290                    None => Ok(Value::Null),
291                }
292            }
293            // Form probing — analyze the original HTML for credential forms.
294            // This catches static HTML forms that weren't created by JavaScript.
295            // The observation is emitted as a credential_form_detected call which
296            // the analyzer recognizes.
297            ["probe_html_forms"] | ["probe_static_forms"] => {
298                let dom = self.dom.lock().map_err(|e| e.to_string())?;
299                let password_inputs = dom.extract_password_inputs();
300
301                if !password_inputs.is_empty() {
302                    let form_actions = dom.extract_form_actions();
303                    let action = form_actions.first().cloned().unwrap_or_default();
304                    let field_count = password_inputs.len();
305                    // Return as credential_form_detected format so the same analyzer
306                    // handler processes it. The value encodes action + fields + has_password.
307                    return Ok(Value::string(format!(
308                        "CREDENTIAL_FORM:action={};fields=password({})",
309                        action, field_count
310                    )));
311                }
312                Ok(Value::Null)
313            }
314            _ => Err(format!("{api} is not defined")),
315        }
316    }
317
318    fn get_property(&self, object: &str, property: &str) -> Result<Value, String> {
319        match object {
320            "navigator" => Ok(navigator::get_navigator_property(&self.persona, property)),
321            "location" | "window.location" => {
322                let loc = self.location.lock().map_err(|e| e.to_string())?;
323                Ok(loc.get_property(property))
324            }
325            "document" => match property {
326                "cookie" => Ok(Value::string(self.cookies.to_cookie_string())),
327                "referrer" => Ok(Value::string(self.persona.referrer.clone())),
328                "title" => Ok(Value::string(String::new())),
329                "readyState" => Ok(Value::string("complete")),
330                "domain" => {
331                    let loc = self.location.lock().map_err(|e| e.to_string())?;
332                    Ok(Value::string(loc.hostname.clone()))
333                }
334                _ => Err(format!("document.{property} is not defined")),
335            },
336            "screen" => match property {
337                "width" => Ok(Value::Int(self.persona.screen_width as i64)),
338                "height" => Ok(Value::Int(self.persona.screen_height as i64)),
339                "colorDepth" | "pixelDepth" => Ok(Value::Int(self.persona.color_depth as i64)),
340                "availWidth" => Ok(Value::Int(self.persona.screen_width as i64)),
341                "availHeight" => Ok(Value::Int((self.persona.screen_height - 40) as i64)),
342                _ => Err(format!("screen.{property} is not defined")),
343            },
344            _ => Err(format!("{object}.{property} is not defined")),
345        }
346    }
347
348    fn set_property(&self, object: &str, property: &str, value: &Value) -> Result<(), String> {
349        match (object, property) {
350            ("document", "cookie") => {
351                if let Some(cookie_str) = value.as_str() {
352                    self.cookies.set_from_string(cookie_str);
353                }
354                Ok(())
355            }
356            ("location" | "window.location", "href") => {
357                if let Some(url) = value.as_str() {
358                    if let Ok(mut targets) = self.redirect_targets.lock() {
359                        targets.push(url.to_string());
360                    }
361                    if let Ok(mut loc) = self.location.lock() {
362                        *loc = Location::from_url(url);
363                    }
364                }
365                Ok(())
366            }
367            ("document", "title") => Ok(()),
368            _ => Ok(()),
369        }
370    }
371
372    fn provided_globals(&self) -> Vec<String> {
373        vec![
374            "document".into(),
375            "window".into(),
376            "navigator".into(),
377            "location".into(),
378            "screen".into(),
379            "localStorage".into(),
380            "sessionStorage".into(),
381            "fetch".into(),
382            "XMLHttpRequest".into(),
383            "getComputedStyle".into(),
384        ]
385    }
386
387    fn bootstrap_js(&self) -> String {
388        // Bootstrap JS is in a separate file for readability and testability.
389        // Template variables ($URL, $HOSTNAME, etc.) are replaced at runtime.
390        static TEMPLATE: &str = include_str!("../js/bootstrap.js");
391
392        let loc = self.location.lock().map(|l| l.clone()).unwrap_or_default();
393        TEMPLATE
394            .replace("{{JSDET_URL}}", &loc.href)
395            .replace("{{JSDET_PROTOCOL}}", &loc.protocol)
396            .replace("{{JSDET_HOSTNAME}}", &loc.hostname)
397            .replace("{{JSDET_PATHNAME}}", &loc.pathname)
398            .replace("{{JSDET_SEARCH}}", &loc.search)
399            .replace("{{JSDET_HASH}}", &loc.hash)
400            .replace("{{JSDET_ORIGIN}}", &loc.origin)
401            .replace("{{JSDET_REFERRER}}", &self.persona.referrer)
402            .replace("{{JSDET_UA}}", &self.persona.user_agent)
403            .replace("{{JSDET_PLATFORM}}", &self.persona.platform)
404            .replace("{{JSDET_LANGUAGE}}", &self.persona.language)
405            .replace(
406                "{{JSDET_LANGUAGES_JSON}}",
407                &serde_json::to_string(&self.persona.languages).unwrap_or_default(),
408            )
409            .replace(
410                "{{JSDET_HW_CONCURRENCY}}",
411                &self.persona.hardware_concurrency.to_string(),
412            )
413            .replace(
414                "{{JSDET_DEVICE_MEMORY}}",
415                &self.persona.device_memory.to_string(),
416            )
417            .replace(
418                "{{JSDET_MAX_TOUCH}}",
419                &self.persona.max_touch_points.to_string(),
420            )
421            .replace("{{JSDET_VENDOR}}", &self.persona.vendor)
422            .replace("{{JSDET_APP_NAME}}", &self.persona.app_name)
423            .replace("{{JSDET_APP_VERSION}}", &self.persona.app_version)
424            .replace("{{JSDET_SCREEN_W}}", &self.persona.screen_width.to_string())
425            .replace(
426                "{{JSDET_SCREEN_H_TASKBAR}}",
427                &self.persona.screen_height.saturating_sub(40).to_string(),
428            )
429            .replace(
430                "{{JSDET_SCREEN_H}}",
431                &self.persona.screen_height.to_string(),
432            )
433            .replace(
434                "{{JSDET_COLOR_DEPTH}}",
435                &self.persona.color_depth.to_string(),
436            )
437    }
438}
439
440// Legacy format string removed — bootstrap JS is now in js/bootstrap.js
441// This dramatically improves readability, testability, and maintainability.
442// The JS can now be:
443// 1. Syntax-checked by any JS linter
444// 2. Tested independently
445// 3. Edited without worrying about {{ }} escaping
446// 4. Read and understood by security researchers
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn bridge_provides_navigator() {
454        let bridge = BrowserBridge::new("https://example.com", "<html></html>", Persona::default());
455        let ua = bridge.get_property("navigator", "userAgent").unwrap();
456        assert!(matches!(ua, Value::String(s, _) if s.contains("Chrome")));
457    }
458
459    #[test]
460    fn bridge_provides_location() {
461        let bridge = BrowserBridge::new(
462            "https://example.com/path?q=1",
463            "<html></html>",
464            Persona::default(),
465        );
466        let host = bridge.get_property("location", "hostname").unwrap();
467        assert_eq!(host, Value::string("example.com"));
468    }
469
470    #[test]
471    fn bridge_creates_elements() {
472        let bridge = BrowserBridge::new("https://example.com", "<html></html>", Persona::default());
473        let id = bridge.call("document.createElement", &[Value::string("div")]);
474        assert!(matches!(id, Ok(Value::Int(_))));
475    }
476
477    #[test]
478    fn bridge_handles_cookies() {
479        let bridge = BrowserBridge::new("https://example.com", "<html></html>", Persona::default());
480        bridge
481            .set_property("document", "cookie", &Value::string("test=hello"))
482            .unwrap();
483        let cookies = bridge.get_property("document", "cookie").unwrap();
484        assert!(matches!(cookies, Value::String(s, _) if s.contains("test=hello")));
485    }
486
487    #[test]
488    fn bridge_handles_storage() {
489        let bridge = BrowserBridge::new("https://example.com", "<html></html>", Persona::default());
490        bridge
491            .call(
492                "localStorage.setItem",
493                &[Value::string("key"), Value::string("val")],
494            )
495            .unwrap();
496        let result = bridge
497            .call("localStorage.getItem", &[Value::string("key")])
498            .unwrap();
499        assert_eq!(result, Value::string("val"));
500    }
501}