diff --git a/README.md b/README.md index 990005f..d039240 100644 --- a/README.md +++ b/README.md @@ -10,39 +10,52 @@ Usage local sandbox = require 'sandbox' - -- sandbox can handle both strings and functions - local msg = sandbox(function() return 'this is untrusted code' end) - local msg2 = sandbox("return 'this is also untrusted code'") +`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(function() - -- see sandbox.lua for a list of safe and unsafe operations - return ('I can use safe operations, like string.upper'):upper() + 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 + + local msg = sandbox.run(function() return 'this is untrusted code' end) + local msg2 = sandbox.run("return 'this is also untrusted code'") + +Only safe modules and operations can be accessed from the sandboxed mode. See the source code for a list of safe/unsafe operations. + + sandbox.run(function() + return string.upper('string.upper is a safe operation.') end) - -- Attempting to invoke unsafe operations (such as os.execute) is not possible - sandbox(function() +Attempting to invoke unsafe operations (such as `os.execute`) is not permitted + + sandbox.run(function() os.execute('rm -rf /') -- this will throw an error, no damage don end) - -- It is not possible to exhaust the machine with infinite loops; the following - -- will throw an error after invoking 500000 instructions: - sandbox('while true do end') +It is not possible to exhaust the machine with infinite loops; the following will throw an error after invoking 500000 instructions: - -- The amount of instructions executed can be tweaked via the quota option - sandbox('while true do end', {quota=10000}) -- throw error after 10000 instructions + sandbox.run('while true do end') - -- It is also possible to use the env option to add additional variables to the environment - sandbox('return foo', {env = {foo = 'This was on the environment'}}) +The amount of instructions executed can be tweaked via the `quota` option (default value: 500000 instructions) - -- The variables defined on the env are deep-copied and changes on them will not be persisted - local env = {foo = "can't touch this"} - sandbox('foo = "bar"', {env = env}) - assert(env.foo = "can't touch this") + sandbox.run('while true do end', {quota=10000}) -- throw error after 10000 instructions - -- If you want to modify variables from inside the sandbox, use the refs option: - local refs = {foo = "kindof insecure"} - sandbox('foo = "changed"', {refs = refs}) - assert(refs.foo = "changed") +It is also possible to use the env option to add additional variables to the environment + + sandbox.run('return foo', {env = {foo = 'This was on the environment'}}) + +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 + + 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) Installation diff --git a/sandbox.lua b/sandbox.lua index 696acc1..060cee7 100644 --- a/sandbox.lua +++ b/sandbox.lua @@ -7,7 +7,7 @@ local BASE_ENV = {} -- _G: Unsafe. It can be mocked though -- load{file|string}: All unsafe because they can grant acces to global env -- raw{get|set|equal}: Potentially unsafe --- module|require|package: Can modify the host settings +-- 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 @@ -36,79 +36,81 @@ string.sub string.upper table.insert table.maxn table.remove table.sort ]]):gsub('%S+', function(id) - local package, method = id:match('([^%.]+)%.([^%.]+)') - if package then - BASE_ENV[package] = BASE_ENV[package] or {} - BASE_ENV[package][method] = _G[package][method] + local module, method = id:match('([^%.]+)%.([^%.]+)') + if module then + BASE_ENV[module] = BASE_ENV[module] or {} + BASE_ENV[module][method] = _G[module][method] else BASE_ENV[id] = _G[id] end end) +local function protect_module(module, module_name) + return setmetatable({}, { + __index = module, + __newindex = function(_, attr_name, _) + error('Can not modify ' .. module_name .. '.' .. attr_name .. '. Protected by the sandbox.') + end + }) +end + +('coroutine math os string table'):gsub('%S+', function(module_name) + BASE_ENV[module_name] = protect_module(BASE_ENV[module_name], module_name) +end) + + local string_rep = string.rep -local copy -- defined below - -local function merge(destination, other) - if type(other) ~= 'table' then return other end - for k,v in pairs(other) do - destination[copy(k)] = copy(v) +local function merge(dest, source) + for k,v in pairs(source) do + dest[k] = dest[k] or v end - return destination + return dest end --- declared above -copy = function(other) - if type(other) ~= 'table' then return other end - local c = merge({}, other) - local mt = getmetatable(other) - if mt then setmetatable(c, copy(mt)) end - return c -end - - -local function cleanup(sandboxed_env, refs) +local function cleanup() debug.sethook() string.rep = string_rep - for k,_ in pairs(refs) do refs[k] = sandboxed_env[k] end end -local function run(f, options) - if type(f) == 'string' then f = assert(loadstring(f)) end +local function protect(f, options) + return function(...) + if type(f) == 'string' then f = assert(loadstring(f)) end - options = options or {} + options = options or {} - local quota = options.quota or 500000 - local env = options.env or {} - local refs = options.refs or {} + local quota = options.quota or 500000 + local env = merge(options.env or {}, BASE_ENV) - local sandboxed_env = merge(copy(BASE_ENV), env) - for k,v in pairs(refs) do sandboxed_env[k] = v end + setfenv(f, env) - setfenv(f, sandboxed_env) - - -- I would love to be able to make step greater than 1 - -- (say, 500000) but any value > 1 seems to choke with a simple while true do end - -- After ~100 iterations, they stop calling timeout. So I need to use step = 1 and - -- instructions_count the steps separatedly - local step = 1 - local instructions_count = 0 - local timeout = function(str) - instructions_count = instructions_count + 1 - if instructions_count >= quota then - cleanup(sandboxed_env, refs) - error('Quota exceeded: ' .. tostring(instructions_count) .. '/' .. tostring(quota) .. ' instructions') + -- I would love to be able to make step greater than 1 + -- (say, 500000) but any value > 1 seems to choke with a simple while true do end + -- After ~100 iterations, they stop calling timeout. So I need to use step = 1 and + -- instructions_count the steps separatedly + local step = 1 + local instructions_count = 0 + local timeout = function(str) + instructions_count = instructions_count + 1 + if instructions_count >= quota then + cleanup() + error('Quota exceeded: ' .. tostring(instructions_count) .. '/' .. tostring(quota) .. ' instructions') + end end + debug.sethook(timeout, "", step) + string.rep = nil + + local ok, result = pcall(f, ...) + + cleanup() + + if not ok then error(result) end + return result end - debug.sethook(timeout, "", step) - string.rep = nil - - local ok, result = pcall(f) - - cleanup(sandboxed_env, refs) - - if not ok then error(result) end - return result end -return setmetatable({run = run}, {__call = function(_,f,o) return run(f,o) end}) +local function run(f, options, ...) + return protect(f, options)(...) +end + +return setmetatable({protect = protect, run = run}, {__call = function(_,f,o) return protect(f,o) end}) diff --git a/spec/sandbox_spec.lua b/spec/sandbox_spec.lua index df3e539..53bbebe 100644 --- a/spec/sandbox_spec.lua +++ b/spec/sandbox_spec.lua @@ -1,108 +1,106 @@ local sandbox = require 'sandbox' -describe('sandbox', function() +describe('sandbox.run', function() - it('can run harmless functions', function() - local r = sandbox(function() return 'hello' end) - assert.equal(r, 'hello') + 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') + 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)")) + end) + + it('does not allow access to not-safe stuff', function() + assert.has_error(function() sandbox.run('return setmetatable({}, {})') end) + assert.has_error(function() sandbox.run('return string.rep("hello", 5)') end) + assert.has_error(function() sandbox.run('return _G.string.upper("hello")') end) + end) end) - it('can run harmless strings', function() - local r = sandbox("return 'hello'") - assert.equal(r, 'hello') - end) + describe('when handling string.rep', function() + it('does not allow pesky string:rep', function() + assert.has_error(function() sandbox.run('return ("hello"):rep(5)') end) + end) - it('has access to safe methods', function() - assert.equal(10, sandbox("return tonumber('10')")) - assert.equal('HELLO', sandbox("return string.upper('hello')")) - assert.equal(1, sandbox("local a = {3,2,1}; table.sort(a); return a[1]")) - assert.equal(10, sandbox("return math.max(1,10)")) - end) + it('restores the value of string.rep', function() + sandbox.run("") + assert.equal('hellohello', string.rep('hello', 2)) + end) - it('does not allow access to not-safe stuff', function() - assert.has_error(function() sandbox('return setmetatable({}, {})') end) - assert.has_error(function() sandbox('return string.rep("hello", 5)') end) - assert.has_error(function() sandbox('return _G.string.upper("hello")') end) - end) + it('restores string.rep even if there is an error', function() + assert.has_error(function() sandbox.run("error('foo')") end) + assert.equal('hellohello', string.rep('hello', 2)) + end) - it('does not allow pesky string:rep', function() - assert.has_error(function() sandbox('return ("hello"):rep(5)') end) - end) - - it('restores the value of string.rep', function() - sandbox("") - assert.equal('hellohello', string.rep('hello', 2)) - end) - - it('restores string.rep even if there is an error', function() - assert.has_error(function() sandbox("error('foo')") end) - assert.equal('hellohello', string.rep('hello', 2)) - end) - - it('should not persist modifying the packages', function() - sandbox("string.foo = 1") - assert.is_nil(sandbox("return string.foo")) + it('passes parameters to the function', function() + assert.equal(sandbox.run(function(a,b) return a + b end, {}, 1,2), 3) + end) end) - describe('when handling infinite loops', function() + describe('when the sandboxed function tries to modify the base environment', function() + + it('does not allow modifying the modules', function() + assert.has_error(function() sandbox.run("string.foo = 1") end) + assert.has_error(function() sandbox.run("string.char = 1") end) + end) + + it('does not persist modifications of base functions', function() + sandbox.run('error = function() end') + assert.has_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') + end) + end) + + + describe('when given infinite loops', function() it('throws an error with infinite loops', function() - assert.has_error(function() sandbox("while true do end") end) + assert.has_error(function() sandbox.run("while true do end") end) end) it('restores string.rep even after a while true', function() - assert.has_error(function() sandbox("while true do end") end) + assert.has_error(function() sandbox.run("while true do end") end) assert.equal('hellohello', string.rep('hello', 2)) end) it('accepts a quota param', function() - assert.no_has_error(function() sandbox("for i=1,100 do end") end) - assert.has_error(function() sandbox("for i=1,100 do end", {quota = 20}) end) + assert.no_has_error(function() sandbox.run("for i=1,100 do end") end) + assert.has_error(function() sandbox.run("for i=1,100 do end", {quota = 20}) end) end) end) + describe('when given an env option', function() it('is available on the sandboxed env', function() - assert.equal(1, sandbox("return foo", {env = {foo = 1}})) + assert.equal(1, sandbox.run("return foo", {env = {foo = 1}})) end) it('does not hide base env', function() - assert.equal('HELLO', sandbox("return string.upper(foo)", {env = {foo = 'hello'}})) + assert.equal('HELLO', sandbox.run("return string.upper(foo)", {env = {foo = 'hello'}})) end) - it('can not modify the env', function() + it('can modify the env', function() local env = {foo = 1} - sandbox("foo = 2", {env = env}) - assert.equal(env.foo, 1) + sandbox.run("foo = 2", {env = env}) + assert.equal(env.foo, 2) end) end) - describe('when given a refs option', function() - it('is available on the sandboxed env', function() - assert.equal(1, sandbox("return foo", {refs = {foo = 1}})) - end) - - it('does not hide base env', function() - assert.equal('HELLO', sandbox("return string.upper(foo)", {refs = {foo = 'hello'}})) - end) - - it('can modify the refs', function() - local refs = {foo = 1} - sandbox("foo = 2", {refs = refs}) - assert.equal(refs.foo, 2) - end) - - it('can modify the ref tables keys', function() - local refs = {items = {quantity = 1}} - sandbox("items.quantity = 2", {refs = refs}) - assert.equal(refs.items.quantity, 2) - end) - - - end) - - - end)