19 Commits
v0.5.0 ... 1.0

Author SHA1 Message Date
Enrique García Cota
f3c66a5c58 chore(ci) github actions for ci 2021-01-05 14:37:29 +01:00
Enrique García Cota
194a2a225d style(sandbox) minor comment changes / luacheck 2021-01-05 14:37:29 +01:00
Enrique García Cota
2a8a2445d3 feat(sandbox) block bytecode when possible 2021-01-05 14:37:29 +01:00
Enrique García Cota
71223d4fe9 feat(sandbox) explicitly drop support of quotas on LuaJIT
The solution we use in PUC Rio Lua (with debug.sethook) simply does not
work in LuaJIT.

* We have added a `sandbox.quota_supported` field to signal this feature
  (or lack of thereof)
* We explicitly return an error if `options.quota` is passed on a LuaJIT
  environment, in order to prevent LuaJIT users from believing that they
  are protected against infinite loops.
2021-01-05 13:21:26 +01:00
Enrique García Cota
242a749c4d feat(sandbox): only allow strings of Lua as params
This change drops support for "protecting" raw Lua functions.

There are two main reasons for this change:

* More modern versions of PUC Rio Lua don't have `setfenv`. It is
  possible to get around this by using the debug library, but that
  library is not available in all environments.
* Solutions based on `load` (which only allow string inputs) are
  objectively better since they give the user more control. For
  instance, you can deactivate support for binary code selectively.

As a result, we are using the `load`-based sandbox in all versions of
Lua that supports it, using `setfenv`-based sandboxing only when nothing
else is available (PUC Rio 5.1).

We are also explicitly raising an error if `options.mode` is passed but
we are using `setfenv`. This is to prevent users from believing they are
protected against binary code, when in fact they are not.
2021-01-05 13:13:43 +01:00
eskerda
67728e9ea4 feat(sandbox) return multiple values 2020-12-13 19:02:54 +01:00
eskerda
11ee23ae30 feat(sandbox) add load mode to string functions 2020-12-13 19:02:54 +01:00
eskerda
d49687555c chore(*) use busted for specs
it does no longer hang
2020-12-13 18:55:12 +01:00
eskerda
9a58f4e6e2 chore(*) add rockspec 2020-12-13 18:55:12 +01:00
eskerda
3d3a8c7549 chore(*) lua > 5.1 compatibility
* add a setfenv implementation
2020-12-13 18:54:55 +01:00
kikito
a4c0a9ad3d edit README 2014-04-28 13:58:39 +02:00
kikito
779c5c4bb0 edit README 2014-04-28 13:56:14 +02:00
kikito
bdecb751d7 added URL attribute to the lib 2013-09-14 13:19:23 +02:00
kikito
bf995029ba passing false as a quota deactivates the hooks 2013-09-14 12:54:49 +02:00
kikito
48ae2844e9 made sandbox survive if debug lib is not present 2013-09-14 12:49:46 +02:00
kikito
66a82c06ce merge copyright & license options. Clearer and easier 2013-09-13 15:56:55 +02:00
kikito
57224ac89d updated readme 2013-09-13 13:56:53 +02:00
kikito
549e31e7cd made _G available as a mocked up env inside the sandboxed env 2013-09-13 13:26:08 +02:00
kikito
721878115a updated README 2013-09-13 13:20:24 +02:00
5 changed files with 292 additions and 98 deletions

