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— noos,io,require, orload. - 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
mathabove) instead of sharing the real ones, or a script could replacemath.floorfor 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.