Embedding Luerl in Elixir: sandboxing untrusted scripts without crashing your node

LLM-authored, human-reviewed

Concurrency & the BEAM

When your application lets users or plugins supply custom logic - pricing rules, workflow filters, routing predicates, or user-authored automation - you face the sandbox problem. Running untrusted code safely is deceptively hard in most language ecosystems.

If you host a Ruby, Python, or Node.js backend, running arbitrary guest code in-process is fraught with danger. In Node, standard vm contexts share the underlying V8 isolate and process memory; escaping vm via constructor prototypes is well-documented. In Python or Ruby, dynamic introspection and standard library access mean that a single __import__('os').system('rm -rf /') or File.read('/etc/passwd') can compromise your entire host.

Teams typically retreat to heavy operational boundaries:

  • Spawning short-lived Docker or microVM (Firecracker) containers per evaluation.
  • Running dedicated out-of-process workers over Unix sockets or IPC with strict Linux seccomp and cgroup limits.
  • Delegating execution to external serverless functions.

These tools work, but they introduce operational friction: process startup latency, serialization overhead across IPC boundaries, container orchestration complexity, and substantial memory footprints.

On the BEAM (the Erlang and Elixir virtual machine), there is an alternative: Luerl, a pure-Erlang implementation of Lua 5.3 created by Robert Virding (one of Erlang’s co-inventors). Because Luerl executes Lua as pure Erlang data structures and functions, untrusted scripts run directly in-BEAM without C NIFs, without external OS processes, and under total isolation.

In-BEAM execution with Luerl

Lua is famous for its small language surface: a single associative data structure (the table), first-class functions, and concise syntax. Luerl compiles Lua source text into an Erlang representation and interprets it against an immutable state tuple.

To evaluate Lua from Elixir, you initialize a state, parse or compile your code, and evaluate it:

# Initialize a fresh Luerl state
state = :luerl.init()

# Evaluate a snippet directly
{:ok, results, _new_state} = :luerl.do("return 10 + 20", state)
# results => [30]

Under the hood, :luerl.init/0 builds an Erlang term containing the Lua environment - global tables, type definitions, and standard library functions.

When performance matters, compile scripts once and execute the compiled chunk across multiple requests:

# Parse and compile Lua code to an abstract syntax chunk
{:ok, chunk, state} = :luerl.load("return x * factor", state)

# Set input variables into state
{:ok, state_with_vars} = :luerl.set_table_keys(["x"], 5, state)
{:ok, state_with_vars} = :luerl.set_table_keys(["factor"], 3, state_with_vars)

# Execute the precompiled chunk
{:ok, [result], _final_state} = :luerl.call_chunk(chunk, state_with_vars)
# result => 15

Because Luerl is written in Erlang, the entire VM state is just an Erlang data structure. It lives on the process heap of the calling BEAM process.

Sandboxing boundaries: stripping dangerous tables

Standard Lua ships with libraries for operating system interaction, file input and output, and dynamic loading of C libraries. In a standard C-Lua environment, these capabilities can access the host machine.

In Luerl, default modules like os, io, and package are also registered during :luerl.init/0. If you evaluate untrusted code without modifications, a script could call :os.cmd through Erlang interop or invoke Lua’s os.execute.

Sandboxing in Luerl is straightforward: you delete or replace dangerous entries in the global table before running untrusted code.

Key targets for removal include:

  • os - operating system facilities, date/time, shell execution.
  • io - file and stream access.
  • package (especially package.loadlib and searchers) - dynamic module loading.
  • dofile and loadfile - reading external files into the Lua runtime.

Here is how you build a hardened Luerl sandbox in Elixir:

defmodule LuaSandbox do
  @dangerous_globals [
    ["os"],
    ["io"],
    ["package", "loadlib"],
    ["package", "loaders"],
    ["package", "searchers"],
    ["dofile"],
    ["loadfile"]
  ]

  @doc """
  Initializes a pristine, stripped Luerl state safe for untrusted code.
  """
  def init_sandboxed do
    base_state = :luerl.init()

    Enum.reduce(@dangerous_globals, base_state, fn path, acc_state ->
      case :luerl.set_table_keys(path, nil, acc_state) do
        {:ok, stripped_state} -> stripped_state
        _ -> acc_state
      end
    end)
  end
end

Setting a key path to nil deletes it from Luerl’s internal tables. If an untrusted script attempts to call os.execute("rm -rf /"), Lua evaluates os as nil and immediately halts execution with an error:

sandboxed_state = LuaSandbox.init_sandboxed()

case :luerl.do("os.execute('whoami')", sandboxed_state) do
  {:lua_error, reason, _state} ->
    # Lua runtime error: attempt to index a nil value (global 'os')
    # reason => {:illegal_index, nil, "execute"}
    {:error, "Sandbox blocked execution: #{inspect(reason)}"}
