Running untrusted JavaScript inside Elixir with WebAssembly

Every way people usually run user supplied code is a variation on the same move: take something dangerous and make it a little less dangerous. eval with a blocklist. A language sandbox that strips the scary globals. A container with seccomp. You start from full power and chip away, and you are never sure you chipped away enough, because the thing you forgot is exactly the thing someone finds.

I wanted the opposite default. Start from nothing. The function can do exactly what I hand it and not one call more, and “what I hand it” is a list I can read in one screen. Users get to write small functions, validate an order, email on a status change, hit an API on a schedule, and those functions run inside the app without me trusting them.

JavaScript is the obvious surface for that. It is the most widely known scripting language outside of specialized communities, and the cost of writing a small function in it is close to zero for most users. The engine choice is an engineering problem; the language choice is a user problem.

That framing is the whole post. Two ideas did the work. Everything else is plumbing I will admit to.

Idea one: the boundary is the product, the engine is a detail

WebAssembly gives you a guest that cannot read memory you did not hand it. That is the sandbox. By itself it is not enough, because a function that can compute but cannot read a record or send a mail is useless. I needed a door, and the door had to be narrow and typed.

The component model with WIT [1] is that door. You declare a world: the imports a guest may call and the export the host calls to start it. The guest sees the imports and nothing else. The host implements them. The types are checked at the edge.

package demo:cf;

interface data {
  query:  func(kind: string, filter-json: string) -> string;
  get:    func(kind: string, id: string) -> string;
  create: func(kind: string, data-json: string) -> string;
}
interface log     { line: func(level: string, msg: string); }
interface context { get: func() -> string; }

world cf {
  import data;
  import log;
  import context;
  export run: func(input: string) -> string;
}

A function is not “JavaScript with some globals removed.” It is a guest that can call query, get, create, log, and context, because those are the five things in its world, and nothing whispers about a filesystem or a socket because nothing in the world mentions one. The security review is reading the world file. That is the entire attack surface, on one screen, in a language a non specialist can follow.

Here is the part that took me a while to believe. Once the boundary is the contract, the JavaScript engine behind it is a swappable implementation detail. I started on ComponentizeJS [2], which embeds a SpiderMonkey build. Real, modern JavaScript, and a component around twelve megabytes that wants nine and a half megabytes of memory per instance before your code runs. Fine for one function, a problem for a few hundred small ones on a node.

I moved to quickjs-ng [3], compiled through wasm-rquickjs into a component with QuickJS embedded. The artifact dropped to about five megabytes and the per instance memory to a fraction of that. The migration touched zero lines of host code and zero lines of any function. I rebuilt the component and pointed the runtime at the new file. That is the payoff of putting the contract first: the expensive decision, which engine, became a cheap one.

(I looked at Javy [4] too, the tiny QuickJS option everyone reaches for. It is a stdin to stdout transform with no host imports. My whole design is the guest calling back during execution, so Javy is the wrong shape, not the wrong size. Shape beats size.)

Running it, and the limit I did not get for free

The runtime is Wasmex [5], which wraps Wasmtime [6] as a native module. The path a single call takes is short:

HTTP request
    |
    v
Phoenix route (Elixir)              authorize, scope to the caller
    |
    v
Wasmex.Components.call_function     runs in a BEAM process with a timeout
    |
    v
QuickJS component instance          fresh per call, memory + fuel capped
    |
    |  guest calls an import (data.query, log, context)
    +------------------------------> back into Elixir, already scoped
    |  <----------------------------- typed result
    v
run(input) -> result                JSON back to the caller

The lifecycle is compile once, instantiate per call. component_new compiles the component when the node boots and keeps the handle. Each invocation gets a fresh instance from that handle, which is the isolation guarantee: one request cannot leave anything behind for the next.

Limits are where the honesty lives. You want three: memory, an instruction budget, and a wall clock deadline. Wasmex hands you the first two. Memory is a store limit and it genuinely bites, ask for a one megabyte cap and the engine refuses to start because the JS runtime needs more than that to exist at all. Fuel, the instruction budget, is there through a flag plus a per store amount.

