10 Commits
v1.0.1 ... 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
5 changed files with 200 additions and 79 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

View File

@@ -9,7 +9,15 @@ It's possible to provide extra functions via the `options.env` parameter.
Infinite loops are prevented via the `debug` library.
For now, sandbox.lua only works with Lua 5.1.x.
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
=====
@@ -22,9 +30,8 @@ local sandbox = require 'sandbox'
### sandbox.protect
`sandbox.protect(f)` (or `sandbox(f)`) produces a sandboxed version of `f`. `f` can be a Lua function or a string with Lua code.
A sandboxed function works as regular functions as long as they don't access any insecure features:
`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)
@@ -36,9 +43,10 @@ Sandboxed options can not access unsafe Lua modules. (See the [source code](http
When a sandboxed function tries to access an unsafe module, an error is produced.
```lua
local sf = sandbox.protect(function()
local sf = sandbox.protect([[
os.execute('rm -rf /') -- this will throw an error, no damage done
end)
end
]])
sf() -- error: os.execute not found
```
@@ -46,13 +54,36 @@ sf() -- error: os.execute not found
Sandboxed functions will eventually throw an error if they contain infinite loops:
```lua
local sf = sandbox.protect(function()
local sf = sandbox.protect([[
while true do end
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
@@ -67,7 +98,7 @@ sandbox.run('while true do end') -- raise errors after 500000 instructions
sandbox.run('while true do end', {quota=10000}) -- raise error after 10000 instructions
```
Note that if the quota is low enough, sandboxed functions that do lots of calculations might fail:
If the quota is low enough, sandboxed functions that do lots of calculations might fail:
``` lua
local f = function()
@@ -79,6 +110,8 @@ end
sandbox.run(f, {quota=100}) -- raises error before the function ends
```
Note: This feature is not available in LuaJIT
### options.env
Use the `env` option to inject additional variables to the environment in which the sandboxed function is executed.
@@ -95,14 +128,15 @@ recommended to discard it after use.
### sandbox.run
`sandbox.run(f)` sanboxes and executes `f` in a single line. `f` can be either a string or a function
`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.
In other words, `sandbox.run(f, o, ...)` is equivalent to `sandbox.protect(f,o)(...)`.
Any extra parameters will just be passed to the sandboxed function when executed, and available on the top-level scope via the `...` varargs parameters.
Notice that if `f` throws an error, it is *NOT* captured by `sandbox.run`. Use `pcall` if you want your app to be immune to errors, like this:
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")')
@@ -122,11 +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/*
busted 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.

View File

@@ -5,7 +5,7 @@ local sandbox = {
_LICENSE = [[
MIT LICENSE
Copyright (c) 2013 Enrique García Cota
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
@@ -25,14 +25,23 @@ 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)
@@ -43,9 +52,8 @@ local BASE_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
@@ -112,16 +120,17 @@ end
local function cleanup()
sethook()
string.rep = string_rep
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 = 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
@@ -129,11 +138,20 @@ function sandbox.protect(f, options)
local env = merge(options.env or {}, BASE_ENV)
env._G = env._G or env
setfenv(f, 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 then
if quota and quota_supported then
local timeout = function()
cleanup()
error('Quota exceeded: ' .. tostring(quota))
@@ -141,23 +159,24 @@ function sandbox.protect(f, options)
sethook(timeout, "", quota)
end
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,108 +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 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)
describe('when given infinite loops', function()
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)
end)
it('throws an error with infinite loops', function()
assert_error(function() sandbox.run("while true do 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))
end)
it('accepts a quota param', function()
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)
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))
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)
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)
end)
it('does not use quotes if the quote param is false', function()
assert_not_error(function() sandbox.run("for i=1,1000000 do end", {quota = false}) end)
end)
end)
end
describe('when given an env option', function()
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}))
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)