cred: isolate scripts in WASI and move RSA signing to AWS-LC

This commit is contained in:
2026-11-06 22:22:49 +00:00
parent 5b3e1393f5
commit 86c5d8a4e4
217 changed files with 155392 additions and 391 deletions
+243
View File
@@ -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;
+58
View File
@@ -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;
}
}
+196
View File
@@ -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());
}