Skip to content
AgileLogics
6 min read

Environments and Sandboxing

Every chunk of Lua code looks up globals in a table called its environment, _ENV. By default that's the global table _G — but you can give code a different one. That's how hosts run untrusted scripts safely.

_ENV

local print = print

do
  local _ENV = {x = 10}
  y = x * 2          -- sets _ENV.y, not a real global
  print(x, y)
end

print(y)
10	20
nil

A free name like x is really _ENV.x. Inside the block, _ENV is a small table, so y was stored there and never became a global. (print had to be saved in a local first, because the new _ENV doesn't contain it.)

Running code in a sandbox

load(code, name, mode, env) compiles code with a given environment:

local function runSandboxed(code)
  local env = {
    print = print,
    math = {floor = math.floor, max = math.max, min = math.min},
    string = {format = string.format, upper = string.upper},
    pairs = pairs, ipairs = ipairs, tostring = tostring,
  }
  local fn, err = load(code, "user script", "t", env)
  if not fn then return false, err end
  return pcall(fn)
end

print(runSandboxed([[ print(string.upper("hello"), math.max(3, 7)) ]]))
print(runSandboxed([[ os.remove("important.txt") ]]))
print(runSandboxed([[ io.open("secrets.txt") ]]))
print(runSandboxed([[ this is not lua ]]))
HELLO	7
true
false	[string "user script"]:1: attempt to index a nil value (global 'os')
false	[string "user script"]:1: attempt to index a nil value (global 'io')
false	[string "user script"]:1: syntax error near 'is'
  • The script sees only what you put in env — no os, io, require, or load.
  • Mode "t" accepts text only; loading precompiled bytecode from untrusted sources is unsafe, because malformed bytecode can crash the interpreter.
  • Copy library tables (as with math above) instead of sharing the real ones, or a script could replace math.floor for everyone.

Limiting run time

A sandbox also needs protection from infinite loops. A debug hook can abort a script after a number of instructions:

local function runWithLimit(code, maxInstructions)
  local fn = assert(load(code, "script", "t", {}))
  local co = coroutine.create(fn)
  debug.sethook(co, function() error("instruction limit exceeded") end, "", maxInstructions)
  local ok, err = coroutine.resume(co)
  return ok, err
end

print(runWithLimit("local n = 0 for i = 1, 100 do n = n + i end", 100000))
print(runWithLimit("while true do end", 100000))
true	nil
false	main.lua:4: instruction limit exceeded

Memory limits need a custom allocator in the host's C code. Roblox, Redis, and game engines combine all of these techniques.