end

The script cannot escape the sandbox to inspect the filesystem, spawn OS processes, or interfere with other BEAM memory.

Fuel and preemption: preventing infinite loops

A safe sandbox must guard against denial-of-service attacks. What happens if a user submits a script with an infinite loop or a memory explosion?

while true do
  -- infinite computation
end

In a single-threaded runtime like Node or standard Python, an infinite loop in a C extension blocks the OS thread entirely.

Because Luerl executes within BEAM processes, we benefit from the Erlang scheduler:

  1. BEAM Preemption via Reductions: Every BEAM process has a reduction counter (roughly one function call or loop cycle per reduction). When a process uses 4,000 reductions, the BEAM preempts it and schedules another process. An infinite loop in Luerl will never starve your server cores or freeze your Phoenix web endpoints.

  2. Process Timeouts and Supervision: To prevent an infinite loop from running forever in the background, wrap the evaluation in a supervised task with a strict timeout:

defmodule LuaRunner do
  @timeout_ms 1000

  def safe_eval(lua_code, state) do
    task = Task.async(fn ->
      case :luerl.do(lua_code, state) do
        {:ok, results, new_state} -> {:ok, results, new_state}
        {:lua_error, reason, _state} -> {:error, reason}
        {:error, reason, _state} -> {:error, reason}
      end
    end)

    case Task.yield(task, @timeout_ms) || Task.shutdown(task, :brutal_kill) do
      {:ok, {:ok, results, _new_state}} ->
        {:ok, results}

      {:ok, {:error, reason}} ->
        {:error, "Execution error: #{inspect(reason)}"}

      nil ->
        {:error, "Execution timed out after #{@timeout_ms}ms"}
    end
  end
end

When Task.shutdown(task, :brutal_kill) executes, the BEAM immediately terminates the process executing Luerl. Its process heap and all allocated tables are reclaimed in one garbage-collection sweep. The rest of your application continues uninterrupted.

Host-guest interop: data encoding and callbacks

A sandbox is only useful if your host application can inject data and expose controlled functionality to the guest script.

1. Passing Elixir data to Lua (:luerl.encode/2)

Luerl provides :luerl.encode/2 to convert Elixir terms (maps, lists, numbers, binaries) into Lua values and tables:

state = LuaSandbox.init_sandboxed()

user_context = %{
  "user_id" => 42,
  "role" => "editor",
  "tags" => ["engineering", "elixir"]
}

# Encode Elixir map into Lua table representation
{lua_user, state} = :luerl.encode(user_context, state)

# Expose as a global variable `user` in Lua
{:ok, state} = :luerl.set_table_keys(["user"], lua_user, state)

script = """
if user.role == "editor" then
  return "Access granted for user " .. tostring(user.user_id)
else
  return "Access denied"
end
"""

{:ok, [message], _} = :luerl.do(script, state)
# message => "Access granted for user 42"

2. Exposing Elixir functions as Lua callbacks

You can expose safe Elixir functions to Lua as callbacks using {:erl_func, fun}. The Elixir function takes (arguments, state) and returns {return_values, state}:

state = LuaSandbox.init_sandboxed()

# Define an Elixir callback exposed to Lua
hash_callback = fn args, st ->
  case args do
    [input] when is_binary(input) ->
      digest = :crypto.hash(:sha256, input) |> Base.encode16(case: :lower)
      {[digest], st}

    _ ->
      {[:nil], st}
  end
end

# Register the callback in Lua under `crypto.sha256`
{:ok, state} = :luerl.set_table_keys(["crypto", "sha256"], {:erl_func, hash_callback}, state)

script = """
local signature = crypto.sha256("payload-data")
return signature
"""

{:ok, [signature], _} = :luerl.do(script, state)
# signature => "a57636..."

By providing curated callbacks, you give user scripts exactly the capabilities they need - querying a specific cache key, logging formatted messages, or calculating metrics - while completely denying raw system access.

Summary

Embedding untrusted script execution in web applications usually demands heavy operational safeguards. With Luerl on the BEAM, the properties of the Erlang runtime become your sandbox:

  • Total memory isolation: Each evaluation runs in its own process or state, isolated from the rest of the application.
  • Selective capabilities: Dangerous global modules like os and io are stripped with :luerl.set_table_keys/3.
  • Preemptive scheduling: The BEAM reduction model prevents CPU starvation.
  • Zero-leak teardown: Crashing or terminating the executing task instantaneously reclaims all allocated memory.

For lightweight domain rules, rule engines, and user scripts, Luerl offers a secure in-BEAM execution environment with minimal overhead.

← Back to articles