1use bext_plugin_api::types::SandboxPermissions;
7use rquickjs::{Ctx, Function, Object, Result as JsResult};
8use std::path::PathBuf;
9use std::sync::{Arc, Mutex};
10
11pub(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
105pub(crate) fn register_globals(ctx: &Ctx<'_>, bridge: Arc<HostBridge>) -> JsResult<()> {
107 let globals = ctx.globals();
108
109 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 let bext = Object::new(ctx.clone())?;
160
161 {
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 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 {
232 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 if is_private_url(&url) {
239 return Err(rquickjs::Error::new_from_js("string", "blocked: private/internal URL"));
240 }
241 if !b.is_url_allowed(&url) {
243 return Err(rquickjs::Error::new_from_js("string", "URL not in allowlist"));
244 }
245 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 {
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 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
379fn is_private_url(url_str: &str) -> bool {
393 let parsed = match url::Url::parse(url_str) {
394 Ok(u) => u,
395 Err(_) => return true, };
397 let host = match parsed.host_str() {
398 Some(h) => h,
399 None => return true,
400 };
401
402 let host_lower = host.to_lowercase();
404 if host_lower == "localhost" || host_lower.ends_with(".localhost") {
405 return true;
406 }
407
408 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
410 return is_private_ip(ip);
411 }
412
413 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 if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&(host, 80)) {
423 let all_addrs: Vec<_> = addrs.collect();
424 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) || (v6.octets()[0] & 0xfe == 0xfc) || 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}