The third, an epoch deadline for wall clock time, was not exposed in the version I used. The textbook answer is to patch the native module. I did not, and here is why that was free on this platform. Elixir runs on the BEAM, the Erlang virtual machine, where every unit of work is a cheap isolated process, not an OS thread, and the scheduler is preemptive, so it can stop a process mid computation. I already run each function call in its own such process with a deadline attached. When the call overruns, the BEAM kills that one process and nothing else notices: no shared memory to corrupt, no other request affected, no node to restart. That is the wall clock kill switch, and it was sitting under me the whole time. So the runtime I did not write gave me the limit the engine did not expose. Fuel stops a tight loop, the process timeout stops a slow host call, and the gap between them is small enough to ignore. Reaching for the platform you are already standing on beats patching the one you are embedding.

The capabilities are Elixir closures handed in at instantiation. A granted capability is the real function, bound to the caller’s context so a query is scoped before the guest ever sees a row. A capability the manifest did not grant is a stub closure that returns an error. Same world every time, different closures per call. Revoking access is not a redeploy, it is a different map. (One hour lost to a detail: the import map is keyed by the full interface path, demo:cf/data, not the short name. The linker error says so once you learn to read it.)

Idea two: the runtime is a projection of the database

A function is a row. Name, body, a manifest of trigger and capabilities, and a pointer to the compiled artifact. Not a file in a directory, not an entry in a config module. A row.

This sounds like a storage decision and it is actually the operational model. The cron schedule is derived from the rows whose trigger is a schedule. The reaction routing, which function fires on which data change, is derived from the rows whose trigger is a reaction. The set of runnable functions is the set of rows. So when the node boots it does not restore state, it reads the table and rebuilds everything that table implies. A restart is a re-derivation, not a recovery. A crash is the same re-derivation. There is no separate deployment state to get out of sync with the database, because the database is the deployment state.

The triggers fall out of pieces that already exist. An HTTP function is a route that invokes the component. A scheduled function is a cron entry. A reaction function is the one with a moving part: a database trigger calls notify on any write, a listener decodes it, and a job queue runs the function. The trigger fires for any write from anywhere, which is the point, the function reacts to the data and not to the code path that happened to touch it. The queue is Oban [7], backed by the same database, because the durable thing I needed was already running and adding a message broker to get retries would have been a second system to operate for a feature the first one already has.

The fourth trigger, a pre-commit guard that can reject a write, I have not shipped. Doing it honestly needs the write wrapped in a transaction with a hook that can abort, and that boundary did not exist yet. A guard without a real transaction is a guard that lies, so it waits for the transaction rather than pretending.

What bit me

The QuickJS builtins are not cleanly feature gated. Turn most of them off to shrink the artifact and the runtime still reaches for Blob and a crypto helper at load and dies, so I keep fetch, crypto, zlib, and encoding on and stopped optimizing that number. The fetch builtin drags in an HTTP capability the host has to allow on purpose, even though my functions reach the network through my own interface, so I now disable the engine’s network and route egress through the door I control.

The build is a Rust compile, about a minute, and each function carries its own target directory near a gigabyte. Seven of them filled the disk. The full disk took the container runtime down, and the database went with it, and the app would not boot because it could not reach a database that no longer had a daemon to run on. The lesson was not “clean your build artifacts.” It was that a build artifact has no business living next to the thing serving requests. In production the compile runs elsewhere and the artifact is a blob in object storage, which is what the row’s pointer was always going to mean.

And do not run a sixty second compile inside the HTTP request that triggered it. A long synchronous request fails in ways that teach you nothing and blame the browser. Deploy returns immediately and the result is polled.

The part worth keeping

Strip this down and almost none of it is load bearing. The engine is replaceable, I replaced it. The queue is replaceable, it is just the durable thing that was already there. The trigger mechanism is a database feature, not a clever invention. The HTTP layer is a route.

Two things do not move. The typed capability boundary, which turns “is this sandbox tight enough” into “read the five lines of the world file.” And functions as data, which turns “is the deployment in sync” into “the deployment is a query.” Both replace a worry you carry forever with a thing you can look at. That is the only kind of architecture I trust anymore, the kind where the hard question got deleted instead of answered.


References

[1] WebAssembly Component Model — specification and WIT format

[2] ComponentizeJS — SpiderMonkey-based JS to WASM component compiler

[3] quickjs-ng — actively maintained QuickJS fork

[4] Javy — Shopify’s stdin/stdout QuickJS WASM runtime

[5] Wasmex — Elixir library wrapping Wasmtime

[6] Wasmtime — Bytecode Alliance WASM runtime

[7] Oban — Elixir background job processing backed by PostgreSQL