Skip to main content

bext_plugin_quickjs/
api.rs

1//! JS API surface exposed to QuickJS plugins.
2//!
3//! Registers `console.*` and `bext.*` globals that bridge to the host's
4//! sandbox infrastructure (storage, fetch, config, metrics).
5
6use bext_plugin_api::types::SandboxPermissions;
7use rquickjs::{Ctx, Function, Object, Result as JsResult};
8use std::path::PathBuf;
9use std::sync::{Arc, Mutex};
10
11/// Shared host state accessible from JS callbacks.
12pub(crate) struct HostBridge {
13    pub plugin_id: String,
14    pub permissions: SandboxPermissions,
15    pub storage_dir: PathBuf,
16    pub config: serde_json::Value,
17    pub fetch_limiter: Mutex<FetchLimiter>,
18    pub storage_bytes: Mutex<u64>,
19}
20
21pub(crate) struct FetchLimiter {
22    tokens: u32,
23    max_tokens: u32,
24    last_refill: std::time::Instant,
25}
26
27impl FetchLimiter {
28    pub fn new(max_per_minute: u32) -> Self {
29        Self {
30            tokens: max_per_minute,
31            max_tokens: max_per_minute,
32            last_refill: std::time::Instant::now(),
33        }
34    }
35
36    pub fn try_acquire(&mut self) -> bool {
37        let elapsed = self.last_refill.elapsed();
38        if elapsed >= std::time::Duration::from_secs(60) {
39            self.tokens = self.max_tokens;
40            self.last_refill = std::time::Instant::now();
41        }
42        if self.tokens > 0 {
43            self.tokens -= 1;
44            true
45        } else {
46            false
47        }
48    }
49}
50
51impl HostBridge {
52    pub fn new(
53        plugin_id: String,
54        permissions: SandboxPermissions,
55        storage_root: &std::path::Path,
56        config: serde_json::Value,
57    ) -> Self {
58        let storage_dir = storage_root.join(&plugin_id);
59        Self {
60            plugin_id,
61            permissions: permissions.clone(),
62            storage_dir,
63            config,
64            fetch_limiter: Mutex::new(FetchLimiter::new(permissions.max_fetch_per_minute)),
65            storage_bytes: Mutex::new(0),
66        }
67    }
68
69    fn is_url_allowed(&self, url: &str) -> bool {
70        if self.permissions.allowed_urls.is_empty() {
71            return false;
72        }
73        self.permissions
74            .allowed_urls
75            .iter()
76            .any(|p| glob_match(p, url))
77    }
78
79    fn try_fetch(&self) -> bool {
80        self.fetch_limiter
81            .lock()
82            .unwrap_or_else(|e| e.into_inner())
83            .try_acquire()
84    }
85
86    fn check_storage_quota(&self, additional: u64) -> bool {
87        let current = self.storage_bytes.lock().unwrap_or_else(|e| e.into_inner());
88        *current + additional <= self.permissions.storage_quota_kb * 1024
89    }
90
91    fn record_storage(&self, bytes: u64) {
92        let mut current = self.storage_bytes.lock().unwrap_or_else(|e| e.into_inner());
93        *current += bytes;
94    }
95
96    fn sanitize_key(key: &str) -> Option<&str> {
97        if key.contains("..") || key.contains('/') || key.contains('\\') || key.contains('\0') {
98            None
99        } else {
100            Some(key)
101        }
102    }
103}
104
105/// Register `console` and `bext` globals on the given JS context.
106pub(crate) fn register_globals(ctx: &Ctx<'_>, bridge: Arc<HostBridge>) -> JsResult<()> {
107    let globals = ctx.globals();
108
109    // ── console.* ─────────────────────────────────────────────────
110    let console = Object::new(ctx.clone())?;
111    {
112        let id = bridge.plugin_id.clone();
113        console.set(
114            "log",
115            Function::new(ctx.clone(), move |msg: String| {
116                tracing::info!(plugin = %id, "{}", msg);
117            }),
118        )?;
119    }
120    {
121        let id = bridge.plugin_id.clone();
122        console.set(
123            "warn",
124            Function::new(ctx.clone(), move |msg: String| {
125                tracing::warn!(plugin = %id, "{}", msg);
126            }),
127        )?;
128    }
129    {
130        let id = bridge.plugin_id.clone();
131        console.set(
132            "error",
133            Function::new(ctx.clone(), move |msg: String| {
134                tracing::error!(plugin = %id, "{}", msg);
135            }),
136        )?;
137    }
138    {
139        let id = bridge.plugin_id.clone();
140        console.set(
141            "info",
142            Function::new(ctx.clone(), move |msg: String| {
143                tracing::info!(plugin = %id, "{}", msg);
144            }),
145        )?;
146    }
147    {
148        let id = bridge.plugin_id.clone();
149        console.set(
150            "debug",
151            Function::new(ctx.clone(), move |msg: String| {
152                tracing::debug!(plugin = %id, "{}", msg);
153            }),
154        )?;
155    }
156    globals.set("console", console)?;
157
158    // ── bext.* ────────────────────────────────────────────────────
159    let bext = Object::new(ctx.clone())?;
160
161    // bext.config — read-only config object
162    {
163        let config_str = bridge.config.to_string();
164        let config_val: rquickjs::Value = ctx.json_parse(config_str)?;
165        bext.set("config", config_val)?;
166    }
167
168    // bext.storage.get/set/delete
169    let storage = Object::new(ctx.clone())?;
170    {
171        let b = bridge.clone();
172        storage.set(
173            "get",
174            Function::new(
175                ctx.clone(),
176                move |key: String| -> rquickjs::Result<Option<String>> {
177                    let key = HostBridge::sanitize_key(&key).ok_or_else(|| {
178                        rquickjs::Error::new_from_js("string", "invalid storage key")
179                    })?;
180                    let path = b.storage_dir.join(key);
181                    match std::fs::read_to_string(&path) {
182                        Ok(val) => Ok(Some(val)),
183                        Err(_) => Ok(None),
184                    }
185                },
186            ),
187        )?;
188    }
189    {
190        let b = bridge.clone();
191        storage.set(
192            "set",
193            Function::new(
194                ctx.clone(),
195                move |key: String, value: String| -> rquickjs::Result<bool> {
196                    let key = HostBridge::sanitize_key(&key).ok_or_else(|| {
197                        rquickjs::Error::new_from_js("string", "invalid storage key")
198                    })?;
199                    let bytes = value.len() as u64;
200                    if !b.check_storage_quota(bytes) {
201                        return Ok(false);
202                    }
203                    let _ = std::fs::create_dir_all(&b.storage_dir);
204                    match std::fs::write(b.storage_dir.join(key), value.as_bytes()) {
205                        Ok(()) => {
206                            b.record_storage(bytes);
207                            Ok(true)
208                        }
209                        Err(_) => Ok(false),
210                    }
211                },
212            ),
213        )?;
214    }
215    {
216        let b = bridge.clone();
217        storage.set(
218            "delete",
219            Function::new(ctx.clone(), move |key: String| -> rquickjs::Result<bool> {
220                let key = HostBridge::sanitize_key(&key)
221                    .ok_or_else(|| rquickjs::Error::new_from_js("string", "invalid storage key"))?;
222                Ok(std::fs::remove_file(b.storage_dir.join(key)).is_ok())
223            }),
224        )?;
225    }
226    bext.set("storage", storage)?;
227
228    // bext.fetch(url, options?) — blocking HTTP fetch
229    // Returns a JSON string `{"status":200,"body":"..."}` to avoid rquickjs lifetime issues.
230    // Parse in JS: `let resp = JSON.parse(bext.fetch(url, opts))`
231    {
232        /// Maximum response body size (1 MB).
233        const MAX_RESPONSE_BYTES: u64 = 1_048_576;
234
235        let b = bridge.clone();
236        bext.set("fetch", Function::new(ctx.clone(), move |url: String, method: Option<String>, body: Option<String>| -> rquickjs::Result<String> {
237            // SSRF prevention: block private/internal IPs
238            if is_private_url(&url) {
239                return Err(rquickjs::Error::new_from_js("string", "blocked: private/internal URL"));
240            }
241            // URL allowlist check
242            if !b.is_url_allowed(&url) {
243                return Err(rquickjs::Error::new_from_js("string", "URL not in allowlist"));
244            }
245            // Rate limit
246            if !b.try_fetch() {
247                return Err(rquickjs::Error::new_from_js("string", "rate limit exceeded"));
248            }
249
250            let method = method.unwrap_or_else(|| "GET".into());
251
252            let request = match method.to_uppercase().as_str() {
253                "GET" => ureq::get(&url),
254                "POST" => ureq::post(&url),
255                "PUT" => ureq::put(&url),
256                "DELETE" => ureq::delete(&url),
257                "PATCH" => ureq::patch(&url),
258                "HEAD" => ureq::head(&url),
259                _ => return Err(rquickjs::Error::new_from_js("string", "unsupported method")),
260            }
261            .timeout(std::time::Duration::from_secs(5));
262
263            let response = if let Some(ref b) = body {
264                request.send_string(b)
265            } else {
266                request.call()
267            };
268
269            let read_body_limited = |resp: ureq::Response| -> std::result::Result<String, String> {
270                use std::io::Read;
271                let mut reader = resp.into_reader().take(MAX_RESPONSE_BYTES + 1);
272                let mut buf = Vec::new();
273                match reader.read_to_end(&mut buf) {
274                    Ok(_) => {
275                        if buf.len() as u64 > MAX_RESPONSE_BYTES {
276                            return Err(format!(
277                                "response body exceeds {} byte limit",
278                                MAX_RESPONSE_BYTES
279                            ));
280                        }
281                        Ok(String::from_utf8(buf)
282                            .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).to_string()))
283                    }
284                    Err(_) => Ok(String::new()),
285                }
286            };
287
288            match response {
289                Ok(resp) => {
290                    let status = resp.status();
291                    let resp_body = match read_body_limited(resp) {
292                        Ok(body) => body,
293                        Err(e) => {
294                            tracing::warn!(error = %e, "fetch response body error");
295                            return Err(rquickjs::Error::new_from_js("string", "response body too large or unreadable"));
296                        }
297                    };
298                    Ok(serde_json::json!({"status": status, "body": resp_body}).to_string())
299                }
300                Err(ureq::Error::Status(code, resp)) => {
301                    let resp_body = match read_body_limited(resp) {
302                        Ok(body) => body,
303                        Err(e) => {
304                            tracing::warn!(error = %e, "fetch response body error");
305                            return Err(rquickjs::Error::new_from_js("string", "response body too large or unreadable"));
306                        }
307                    };
308                    Ok(serde_json::json!({"status": code, "body": resp_body}).to_string())
309                }
310                Err(e) => {
311                    tracing::warn!(plugin = %b.plugin_id, url = %url, error = %e, "fetch failed");
312                    Err(rquickjs::Error::new_from_js("string", "fetch request failed"))
313                }
314            }
315        }))?;
316    }
317
318    // bext._metricImpl(name, value, tags) — internal, always receives 3 args
319    {
320        let b = bridge.clone();
321        bext.set(
322            "_metricImpl",
323            Function::new(
324                ctx.clone(),
325                move |name: String, value: f64, tags: String| {
326                    tracing::info!(
327                        target: "bext::plugin_metric",
328                        plugin = %b.plugin_id,
329                        metric = %name,
330                        value = value,
331                        tags = %tags,
332                        "plugin_metric"
333                    );
334                },
335            ),
336        )?;
337    }
338
339    globals.set("bext", bext)?;
340
341    // bext.metric(name, value, tags?) — JS wrapper that defaults tags to "{}"
342    // Must run after `bext` is attached to globals.
343    ctx.eval::<(), _>(
344        b"bext.metric = function(name, value, tags) { bext._metricImpl(name, value, tags || '{}'); };"
345    )?;
346
347    Ok(())
348}
349
350fn glob_match(pattern: &str, input: &str) -> bool {
351    let parts: Vec<&str> = pattern.split('*').collect();
352    if parts.len() == 1 {
353        return pattern == input;
354    }
355    let mut pos = 0;
356    if !parts[0].is_empty() {
357        if !input.starts_with(parts[0]) {
358            return false;
359        }
360        pos = parts[0].len();
361    }
362    for part in &parts[1..parts.len() - 1] {
363        if part.is_empty() {
364            continue;
365        }
366        match input[pos..].find(part) {
367            Some(idx) => pos += idx + part.len(),
368            None => return false,
369        }
370    }
371    let last = parts[parts.len() - 1];
372    if !last.is_empty() {
373        input[pos..].ends_with(last)
374    } else {
375        true
376    }
377}
378
379/// Check if a URL targets a private/internal IP address (SSRF prevention).
380///
381/// Blocks loopback (127.0.0.0/8, ::1), private ranges (10/8, 172.16/12, 192.168/16),
382/// link-local (169.254/16, fe80::/10), unique-local IPv6 (fc00::/7), and
383/// IPv4-mapped IPv6 addresses that resolve to private IPs.
384///
385/// **TOCTOU note**: There is an inherent time-of-check/time-of-use gap between
386/// DNS resolution here and the subsequent HTTP request (which resolves DNS
387/// again). A malicious DNS server could return a public IP on the first lookup
388/// and a private IP on the second (DNS rebinding). To mitigate this, we resolve
389/// ALL returned A/AAAA records and reject if ANY is private. This narrows the
390/// window but does not fully eliminate it. For complete protection, the HTTP
391/// client should be configured to connect to the resolved IP directly.
392fn is_private_url(url_str: &str) -> bool {
393    let parsed = match url::Url::parse(url_str) {
394        Ok(u) => u,
395        Err(_) => return true, // Unparseable → blocked
396    };
397    let host = match parsed.host_str() {
398        Some(h) => h,
399        None => return true,
400    };
401
402    // Block "localhost" variants
403    let host_lower = host.to_lowercase();
404    if host_lower == "localhost" || host_lower.ends_with(".localhost") {
405        return true;
406    }
407
408    // Try parsing as IP directly
409    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
410        return is_private_ip(ip);
411    }
412
413    // Strip IPv6 brackets
414    let stripped = host.trim_start_matches('[').trim_end_matches(']');
415    if let Ok(ip) = stripped.parse::<std::net::IpAddr>() {
416        return is_private_ip(ip);
417    }
418
419    // DNS resolution — resolve hostname and check ALL returned IPs.
420    // We collect all addresses to ensure we check every record, not just the first.
421    // If ANY resolved address is private, the URL is blocked.
422    if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&(host, 80)) {
423        let all_addrs: Vec<_> = addrs.collect();
424        // If DNS returned no records, block the request (suspicious)
425        if all_addrs.is_empty() {
426            return true;
427        }
428        for addr in &all_addrs {
429            if is_private_ip(addr.ip()) {
430                return true;
431            }
432        }
433    }
434
435    false
436}
437
438fn is_private_ip(ip: std::net::IpAddr) -> bool {
439    match ip {
440        std::net::IpAddr::V4(v4) => {
441            v4.is_loopback()
442                || v4.is_private()
443                || v4.is_link_local()
444                || v4.is_unspecified()
445                || v4.is_broadcast()
446        }
447        std::net::IpAddr::V6(v6) => {
448            v6.is_loopback()
449                || v6.is_unspecified()
450                || (v6.octets()[0] == 0xfe && (v6.octets()[1] & 0xc0) == 0x80) // fe80::/10 link-local
451                || (v6.octets()[0] & 0xfe == 0xfc) // fc00::/7 unique-local
452                || v6.to_ipv4_mapped().is_some_and(|v4| {
453                    v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
454                })
455        }
456    }
457}