Trust model

View Source

asobi treats the Lua scripts mounted at /app/game as trusted in the same sense the /app/bin/asobi binary is trusted: you control what files end up there. The sandbox protects against incidental scripting bugs - infinite loops, missed nil checks, atom exhaustion driven by player input - and makes it harder for a compromised dependency or a required module to escape. It is not a defence against a deliberate, Erlang-aware adversary who can write /app/game/match.lua.

Documented properties

Three properties of the sandbox that are easy to re-derive wrongly, with where to check them.

Metatables cannot recover a stripped function

strip_dangerous_globals/1 in asobi_lua_loader sets each dangerous key to nil, and Luerl's set_table_key_key/4 erases the entry from the underlying dict rather than storing a nil. So setmetatable(_G, ...) and setmetatable(os, ...) remain allowed, and an __index metatable does intercept lookups for the now-absent keys - but the Erlang function references for os.execute and the rest lived only in the dict entry that was erased. Nothing else in the Luerl state holds them, so there is no Lua-reachable path back to them.

_ASOBI_LOADED is visible to script code

The require cache is installed as a global. A script can iterate it, mutate it or delete entries. There is no privilege boundary inside a single Luerl state, so this is by design: cross-match isolation comes from each match owning its own state, and a script that clobbers its own cache only denies itself. lookup_loaded in asobi_lua_loader turns a clobbered cache into a clean Lua error rather than a case_clause crash.

terrain_provider cannot inflate the atom table

A script returning { module = "<name>", ... } from terrain_provider/1 cannot mint an atom: the bridge uses binary_to_existing_atom/1. It also requires the module to be on an allowlist, so naming an unrelated loaded module (gen_server, rpc) is rejected with a terrain_provider_not_allowed warning. The default is asobi_terrain_flat and asobi_terrain_perlin, read through asobi_lua_env:get_env(terrain_providers, ...).

Per-callback budgets

Almost every Lua callback runs inside a child process spawned by the bounded_eval helper in asobi_lua_loader, with a wall-clock timeout, max_heap_size with kill => true, and a reduction budget. A runaway loop or allocation kills the child, the parent gen_server sees {error, timeout | heap_exhausted | reductions_exhausted}, and the match or zone continues on its previous state.

CallbackBridgeBoundedBudget
init/1match, worldyes1000 ms match, 2000 ms world
generate_world/2worldyes5000 ms
tick/1, zone_tick/2, post_tick/2match, worldyes500 ms
join/2, leave/2match, worldyes200 ms
get_state/{1,2}match, worldyes100 ms
spawn_position/2worldyes100 ms
vote_requested/1, vote_resolved/3matchyes200 ms
phases/1match, worldyes1000 ms match, 2000 ms world
on_phase_started/2, on_phase_ended/2match, worldyes200 ms
on_zone_loaded/3, on_zone_unloaded/3worldyes200 ms
spawn_templates/1worldyes2000 ms
on_world_recovered/2worldyes2000 ms
terrain_provider/1worldyes2000 ms
bot think/2botyes50 ms
handle_input/3match, worldnosee below

The macros are not all in one place. Match budgets are ?*_TIMEOUT in asobi_lua_match.erl and world budgets are ?*_TIMEOUT in asobi_lua_world.erl, but get_state/1 on the shared-state path has its own ?GET_STATE_TIMEOUT in asobi_lua_match_shared.erl, and the bot think budget is a literal 50 at the call site in asobi_bot.erl rather than a macro. Grep for asobi_lua_loader:call( if you need the authoritative set.

handle-input is not a sandbox boundary

handle_input/3 is the one callback that does not spawn-isolate. At realistic input rates - one tick times N players times the message rate - the per-call spawn cost dominated the actual Lua work: roughly 30 to 50 microseconds of spawn, monitor and heap-cap setup against 50 to 200 microseconds of input handling. Removing the wrapper recovered measured tail-latency wins at 200 players and 10 Hz input.

What that costs is worth stating precisely, because it is not a supervisor event. Input arrives as a cast and is queued; the queue is drained inside the tick, by apply_inputs/3 in asobi_match_server and in asobi_zone. There is no gen_server:call behind it and no call timeout to trip. A while true do end inside handle_input therefore hangs the match or zone process indefinitely: the tick stops, no supervisor restart happens, and every later call against that process times out in its own caller. Blast radius is one match or one zone. Recovery is manual.

So treat handle_input/3 as a hot path for trusted-author scripts, not as a boundary. Audit the inputs your script accepts, avoid dispatching on attacker-controlled strings, and treat it the way you would an Erlang handle_call/3 you wrote yourself. Per-tick safety belongs in tick/1, which still spawn-isolates and is the right place to enforce fairness across players.