cred: isolate scripts in WASI and move RSA signing to AWS-LC
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
//! Bounded, stateless ES modules with JSON text boundaries. No host capabilities.
|
||||
use rquickjs::{Context, Ctx, Function, Module, Promise, Runtime, Value, context::intrinsic};
|
||||
use std::{
|
||||
cell::Cell,
|
||||
rc::Rc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Requested limits. Values are clamped by the evaluator, including zero.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Limits {
|
||||
pub max_time_ms: Option<u64>,
|
||||
pub max_memory_bytes: Option<usize>,
|
||||
pub max_stack_bytes: Option<usize>,
|
||||
}
|
||||
impl Limits {
|
||||
/// Effective time (1..1000ms), heap (256KiB..32MiB), stack (4..16KiB).
|
||||
pub fn clamped(self) -> Self {
|
||||
Self {
|
||||
max_time_ms: Some(self.max_time_ms.unwrap_or(100).clamp(1, 1000)),
|
||||
max_memory_bytes: Some(
|
||||
self.max_memory_bytes
|
||||
.unwrap_or(8 * 1024 * 1024)
|
||||
.clamp(256 * 1024, 32 * 1024 * 1024),
|
||||
),
|
||||
max_stack_bytes: Some(
|
||||
self.max_stack_bytes
|
||||
.unwrap_or(16 * 1024)
|
||||
.clamp(4 * 1024, 16 * 1024),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Stable error categories, independent of QuickJS's exception wording.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorKind {
|
||||
Limit,
|
||||
Syntax,
|
||||
Throw,
|
||||
NonJson,
|
||||
Engine,
|
||||
}
|
||||
/// An evaluation failure. Error text can include script-provided messages.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct Error {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
}
|
||||
fn failure(kind: ErrorKind, message: impl Into<String>) -> Error {
|
||||
Error {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Budget {
|
||||
start: Instant,
|
||||
time: Duration,
|
||||
ticks: Cell<u32>,
|
||||
limited: Cell<bool>,
|
||||
}
|
||||
impl Budget {
|
||||
// A finite job/callback allowance is also needed for a stream of tiny jobs
|
||||
// and for hosts with a stalled monotonic clock. Not an instruction count.
|
||||
fn step(&self) -> bool {
|
||||
self.ticks.set(self.ticks.get().saturating_add(1));
|
||||
let stop = self.ticks.get() >= 100_000 || self.start.elapsed() >= self.time;
|
||||
self.limited.set(self.limited.get() || stop);
|
||||
self.limited.get()
|
||||
}
|
||||
fn check(&self) -> Result<(), Error> {
|
||||
if self.step() {
|
||||
Err(failure(
|
||||
ErrorKind::Limit,
|
||||
"JavaScript execution limit exceeded",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe extern "C" {
|
||||
fn sausage_js_had_oom(ctx: *mut std::ffi::c_void) -> std::ffi::c_int;
|
||||
}
|
||||
fn memory_limited(ctx: &Ctx<'_>) -> bool {
|
||||
// The live Ctx owns this context and holds the runtime lock.
|
||||
unsafe { sausage_js_had_oom(ctx.as_raw().as_ptr().cast()) != 0 }
|
||||
}
|
||||
fn js_error(ctx: &Ctx<'_>, err: rquickjs::Error, phase: ErrorKind, budget: &Budget) -> Error {
|
||||
if memory_limited(ctx) {
|
||||
return failure(ErrorKind::Limit, "JavaScript memory limit exceeded");
|
||||
}
|
||||
if budget.limited.get() {
|
||||
return failure(ErrorKind::Limit, "JavaScript execution limit exceeded");
|
||||
}
|
||||
let mut kind = phase;
|
||||
let mut message = err.to_string();
|
||||
if matches!(err, rquickjs::Error::Exception) {
|
||||
let thrown = ctx.catch();
|
||||
if let Some(object) = thrown.as_object() {
|
||||
if let Ok(text) = object.get::<_, String>("message") {
|
||||
message = text;
|
||||
}
|
||||
// Only genuine engine Error instances determine resource/syntax kinds;
|
||||
// plain thrown objects remain throws even if they mimic the message.
|
||||
if thrown.is_error() {
|
||||
if message.contains("out of memory") || message.contains("stack size exceeded") {
|
||||
kind = ErrorKind::Limit;
|
||||
} else if object
|
||||
.get::<_, String>("name")
|
||||
.is_ok_and(|n| n == "SyntaxError")
|
||||
{
|
||||
kind = ErrorKind::Syntax;
|
||||
}
|
||||
}
|
||||
} else if let Some(text) = thrown.as_string()
|
||||
&& let Ok(text) = text.to_string()
|
||||
{
|
||||
message = text;
|
||||
}
|
||||
} else if matches!(err, rquickjs::Error::Allocation) {
|
||||
kind = ErrorKind::Limit;
|
||||
}
|
||||
if memory_limited(ctx) {
|
||||
return failure(
|
||||
ErrorKind::Limit,
|
||||
"JavaScript memory limit exceeded while reading error",
|
||||
);
|
||||
}
|
||||
if budget.check().is_err() {
|
||||
return failure(
|
||||
ErrorKind::Limit,
|
||||
"JavaScript execution limit exceeded while reading error",
|
||||
);
|
||||
}
|
||||
failure(kind, message)
|
||||
}
|
||||
fn settle<'js>(
|
||||
ctx: &Ctx<'js>,
|
||||
promise: Promise<'js>,
|
||||
budget: &Budget,
|
||||
) -> Result<Value<'js>, Error> {
|
||||
loop {
|
||||
budget.check()?;
|
||||
if let Some(result) = promise.result::<Value>() {
|
||||
return result.map_err(|e| js_error(ctx, e, ErrorKind::Throw, budget));
|
||||
}
|
||||
// This is synchronous queue draining, not polling an external resource.
|
||||
if !ctx.execute_pending_job() {
|
||||
return Err(failure(
|
||||
ErrorKind::Engine,
|
||||
"Promise is pending with no runnable jobs (no ambient async capabilities)",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile an ES module, call its selected export (default `default`) with parsed
|
||||
/// JSON, settle its returned Promise, and JSON.stringify the result. Fresh runtime
|
||||
/// each call. Only LLRT's atob/btoa globals are retained, with no module loader
|
||||
/// or ambient host capabilities. JSON.stringify
|
||||
/// semantics apply (unsupported object properties omitted, nonfinite => null).
|
||||
/// Limits cover compilation, execution, job draining and serialization; QuickJS
|
||||
/// interrupts are cooperative, not preemptive deadlines for every C builtin.
|
||||
pub fn evaluate(
|
||||
script: &str,
|
||||
input: &str,
|
||||
export: Option<&str>,
|
||||
limits: Limits,
|
||||
) -> Result<String, Error> {
|
||||
let limits = limits.clamped();
|
||||
let budget = Rc::new(Budget {
|
||||
start: Instant::now(),
|
||||
time: Duration::from_millis(limits.max_time_ms.unwrap_or(100)),
|
||||
ticks: Cell::new(0),
|
||||
limited: Cell::new(false),
|
||||
});
|
||||
let rt = Runtime::new().map_err(|e| failure(ErrorKind::Engine, e.to_string()))?;
|
||||
rt.set_memory_limit(limits.max_memory_bytes.unwrap_or(8 * 1024 * 1024));
|
||||
rt.set_max_stack_size(limits.max_stack_bytes.unwrap_or(16 * 1024));
|
||||
let cb = budget.clone();
|
||||
rt.set_interrupt_handler(Some(Box::new(move || cb.step())));
|
||||
// LLRT's BasePrimordials needs Date during initialization, as well as the
|
||||
// pure collection/regexp/typed-array intrinsics. Date is removed before
|
||||
// user code runs; no network, filesystem, process or timer modules load.
|
||||
let context = Context::custom::<(
|
||||
intrinsic::Eval,
|
||||
intrinsic::Json,
|
||||
intrinsic::Promise,
|
||||
intrinsic::Date,
|
||||
intrinsic::MapSet,
|
||||
intrinsic::RegExp,
|
||||
intrinsic::Proxy,
|
||||
intrinsic::TypedArrays,
|
||||
)>(&rt)
|
||||
.map_err(|e| failure(ErrorKind::Limit, format!("context creation failed: {e}")))?;
|
||||
context.with(|ctx| {
|
||||
llrt_buffer::init(&ctx)
|
||||
.map_err(|e| js_error(&ctx, e, ErrorKind::Engine, &budget))?;
|
||||
// Retain only atob/btoa from the published buffer initializer. Buffer's
|
||||
// external allocations bypass the heap limit; Blob/File are unnecessary
|
||||
// and File exposes the clock. Buffer has both a global lexical class
|
||||
// binding and a globalThis property: clear both. Date is init-only.
|
||||
// Disable QuickJS's structured stack hook before it can expose call sites.
|
||||
ctx.eval::<(), _>("Object.defineProperty(Error, 'prepareStackTrace', { value: undefined, writable: false, configurable: false }); Buffer = undefined; delete globalThis.Buffer; delete Math.random; delete globalThis.Date; delete globalThis.Blob; delete globalThis.File; for (const name of ['WebAssembly', 'SharedArrayBuffer', 'Atomics', 'Intl', '__infra', '__bootstrap', 'queueMicrotask']) delete globalThis[name];")
|
||||
.map_err(|e| js_error(&ctx, e, ErrorKind::Engine, &budget))?;
|
||||
let input = ctx
|
||||
.json_parse(input)
|
||||
.map_err(|e| js_error(&ctx, e, ErrorKind::Syntax, &budget))?;
|
||||
let module = Module::declare(ctx.clone(), "jsrun:entry", script)
|
||||
.map_err(|e| js_error(&ctx, e, ErrorKind::Syntax, &budget))?;
|
||||
let (module, ready) = module
|
||||
.eval()
|
||||
.map_err(|e| js_error(&ctx, e, ErrorKind::Throw, &budget))?;
|
||||
settle(&ctx, ready, &budget)?;
|
||||
let function: Function = module
|
||||
.get(export.unwrap_or("default"))
|
||||
.map_err(|e| js_error(&ctx, e, ErrorKind::Engine, &budget))?;
|
||||
let mut value: Value = function
|
||||
.call((input,))
|
||||
.map_err(|e| js_error(&ctx, e, ErrorKind::Throw, &budget))?;
|
||||
if let Some(promise) = value.as_promise() {
|
||||
value = settle(&ctx, promise.clone(), &budget)?;
|
||||
}
|
||||
let text = ctx
|
||||
.json_stringify(value)
|
||||
.map_err(|e| js_error(&ctx, e, ErrorKind::NonJson, &budget))?
|
||||
.ok_or_else(|| {
|
||||
failure(
|
||||
ErrorKind::NonJson,
|
||||
"result is undefined, a function, or otherwise not JSON serializable",
|
||||
)
|
||||
})?
|
||||
.to_string()
|
||||
.map_err(|e| js_error(&ctx, e, ErrorKind::NonJson, &budget))?;
|
||||
budget.check()?;
|
||||
Ok(text)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,58 @@
|
||||
//! One JSON call over stdin/stdout. Script exception text never leaves this guest.
|
||||
use sausage_cred_script::{ErrorKind, Limits, evaluate};
|
||||
use std::io::{Read, Write};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Request {
|
||||
source: String,
|
||||
input: Zeroizing<String>,
|
||||
export: Option<String>,
|
||||
max_time_ms: u64,
|
||||
max_memory_bytes: usize,
|
||||
}
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut wire = Zeroizing::new(String::new());
|
||||
std::io::stdin().read_to_string(&mut wire)?;
|
||||
let request: Request = serde_json::from_str(&wire)?;
|
||||
let result = evaluate(
|
||||
&request.source,
|
||||
&request.input,
|
||||
request.export.as_deref(),
|
||||
Limits {
|
||||
max_time_ms: Some(request.max_time_ms),
|
||||
max_memory_bytes: Some(request.max_memory_bytes),
|
||||
..Limits::default()
|
||||
},
|
||||
);
|
||||
let response = match result {
|
||||
Ok(result) => serde_json::json!({"result": result}),
|
||||
Err(error) => serde_json::json!({"kind": match error.kind {
|
||||
ErrorKind::Limit => "Limit",
|
||||
ErrorKind::Syntax => "Syntax",
|
||||
ErrorKind::Throw => "Throw",
|
||||
ErrorKind::NonJson => "NonJson",
|
||||
ErrorKind::Engine => "Engine",
|
||||
}}),
|
||||
};
|
||||
serde_json::to_writer(std::io::stdout().lock(), &response)?;
|
||||
std::io::stdout().flush()?;
|
||||
Ok(())
|
||||
}
|
||||
fn main() {
|
||||
if run().is_err() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_input_has_zeroizing_ownership() {
|
||||
let request: Request = serde_json::from_str(r#"{"source":"export default x=>x","input":"null","export":null,"max_time_ms":100,"max_memory_bytes":8388608}"#).unwrap();
|
||||
let _: &Zeroizing<String> = &request.input;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use super::*;
|
||||
fn run(s: &str) -> Result<String, Error> {
|
||||
evaluate(s, "null", None, Limits::default())
|
||||
}
|
||||
#[test]
|
||||
fn module_default_and_named() {
|
||||
assert_eq!(
|
||||
evaluate(
|
||||
"export default x => ({value:x}); export const other=x=>x+1",
|
||||
"42",
|
||||
None,
|
||||
Limits::default()
|
||||
)
|
||||
.unwrap(),
|
||||
"{\"value\":42}"
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(
|
||||
"export const other=x=>x+1",
|
||||
"42",
|
||||
Some("other"),
|
||||
Limits::default()
|
||||
)
|
||||
.unwrap(),
|
||||
"43"
|
||||
);
|
||||
assert_eq!(run("export default x=>null").unwrap(), "null");
|
||||
}
|
||||
#[test]
|
||||
fn promise_settlement() {
|
||||
assert_eq!(
|
||||
run("export default async x => { await Promise.resolve(); return 42; }").unwrap(),
|
||||
"42"
|
||||
);
|
||||
assert_eq!(
|
||||
run("await Promise.resolve(); export default x=>true").unwrap(),
|
||||
"true"
|
||||
);
|
||||
for s in [
|
||||
"export default x=>Promise.reject(new Error('rejected'))",
|
||||
"export default async x=>{ throw 'rejected'; }",
|
||||
"await Promise.reject('rejected'); export default x=>true",
|
||||
] {
|
||||
let e = run(s).unwrap_err();
|
||||
assert_eq!(e.kind, ErrorKind::Throw);
|
||||
assert_eq!(e.message, "rejected");
|
||||
}
|
||||
assert_eq!(
|
||||
run("export default x=>new Promise(()=>{})")
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Engine
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn typed_errors() {
|
||||
assert_eq!(run("export default (").unwrap_err().kind, ErrorKind::Syntax);
|
||||
assert_eq!(
|
||||
run("export default x=>{throw new Error('oops')}")
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Throw
|
||||
);
|
||||
for s in [
|
||||
"export default x=>undefined",
|
||||
"export default x=>(()=>1)",
|
||||
"export default x=>{let a={};a.a=a;return a}",
|
||||
"export default x=>Symbol()",
|
||||
] {
|
||||
assert_eq!(run(s).unwrap_err().kind, ErrorKind::NonJson);
|
||||
}
|
||||
assert_eq!(
|
||||
evaluate("export default x=>x", "not json", None, Limits::default())
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Syntax
|
||||
);
|
||||
assert_eq!(run("export const x=1").unwrap_err().kind, ErrorKind::Engine);
|
||||
assert_eq!(run("export default 1").unwrap_err().kind, ErrorKind::Engine);
|
||||
}
|
||||
#[test]
|
||||
fn recursion_is_bounded() {
|
||||
for stack in [4096, 16384, usize::MAX] {
|
||||
let e = evaluate(
|
||||
"export default x=>{function f(){return f()+1}return f()}",
|
||||
"null",
|
||||
None,
|
||||
Limits {
|
||||
max_stack_bytes: Some(stack),
|
||||
..Limits::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
eprintln!("STACK {stack}: {e:?}");
|
||||
assert_eq!(e.kind, ErrorKind::Limit);
|
||||
assert!(e.message.contains("stack"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn runaway_loop_and_jobs() {
|
||||
for s in [
|
||||
"export default x=>{while(true){}}",
|
||||
"while(true){};export default x=>x",
|
||||
"export default x=>({toJSON(){while(true){}}})",
|
||||
"export default x=>{throw {get message(){while(true){}}}}",
|
||||
"export default x=>new Promise(()=>{function f(){Promise.resolve().then(f)};f()})",
|
||||
] {
|
||||
let start = Instant::now();
|
||||
assert_eq!(run(s).unwrap_err().kind, ErrorKind::Limit);
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn runaway_allocation() {
|
||||
let e = evaluate(
|
||||
"export default x=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}",
|
||||
"null",
|
||||
None,
|
||||
Limits {
|
||||
max_time_ms: Some(1000),
|
||||
max_memory_bytes: Some(1024 * 1024),
|
||||
..Limits::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
eprintln!("HEAP {e:?}");
|
||||
assert_eq!(e.kind, ErrorKind::Limit);
|
||||
assert!(e.message.contains("memory"));
|
||||
}
|
||||
#[test]
|
||||
fn forbidden_globals_and_imports() {
|
||||
for name in [
|
||||
"Date",
|
||||
"performance",
|
||||
"Math.random",
|
||||
"fetch",
|
||||
"XMLHttpRequest",
|
||||
"require",
|
||||
"process",
|
||||
"std",
|
||||
"os",
|
||||
"fs",
|
||||
"crypto",
|
||||
"console",
|
||||
"setTimeout",
|
||||
] {
|
||||
assert_eq!(
|
||||
run(&format!("export default x=>typeof {name}")).unwrap(),
|
||||
"\"undefined\"",
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
assert!(run("import x from 'fs';export default x=>x").is_err());
|
||||
assert_eq!(
|
||||
run("export default x=>import('fs')").unwrap_err().kind,
|
||||
ErrorKind::Throw
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn isolation_and_limits() {
|
||||
run("export default x=>{globalThis.leak=42;return true}").unwrap();
|
||||
assert_eq!(
|
||||
run("export default x=>typeof leak").unwrap(),
|
||||
"\"undefined\""
|
||||
);
|
||||
assert_eq!(
|
||||
Limits {
|
||||
max_time_ms: Some(u64::MAX),
|
||||
max_memory_bytes: Some(usize::MAX),
|
||||
max_stack_bytes: Some(usize::MAX)
|
||||
}
|
||||
.clamped()
|
||||
.max_time_ms,
|
||||
Some(1000)
|
||||
);
|
||||
assert_eq!(
|
||||
Limits {
|
||||
max_time_ms: Some(0),
|
||||
..Limits::default()
|
||||
}
|
||||
.clamped()
|
||||
.max_time_ms,
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn timings() {
|
||||
let start = Instant::now();
|
||||
assert_eq!(run("export default x=>true").unwrap(), "true");
|
||||
eprintln!("TIMING first eval {}us", start.elapsed().as_micros());
|
||||
let start = Instant::now();
|
||||
for _ in 0..100 {
|
||||
assert_eq!(run("export default x=>true").unwrap(), "true");
|
||||
}
|
||||
eprintln!("TIMING 100 evals {}us", start.elapsed().as_micros());
|
||||
}
|
||||
Reference in New Issue
Block a user