29
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: test
on: [push]
jobs:
test:
strategy:
matrix:
luaVersion: ["5.1", "5.2", "5.3", "5.4", "luajit", "luajit-openresty"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: leafo/gh-actions-lua@v8.0.0
with:
luaVersion: ${{ matrix.luaVersion }}
- uses: leafo/gh-actions-luarocks@v4.0.0
- name: build
run: |
luarocks install busted
luarocks install busted-htest
- name: test
run: |
busted -o htest

141
README.md
View File

@@ -3,59 +3,144 @@ sandbox.lua
A pure-lua solution for running untrusted Lua code.
For now, sandbox.lua only works with Lua 5.1.x.
The default behavior is restricting access to "dangerous" functions in Lua, such as `os.execute`.
It's possible to provide extra functions via the `options.env` parameter.
Infinite loops are prevented via the `debug` library.
Supported Lua versions:
======================
All the features of sandbox.lua work in the following Lua environments:
* PUC-Rio Lua 5.1 **allows execution of bytecode**, which is a huge limitation (see the bytecode section below)
* PUC-Rio Lua 5.2, 5.3, 5.4 have total support.
* LuaJIT is not protected against infinite loops (see the notes in `options.quota` below)
Usage
=====
Require the module like this:
``` lua
local sandbox = require 'sandbox'
```
`sandbox(f, options)` and `sandbox.protect(f, options)` are synonyms. They return a sandboxed version of `f`.
`options` is not required. So far the only possible options are `env` and `quota` (see below)
### sandbox.protect
`sandbox.protect("lua code")` (or `sandbox("lua code")`) produces a sandboxed function. The resulting sandboxed
function works as regular functions as long as they don't access any insecure features:
```lua
local sandboxed_f = sandbox(function() return 'hey' end)
local msg = sandboxed_f() -- msg is now 'hey'
```
`sandbox.run(f)` sanboxes a function and executes it. f can be either a string or a function
Sandboxed options can not access unsafe Lua modules. (See the [source code](https://github.com/kikito/sandbox.lua/blob/master/sandbox.lua#L35) for a list)
local msg = sandbox.run(function() return 'this is untrusted code' end)
local msg2 = sandbox.run("return 'this is also untrusted code'")
When a sandboxed function tries to access an unsafe module, an error is produced.
Only safe modules and operations can be accessed from the sandboxed mode. See the source code for a list of safe/unsafe operations.
```lua
local sf = sandbox.protect([[
os.execute('rm -rf /') -- this will throw an error, no damage done
end
]])
sandbox.run(function()
return string.upper('string.upper is a safe operation.')
end)
sf() -- error: os.execute not found
```
Attempting to invoke unsafe operations (such as `os.execute`) is not permitted
Sandboxed functions will eventually throw an error if they contain infinite loops:
sandbox.run(function()
os.execute('rm -rf /') -- this will throw an error, no damage don
end)
```lua
local sf = sandbox.protect([[
while true do end
]])
sf() -- error: quota exceeded
```
### Bytecode
It is possible to exit a sandbox using Lua bytecode. References:
* http://apocrypha.numin.it/talks/lua_bytecode_exploitation.pdf
* https://github.com/erezto/lua-sandbox-escape
* https://gist.github.com/corsix/6575486
Because of this, the sandbox deactivates bytecode in all the versions of Lua where it is possible:
* PUC-Rio Lua 5.2, 5.3, 5.4
* LuaJIT
In other words, _all except PUC-Rio Lua 5.1_.
** The sandbox can be exploited in PUC-Rio Lua 5.1 via bytecode **
The only reason we keep Lua 5.1 in the list of supported versions of Lua is because
sandboxing can help against users attempting to delete a file by mistake. _It does not provide
protection against malicious users_.
As a result we _strongly recommend updating to a more recent version when possible_.
### options.quota
`sandbox.lua` prevents infinite loops from halting the program by hooking the `debug` library to the sandboxed function, and "counting instructions". When
the instructions reach a certain limit, an error is produced.
This limit can be tweaked via the `quota` option. But default, it is 500000.
It is not possible to exhaust the machine with infinite loops; the following will throw an error after invoking 500000 instructions:
sandbox.run('while true do end')
``` lua
sandbox.run('while true do end') -- raise errors after 500000 instructions
sandbox.run('while true do end', {quota=10000}) -- raise error after 10000 instructions
```
The amount of instructions executed can be tweaked via the `quota` option (default value: 500000 instructions)
If the quota is low enough, sandboxed functions that do lots of calculations might fail:
sandbox.run('while true do end', {quota=10000}) -- throw error after 10000 instructions
``` lua
local f = function()
local count = 1
for i=1, 400 do count = count + 1 end
return count
end
It is also possible to use the env option to add additional variables to the environment
sandbox.run(f, {quota=100}) -- raises error before the function ends
```
sandbox.run('return foo', {env = {foo = 'This was on the environment'}})
Note: This feature is not available in LuaJIT
If provided, the env variable will be heavily modified by the sanbox (adding base modules like string)
The sandboxed code can also modify the env
### options.env
Use the `env` option to inject additional variables to the environment in which the sandboxed function is executed.
local msg = sandbox.run('return foo', {env = {foo = 'This is a global var on the the environment'}})
Note that the `env` variable will be modified by the sandbox (adding base modules like `string`). The sandboxed code can also modify it. It is
recommended to discard it after use.
local env = {amount = 1}
sandbox.run('amount = amount + 1', {env = env})
assert(env.amount = 2)
Finally, you may pass parameters to the sandboxed function directly in `sandbox.run`. Just add them after the `options` param.
local secret = sandbox.run(function(a,b) return a + b, {}, 1, 2)
assert(secret == 3)
### sandbox.run
`sandbox.run(code)` sandboxes and executes `code` in a single line. `code` must be a string with Lua code inside.
You can pass `options` param, and it will work like in `sandbox.protect`.
Any extra parameters will just be passed to the sandboxed function when executed, and available on the top-level scope via the `...` varargs parameters.
In other words, `sandbox.run(c, o, ...)` is equivalent to `sandbox.protect(c, o)(...)`.
Notice that if `code` throws an error, it is *NOT* captured by `sandbox.run`. Use `pcall` if you want your app to be immune to errors, like this:
``` lua
local ok, result = pcall(sandbox.run, 'error("this just throws an error")')
```
Installation
@@ -71,9 +156,9 @@ This library is released under the MIT license. See MIT-LICENSE.txt for details
Specs
=====
This project uses [telescope](https://github.com/norman/telescope) for its specs. In order to run them, install it and then:
This project uses [busted](https://github.com/Olivine-Labs/busted) for its specs. In order to run them, install it and then:
```
cd /path/to/where/the/spec/folder/is
tsc spec/*
I would love to use [busted](http://olivinelabs.com/busted/), but it has some incompatibility with `debug.sethook(f, "", quota)` and the tests just hanged up.
busted spec/*
```

View File

@@ -1,10 +1,12 @@
local sandbox = {
_VERSION = "sandbox 0.5",
_DESCRIPTION = "A pure-lua solution for running untrusted Lua code.",
_COPYRIGHT = "Copyright (c) 2013 Enrique García Cota",
_URL = "https://github.com/kikito/sandbox.lua",
_LICENSE = [[
MIT LICENSE
Copyright (c) 2021 Enrique García Cota
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
@@ -23,27 +25,35 @@ local sandbox = {
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
]],
}
-- quotas don't work in LuaJIT since debug.sethook works differently there
local quota_supported = type(_G.jit) == "nil"
sandbox.quota_supported = quota_supported
-- PUC-Rio Lua 5.1 does not support deactivation of bytecode
local bytecode_blocked = _ENV or type(_G.jit) == "table"
sandbox.bytecode_blocked = bytecode_blocked
-- The base environment is merged with the given env option (or an empty table, if no env provided)
--
local BASE_ENV = {}
-- List of non-safe packages/functions:
-- List of unsafe packages/functions:
--
-- * string.rep: can be used to allocate millions of bytes in 1 operation
-- * {set|get}metatable: can be used to modify the metatable of global objects (strings, integers)
-- * collectgarbage: can affect performance of other systems
-- * dofile: can access the server filesystem
-- * _G: It has access to everything. It could be mocked though.
-- * _G: It has access to everything. It can be mocked to other things though.
-- * load{file|string}: All unsafe because they can grant acces to global env
-- * raw{get|set|equal}: Potentially unsafe
-- * module|require|module: Can modify the host settings
-- * string.dump: Can display confidential server info (implementation of functions)
-- * string.rep: Can allocate millions of bytes in one go
-- * math.randomseed: Can affect the host sytem
-- * io.*, os.*: Most stuff there is non-save
-- * io.*, os.*: Most stuff there is unsafe, see below for exceptions
-- Safe packages/functions below
@@ -103,46 +113,70 @@ local function merge(dest, source)
return dest
end
local function sethook(f, key, quota)
if type(debug) ~= 'table' or type(debug.sethook) ~= 'function' then return end
debug.sethook(f, key, quota)
end
local function cleanup()
debug.sethook()
string.rep = string_rep
sethook()
string.rep = string_rep -- luacheck: no global
end
-- Public interface: sandbox.protect
function sandbox.protect(f, options)
if type(f) == 'string' then f = assert(loadstring(f)) end
function sandbox.protect(code, options)
options = options or {}
local quota = options.quota or 500000
local env = merge(options.env or {}, BASE_ENV)
local quota = false
if options.quota and not quota_supported then
error("options.quota is not supported on this environment (usually LuaJIT). Please unset options.quota")
end
if options.quota ~= false then
quota = options.quota or 500000
end
local env = merge(options.env or {}, BASE_ENV)
env._G = env._G or env
assert(type(code) == 'string', "expected a string")
local f
if bytecode_blocked then
f = assert(load(code, nil, 't', env))
else
f = assert(loadstring(code))
setfenv(f, env)
end
return function(...)
if quota and quota_supported then
local timeout = function()
cleanup()
error('Quota exceeded: ' .. tostring(quota))
end
sethook(timeout, "", quota)
end
debug.sethook(timeout, "", quota)
string.rep = nil
string.rep = nil -- luacheck: no global
local ok, result = pcall(f, ...)
local t = table.pack(pcall(f, ...))
cleanup()
if not ok then error(result) end
return result
if not t[1] then error(t[2]) end
return table.unpack(t, 2, t.n)
end
end
-- Public interface: sandbox.run
function sandbox.run(f, options, ...)
return sandbox.protect(f, options)(...)
function sandbox.run(code, options, ...)
return sandbox.protect(code, options)(...)
end
-- make sandbox(f) == sandbox.protect(f)
setmetatable(sandbox, {__call = function(_,f,o) return sandbox.protect(f,o) end})
setmetatable(sandbox, {__call = function(_,code,o) return sandbox.protect(code,o) end})
return sandbox

View File

@@ -0,0 +1,24 @@
package = "sandbox.lua"
version = "0.0.1-0"
source = {
url = "git://github.com/kikito/sandbox.lua.git",
tag = "0.0.1"
}
description = {
summary = "A pure-lua solution for running untrusted Lua code.",
homepage = "https://github.com/kikito/sandbox.lua",
}
dependencies = {
"lua >= 5.1",
}
build = {
type = "builtin",
modules = {
["sandbox"] = "sandbox.lua",
}
}

View File

@@ -3,103 +3,125 @@ local sandbox = require 'sandbox'
describe('sandbox.run', function()
describe('when handling base cases', function()
it('can run harmless functions', function()
local r = sandbox.run(function() return 'hello' end)
assert_equal(r, 'hello')
end)
it('can run harmless strings', function()
local r = sandbox.run("return 'hello'")
assert_equal(r, 'hello')
assert.equal(r, 'hello')
end)
if sandbox.bytecode_blocked then
it('rejects bytecode', function()
local fn = function() end
assert.error(function() sandbox.run(string.dump(fn)) end)
end)
else
it('accepts bytecode (PUC Rio 5.1)', function()
local fn = function() end
assert.has.no.error(function() sandbox.run(string.dump(fn)) end)
end)
end
it('has access to safe methods', function()
assert_equal(10, sandbox.run("return tonumber('10')"))
assert_equal('HELLO', sandbox.run("return string.upper('hello')"))
assert_equal(1, sandbox.run("local a = {3,2,1}; table.sort(a); return a[1]"))
assert_equal(10, sandbox.run("return math.max(1,10)"))
assert.equal(10, sandbox.run("return tonumber('10')"))
assert.equal('HELLO', sandbox.run("return string.upper('hello')"))
assert.equal(1, sandbox.run("local a = {3,2,1}; table.sort(a); return a[1]"))
assert.equal(10, sandbox.run("return math.max(1,10)"))
end)
it('does not allow access to not-safe stuff', function()
assert_error(function() sandbox.run('return setmetatable({}, {})') end)
assert_error(function() sandbox.run('return string.rep("hello", 5)') end)
assert_error(function() sandbox.run('return _G.string.upper("hello")') end)
assert.error(function() sandbox.run('return setmetatable({}, {})') end)
assert.error(function() sandbox.run('return string.rep("hello", 5)') end)
end)
it('does return multiple values', function()
local result = { sandbox.run("return 'hello', 'world'") }
assert.same({ 'hello', 'world' }, result)
end)
end)
describe('when handling string.rep', function()
it('does not allow pesky string:rep', function()
assert_error(function() sandbox.run('return ("hello"):rep(5)') end)
assert.error(function() sandbox.run('return ("hello"):rep(5)') end)
end)
it('restores the value of string.rep', function()
sandbox.run("")
assert_equal('hellohello', string.rep('hello', 2))
assert.equal('hellohello', string.rep('hello', 2))
end)
it('restores string.rep even if there is an error', function()
assert_error(function() sandbox.run("error('foo')") end)
assert_equal('hellohello', string.rep('hello', 2))
assert.error(function() sandbox.run("error('foo')") end)
assert.equal('hellohello', string.rep('hello', 2))
end)
it('passes parameters to the function', function()
assert_equal(sandbox.run(function(a,b) return a + b end, {}, 1,2), 3)
it('passes parameters to the code', function()
assert.equal(sandbox.run("local a, b = ...; return a + b", {}, 1,2), 3)
end)
end)
describe('when the sandboxed function tries to modify the base environment', function()
describe('when the sandboxed code tries to modify the base environment', function()
it('does not allow modifying the modules', function()
assert_error(function() sandbox.run("string.foo = 1") end)
assert_error(function() sandbox.run("string.char = 1") end)
assert.error(function() sandbox.run("string.foo = 1") end)
assert.error(function() sandbox.run("string.char = 1") end)
end)
it('does not persist modifications of base functions', function()
sandbox.run('error = function() end')
assert_error(function() sandbox.run("error('this should be raised')") end)
assert.error(function() sandbox.run("error('this should be raised')") end)
end)
it('DOES persist modification to base functions when they are provided by the base env', function()
local env = {['next'] = 'hello'}
sandbox.run('next = "bye"', {env=env})
assert_equal(env['next'], 'bye')
assert.equal(env['next'], 'bye')
end)
end)
if sandbox.quota_supported then
describe('when given infinite loops', function()
it('throws an error with infinite loops', function()
assert_error(function() sandbox.run("while true do end") end)
assert.error(function() sandbox.run("while true do end") end)
end)
it('restores string.rep even after a while true', function()
assert_error(function() sandbox.run("while true do end") end)
assert_equal('hellohello', string.rep('hello', 2))
assert.error(function() sandbox.run("while true do end") end)
assert.equal('hellohello', string.rep('hello', 2))
end)
it('accepts a quota param', function()
assert_not_error(function() sandbox.run("for i=1,100 do end") end)
assert_error(function() sandbox.run("for i=1,100 do end", {quota = 20}) end)
assert.has_no.errors(function() sandbox.run("for i=1,100 do end") end)
assert.error(function() sandbox.run("for i=1,100 do end", {quota = 20}) end)
end)
it('does not use quotes if the quote param is false', function()
assert.has_no.errors(function() sandbox.run("for i=1,1000000 do end", {quota = false}) end)
end)
end)
else
it('throws an error when trying to use the quota option in an unsupported environment (LuaJIT)', function()
assert.error(function() sandbox.run("", {quota = 20}) end)
end)
end
describe('when given an env option', function()
it('is available on the sandboxed env', function()
assert_equal(1, sandbox.run("return foo", {env = {foo = 1}}))
it('is available on the sandboxed env as the _G variable', function()
local env = {foo = 1}
assert.equal(1, sandbox.run("return foo", {env = env}))
assert.equal(env, sandbox.run("return _G", {env = env}))
end)
it('does not hide base env', function()
assert_equal('HELLO', sandbox.run("return string.upper(foo)", {env = {foo = 'hello'}}))
assert.equal('HELLO', sandbox.run("return string.upper(foo)", {env = {foo = 'hello'}}))
end)
it('can modify the env', function()
local env = {foo = 1}
sandbox.run("foo = 2", {env = env})
assert_equal(env.foo, 2)
assert.equal(env.foo, 2)
end)
end)