pluginengine01 / crates /bex-js /src /polyfills.rs
krystv's picture
Upload 107 files
3374e90 verified
//! Browser polyfills for the QuickJS sandbox.
//!
//! Design principles:
//! - Security-critical polyfills (crypto.getRandomValues) MUST be Rust-backed
//! - TextEncoder/TextDecoder must be spec-correct UTF-8
//! - console.log must route to tracing (not silently dropped)
//! - setTimeout must call the callback (sync, for compat)
//! - crypto.subtle must be implemented (AES, SHA, HMAC)
use rquickjs::function::Rest;
use rquickjs::{Ctx, Function, Object, Value};
/// Install all polyfills into a JS context.
pub fn install(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
install_base64(ctx)?;
install_encoding(ctx)?;
install_console(ctx)?;
install_timers(ctx)?;
install_url(ctx)?;
install_crypto(ctx)?;
install_globals(ctx)?;
remove_dangerous(ctx)?;
Ok(())
}
fn install_base64(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
// Rust-backed for correct Latin-1 handling and performance
use base64::{engine::general_purpose::STANDARD, Engine as _};
ctx.globals().set(
"atob",
Function::new(ctx.clone(), |s: String| -> rquickjs::Result<String> {
let cleaned = s
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>();
let decoded = STANDARD
.decode(&cleaned)
.map_err(|_| rquickjs::Error::Exception)?;
// btoa/atob work with Latin-1 byte values, not UTF-8
Ok(decoded.iter().map(|&b| b as char).collect())
})?,
)?;
ctx.globals().set(
"btoa",
Function::new(ctx.clone(), |s: String| -> rquickjs::Result<String> {
let bytes: Result<Vec<u8>, _> = s
.chars()
.map(|c| {
if c as u32 > 255 {
Err(rquickjs::Error::Exception)
} else {
Ok(c as u8)
}
})
.collect();
Ok(STANDARD.encode(bytes?))
})?,
)?;
Ok(())
}
fn install_encoding(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
// Spec-correct UTF-8 TextEncoder/TextDecoder implementations
ctx.eval::<(), _>(
r#"
globalThis.TextEncoder = class TextEncoder {
constructor() { this.encoding = 'utf-8'; }
encode(str) {
const bytes = [];
for (let i = 0; i < str.length; i++) {
let cp = str.codePointAt(i);
if (cp > 0xFFFF) i++;
if (cp <= 0x7F) {
bytes.push(cp);
} else if (cp <= 0x7FF) {
bytes.push(0xC0 | (cp >> 6), 0x80 | (cp & 0x3F));
} else if (cp <= 0xFFFF) {
bytes.push(0xE0|(cp>>12), 0x80|((cp>>6)&0x3F), 0x80|(cp&0x3F));
} else {
bytes.push(0xF0|(cp>>18), 0x80|((cp>>12)&0x3F), 0x80|((cp>>6)&0x3F), 0x80|(cp&0x3F));
}
}
return new Uint8Array(bytes);
}
encodeInto(str, dest) {
const enc = this.encode(str);
const len = Math.min(enc.length, dest.length);
dest.set(enc.subarray(0, len));
return { read: str.length, written: len };
}
};
globalThis.TextDecoder = class TextDecoder {
constructor(enc) { this.encoding = (enc||'utf-8').toLowerCase(); }
decode(buf) {
const bytes = buf instanceof Uint8Array ? buf :
buf instanceof ArrayBuffer ? new Uint8Array(buf) :
new Uint8Array(Array.from(buf));
let str = '', i = 0;
while (i < bytes.length) {
const b = bytes[i];
let cp;
if ((b & 0x80) === 0) { cp = b; i+=1; }
else if ((b & 0xE0) === 0xC0) { cp = ((b&0x1F)<<6)|(bytes[i+1]&0x3F); i+=2; }
else if ((b & 0xF0) === 0xE0) { cp = ((b&0xF)<<12)|((bytes[i+1]&0x3F)<<6)|(bytes[i+2]&0x3F); i+=3; }
else { cp = ((b&7)<<18)|((bytes[i+1]&0x3F)<<12)|((bytes[i+2]&0x3F)<<6)|(bytes[i+3]&0x3F); i+=4; }
str += cp > 0xFFFF ? String.fromCodePoint(cp) : String.fromCharCode(cp);
}
return str;
}
};
"#,
)?;
Ok(())
}
fn install_console(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
let console = Object::new(ctx.clone())?;
macro_rules! add_level {
($name:literal, $macro:ident) => {{
let name = $name;
console.set(
name,
Function::new(ctx.clone(), move |args: Rest<Value<'_>>| {
let parts: Vec<String> = args
.0
.iter()
.map(|v| {
v.as_string()
.and_then(|s| s.to_string().ok())
.unwrap_or_else(|| format!("{:?}", v))
})
.collect();
tracing::$macro!(target: "bex_js", "{}", parts.join(" "));
})?,
)?;
}};
}
add_level!("log", debug);
add_level!("info", debug);
add_level!("debug", trace);
add_level!("warn", warn);
add_level!("error", error);
// console.time / timeEnd stubs (no-op for now)
console.set(
"time",
Function::new(ctx.clone(), |_: String| {})?,
)?;
console.set(
"timeEnd",
Function::new(ctx.clone(), |_: String| {})?,
)?;
ctx.globals().set("console", console)?;
Ok(())
}
fn install_timers(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
ctx.eval::<(), _>(
r#"
// Call callback synchronously (compat for setTimeout(fn, 0) patterns)
globalThis.setTimeout = function(fn, ms) {
if (typeof fn === 'function') { try { fn(); } catch(e) {} }
return 0;
};
globalThis.clearTimeout = function(id) {};
globalThis.setInterval = function(fn, ms) {
if (typeof fn === 'function') { try { fn(); } catch(e) {} }
return 0;
};
globalThis.clearInterval = function(id) {};
globalThis.queueMicrotask = function(fn) {
if (typeof fn === 'function') { try { fn(); } catch(e) {} }
};
"#,
)?;
Ok(())
}
fn install_url(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
ctx.eval::<(), _>(
r#"
globalThis.URLSearchParams = class URLSearchParams {
constructor(init) {
this._map = {};
if (typeof init === 'string') {
init.replace(/^\?/, '').split('&').filter(Boolean).forEach(pair => {
const eq = pair.indexOf('=');
const k = eq >= 0 ? pair.substring(0, eq) : pair;
const v = eq >= 0 ? pair.substring(eq + 1) : '';
this._map[decodeURIComponent(k)] = decodeURIComponent(v);
});
}
}
get(k) { return this._map[k] !== undefined ? this._map[k] : null; }
set(k, v) { this._map[k] = String(v); }
delete(k) { delete this._map[k]; }
has(k) { return k in this._map; }
append(k, v) { this._map[k] = v; }
toString() {
return Object.entries(this._map)
.map(([k,v]) => encodeURIComponent(k)+'='+encodeURIComponent(v))
.join('&');
}
entries() { return Object.entries(this._map)[Symbol.iterator](); }
};
globalThis.URL = class URL {
constructor(url, base) {
this.href = url;
const m = url.match(/^([\w+.-]+:)\/\/([^/?#@]*@)?([^/?#:]*)(?::(\d+))?(\/[^?#]*)?(\?[^#]*)?(#.*)?$/);
if (m) {
this.protocol = m[1] || 'https:';
this.username = ''; this.password = '';
this.hostname = m[3] || '';
this.port = m[4] || '';
this.host = this.hostname + (this.port ? ':' + this.port : '');
this.pathname = m[5] || '/';
this.search = m[6] || '';
this.hash = m[7] || '';
this.origin = this.protocol + '//' + this.host;
this.searchParams = new URLSearchParams(this.search);
}
}
toString() { return this.href; }
};
"#,
)?;
Ok(())
}
fn install_crypto(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
// Install a Rust-backed __randomBytes function that the JS getRandomValues can call
ctx.globals().set(
"__randomBytes",
Function::new(ctx.clone(), |len: u32| -> rquickjs::Result<Vec<u8>> {
use rand::RngCore;
let mut buf = vec![0u8; len as usize];
rand::thread_rng().fill_bytes(&mut buf);
Ok(buf)
})?,
)?;
// Install crypto object with getRandomValues using the Rust-backed __randomBytes
ctx.eval::<(), _>(
r#"
globalThis.crypto = {
getRandomValues(arr) {
if (!(arr instanceof Uint8Array)) throw new TypeError('Expected Uint8Array');
const bytes = __randomBytes(arr.length);
for (let i = 0; i < arr.length; i++) arr[i] = bytes[i];
return arr;
},
randomUUID() {
const bytes = __randomBytes(16);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
return hex.slice(0,8) + '-' + hex.slice(8,12) + '-' + hex.slice(12,16) + '-' + hex.slice(16,20) + '-' + hex.slice(20);
}
};
"#,
)?;
// crypto.subtle: implemented in pure JS for AES, SHA, HMAC
ctx.eval::<(), _>(include_str!("../assets/crypto_subtle.js"))?;
Ok(())
}
fn install_globals(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
ctx.eval::<(), _>(
r#"
// Standard browser/worker global aliases
globalThis.self = globalThis;
globalThis.window = globalThis;
// performance.now() stub (monotonic ms)
globalThis.performance = {
_start: Date.now(),
now() { return Date.now() - this._start; }
};
// structuredClone (deep copy via JSON round-trip)
globalThis.structuredClone = function(obj) {
return JSON.parse(JSON.stringify(obj));
};
// navigator stub
globalThis.navigator = {
userAgent: 'Mozilla/5.0 (compatible; BexEngine/6.0)',
language: 'en-US',
};
// location stub (helps window.location patterns)
globalThis.location = {
href: 'https://bex.engine/',
hostname: 'bex.engine',
protocol: 'https:',
assign(url) { this.href = url; },
replace(url) { this.href = url; },
};
"#,
)?;
Ok(())
}
fn remove_dangerous(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
ctx.eval::<(), _>(
r#"
// Remove APIs that could be used to escape the sandbox or identify the host
delete globalThis.WebAssembly;
// Note: eval() is intentionally KEPT — many sites use eval(atob(...)) patterns
// Note: Function() constructor is kept for compat but monitored
"#,
)?;
Ok(())
}