Files
madbase/functions/src/deno_runtime.rs
Vlad Durnea 780e8b1c43
Some checks failed
CI/CD Pipeline / lint (push) Successful in 3m45s
CI/CD Pipeline / integration-tests (push) Failing after 55s
CI/CD Pipeline / unit-tests (push) Failing after 1m1s
CI/CD Pipeline / e2e-tests (push) Has been skipped
CI/CD Pipeline / build (push) Has been skipped
improved tests
2026-03-15 13:01:53 +02:00

255 lines
9.7 KiB
Rust

use anyhow::Result;
use deno_core::{JsRuntime, RuntimeOptions, v8};
use serde_json::Value;
use std::collections::HashMap;
pub struct DenoRuntime {
// We create a new runtime for each execution to ensure isolation
// In a production environment, we might want to pool runtimes or use isolates more efficiently
}
impl DenoRuntime {
pub fn new() -> Self {
Self {}
}
pub async fn execute(&self, code: String, payload: Option<Value>, headers: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>)> {
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let result = Self::execute_inner(code, payload, headers).await;
let _ = tx.send(result);
});
});
rx.await.map_err(|_| anyhow::anyhow!("Deno execution thread panicked"))?
}
async fn execute_inner(code: String, payload: Option<Value>, headers: HashMap<String, String>) -> Result<(String, String, u16, HashMap<String, String>)> {
// Initialize JS Runtime
let mut runtime = JsRuntime::new(RuntimeOptions::default());
// 1. Inject Preamble (Polyfills for Deno.serve, Request, Response, Headers)
let preamble = r#"
globalThis.console = {
log: (...args) => {
Deno.core.print(args.map(a => String(a)).join(" ") + "\n");
},
error: (...args) => {
Deno.core.print("[ERROR] " + args.map(a => String(a)).join(" ") + "\n", true);
}
};
class Headers {
constructor(init) {
this.map = new Map();
if (init) {
if (init instanceof Headers) {
init.forEach((v, k) => this.map.set(k.toLowerCase(), v));
} else if (Array.isArray(init)) {
init.forEach(([k, v]) => this.map.set(k.toLowerCase(), v));
} else {
Object.entries(init).forEach(([k, v]) => this.map.set(k.toLowerCase(), v));
}
}
}
get(key) { return this.map.get(key.toLowerCase()) || null; }
set(key, value) { this.map.set(key.toLowerCase(), value); }
has(key) { return this.map.has(key.toLowerCase()); }
forEach(callback) { this.map.forEach(callback); }
entries() { return this.map.entries(); }
}
globalThis.Headers = Headers;
globalThis.Deno = {
serve: (handler) => {
globalThis._handler = handler;
},
core: Deno.core,
env: {
get: (key) => {
return globalThis._env ? globalThis._env[key] : null;
},
toObject: () => {
return globalThis._env || {};
}
}
};
class Response {
constructor(body, init) {
this.body = body;
this.status = init?.status || 200;
this.headers = new Headers(init?.headers);
}
async text() { return String(this.body); }
async json() { return JSON.parse(this.body); }
}
globalThis.Response = Response;
class Request {
constructor(url, init) {
this.url = url;
this.method = init?.method || "GET";
this._body = init?.body;
this.headers = new Headers(init?.headers);
}
async json() { return typeof this._body === 'string' ? JSON.parse(this._body) : this._body; }
async text() { return typeof this._body === 'string' ? this._body : JSON.stringify(this._body); }
}
globalThis.Request = Request;
"#;
runtime.execute_script("<preamble>", preamble.to_string())?;
// 2. Execute User Code
runtime.execute_script("<user_script>", code.to_string())?;
// 3. Invoke Handler
// Double-serialize to prevent JS injection: the outer JSON string is parsed
// by JSON.parse() in JS, producing the original value safely.
let payload_json = serde_json::to_string(&payload.unwrap_or(serde_json::json!({})))?;
let headers_json = serde_json::to_string(&headers)?;
let safe_payload = serde_json::to_string(&payload_json)?;
let safe_headers = serde_json::to_string(&headers_json)?;
let invoke_script = format!(r#"
(async () => {{
if (!globalThis._handler) {{
return {{ error: "No handler registered via Deno.serve" }};
}}
try {{
const headers = JSON.parse({1});
const body = JSON.parse({0});
const req = new Request("http://localhost", {{
method: "POST",
body: typeof body === 'string' ? body : JSON.stringify(body),
headers: headers
}});
const res = await globalThis._handler(req);
const text = await res.text();
const resHeaders = {{}};
if (res.headers && typeof res.headers.forEach === 'function') {{
res.headers.forEach((v, k) => resHeaders[k] = v);
}}
return {{
result: text,
headers: resHeaders,
status: res.status
}};
}} catch (e) {{
return {{ error: String(e) }};
}}
}})()
"#, safe_payload, safe_headers);
let result_val = runtime.execute_script("<invocation>", invoke_script)?;
#[allow(deprecated)]
let result = runtime.resolve_value(result_val).await?;
let scope = &mut runtime.handle_scope();
let local = v8::Local::new(scope, result);
let deserialized_value: Value = deno_core::serde_v8::from_v8(scope, local)?;
let stdout = if let Some(res) = deserialized_value.get("result") {
res.as_str().unwrap_or("").to_string()
} else {
String::new()
};
let stderr = if let Some(err) = deserialized_value.get("error") {
err.as_str().unwrap_or("Unknown error").to_string()
} else {
String::new()
};
let status = if let Some(s) = deserialized_value.get("status") {
s.as_u64().unwrap_or(200) as u16
} else {
200
};
let mut headers = HashMap::new();
if let Some(h) = deserialized_value.get("headers") {
if let Some(obj) = h.as_object() {
for (k, v) in obj {
if let Some(s) = v.as_str() {
headers.insert(k.clone(), s.to_string());
}
}
}
}
Ok((stdout, stderr, status, headers))
}
}
#[cfg(test)]
mod tests {
use serde_json::{json, Value};
/// Validates that the double-serialization technique produces safe JS string
/// literals, even when the payload contains characters that could break out
/// of a JS template if interpolated naively.
#[test]
fn test_double_serialize_escapes_js_injection() {
let malicious_payload = json!({
"key": "\"); process.exit(1); //"
});
let first = serde_json::to_string(&malicious_payload).unwrap();
let double = serde_json::to_string(&first).unwrap();
// The double-serialized value must be a valid JSON string
let recovered_first: String = serde_json::from_str(&double).unwrap();
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
assert_eq!(recovered, malicious_payload);
}
#[test]
fn test_double_serialize_handles_backtick_injection() {
let payload = json!({
"attack": "${globalThis.Deno.exit()}"
});
let first = serde_json::to_string(&payload).unwrap();
let double = serde_json::to_string(&first).unwrap();
// The value when placed in a JS template literal is still just a string
let recovered_first: String = serde_json::from_str(&double).unwrap();
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
assert_eq!(recovered, payload);
}
#[test]
fn test_double_serialize_handles_empty() {
let payload = json!({});
let first = serde_json::to_string(&payload).unwrap();
let double = serde_json::to_string(&first).unwrap();
let recovered_first: String = serde_json::from_str(&double).unwrap();
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
assert_eq!(recovered, payload);
}
#[test]
fn test_double_serialize_preserves_unicode() {
let payload = json!({"emoji": "🔐", "chinese": "安全"});
let first = serde_json::to_string(&payload).unwrap();
let double = serde_json::to_string(&first).unwrap();
let recovered_first: String = serde_json::from_str(&double).unwrap();
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
assert_eq!(recovered, payload);
}
}