-
Notifications
You must be signed in to change notification settings - Fork 170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sandbox policy loading #566
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,280 @@ | ||
--- Policy loader | ||
-- This module loads a policy defined by its name and version. | ||
-- It uses sandboxed require to isolate dependencies and not mutate global state. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe emphasize somehow that this loader allows us to load several versions of the same policy without problems? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep. It even allows loading the same policy several times without global state. Will emphasize that. |
||
-- That allows for loading several versions of the same policy with different dependencies. | ||
-- And even loading several independent copies of the same policy with no shared state. | ||
-- Each object returned by the loader is new table and shares only shared APIcast code. | ||
|
||
local format = string.format | ||
local error = error | ||
local type = type | ||
local loadfile = loadfile | ||
local getenv = os.getenv | ||
local insert = table.insert | ||
local setmetatable = setmetatable | ||
local concat = table.concat | ||
|
||
local _G = _G | ||
local _M = {} | ||
|
||
local searchpath = package.searchpath | ||
local root_loaded = package.loaded | ||
|
||
local root_require = require | ||
|
||
local preload = package.preload | ||
|
||
--- create a require function not using the global namespace | ||
-- loading code from policy namespace should have no effect on the global namespace | ||
-- but poliocy can load shared libraries that would be cached globally | ||
local function gen_require(package) | ||
|
||
local function not_found(modname, err) | ||
return error(format("module '%s' not found:%s", modname, err), 0) | ||
end | ||
|
||
--- helper function to safely use the native require function | ||
local function fallback(modname) | ||
local mod | ||
|
||
mod = package.loaded[modname] | ||
|
||
if not mod then | ||
ngx.log(ngx.DEBUG, 'native require for: ', modname) | ||
mod = root_require(modname) | ||
end | ||
|
||
return mod | ||
end | ||
|
||
--- helper function to find and return correct loader for a module | ||
local function find_loader(modname) | ||
local loader, file, err, ret | ||
|
||
-- http://www.lua.org/manual/5.2/manual.html#pdf-package.searchers | ||
|
||
-- When looking for a module, require calls each of these searchers in ascending order, | ||
for i=1, #package.searchers do | ||
-- with the module name (the argument given to require) as its sole parameter. | ||
ret, err = package.searchers[i](modname) | ||
|
||
-- The function can return another function (the module loader) | ||
-- plus an extra value that will be passed to that loader, | ||
if type(ret) == 'function' then | ||
loader = ret | ||
file = err | ||
break | ||
-- or a string explaining why it did not find that module | ||
elseif type(ret) == 'string' then | ||
err = ret | ||
end | ||
-- (or nil if it has nothing to say). | ||
end | ||
|
||
return loader, file, err | ||
end | ||
|
||
--- reimplemented require function | ||
-- - return a module if it was already loaded (globally or locally) | ||
-- - try to find loader function | ||
-- - fallback to global require | ||
-- @tparam string modname module name | ||
-- @tparam boolean exclusive load only policy code, turns off the fallback loader | ||
return function(modname, exclusive) | ||
-- http://www.lua.org/manual/5.2/manual.html#pdf-require | ||
ngx.log(ngx.DEBUG, 'sandbox require: ', modname) | ||
|
||
-- The function starts by looking into the package.loaded table | ||
-- to determine whether modname is already loaded. | ||
-- NOTE: this is different from the spec: use the top level package.loaded, | ||
-- otherwise it would try to sandbox load already loaded shared code | ||
local mod = root_loaded[modname] | ||
|
||
-- If it is, then require returns the value stored at package.loaded[modname]. | ||
if mod then return mod end | ||
|
||
-- Otherwise, it tries to find a loader for the module. | ||
local loader, file, err = find_loader(modname) | ||
|
||
-- Once a loader is found, | ||
if loader then | ||
ngx.log(ngx.DEBUG, 'sandboxed require for: ', modname, ' file: ', file) | ||
-- require calls the loader with two arguments: | ||
-- modname and an extra value dependent on how it got the loader. | ||
-- (If the loader came from a file, this extra value is the file name.) | ||
mod = loader(modname, file) | ||
elseif not exclusive then | ||
ngx.log(ngx.DEBUG, 'fallback loader for: ', modname, ' error: ', err) | ||
mod = fallback(modname) | ||
else | ||
-- If there is any error loading or running the module, | ||
-- or if it cannot find any loader for the module, then require raises an error. | ||
return not_found(modname, err) | ||
end | ||
|
||
-- If the loader returns any non-nil value, | ||
if mod ~= nil then | ||
-- require assigns the returned value to package.loaded[modname]. | ||
package.loaded[modname] = mod | ||
|
||
-- If the loader does not return a non-nil value | ||
-- and has not assigned any value to package.loaded[modname], | ||
elseif not package.loaded[modname] then | ||
-- then require assigns true to this entry. | ||
package.loaded[modname] = true | ||
end | ||
|
||
-- In any case, require returns the final value of package.loaded[modname]. | ||
return package.loaded[modname] | ||
end | ||
end | ||
|
||
local function export(list, env) | ||
assert(env, 'missing env') | ||
list:gsub('%S+', function(id) | ||
local module, method = id:match('([^%.]+)%.([^%.]+)') | ||
if module then | ||
env[module] = env[module] or {} | ||
env[module][method] = _G[module][method] | ||
else | ||
env[id] = _G[id] | ||
end | ||
end) | ||
|
||
return env | ||
end | ||
|
||
--- this is environment exposed to the policies | ||
-- that means this is very light sandbox so policies don't mutate global env | ||
-- and most importantly we replace the require function with our own | ||
-- The env intentionally does not expose getfenv so sandboxed code can't get top level globals. | ||
-- And also does not expose functions for loading code from filesystem (loadfile, dofile). | ||
-- Neither exposes debug functions unless openresty was compiled --with-debug. | ||
-- But it exposes ngx as the same object, so it can be changed from within the policy. | ||
_M.env = export([[ | ||
_VERSION assert print xpcall pcall error | ||
unpack next ipairs pairs select | ||
collectgarbage gcinfo newproxy loadstring load | ||
setmetatable getmetatable | ||
tonumber tostring type | ||
rawget rawequal rawlen rawset | ||
|
||
bit.arshift bit.band bit.bnot bit.bor bit.bswap bit.bxor | ||
bit.lshift bit.rol bit.ror bit.rshift bit.tobit bit.tohex | ||
|
||
coroutine.create coroutine.resume coroutine.running coroutine.status | ||
coroutine.wrap coroutine.yield coroutine.isyieldable | ||
|
||
debug.traceback | ||
|
||
io.open io.close io.flush io.tmpfile io.type | ||
io.input io.output io.stderr io.stdin io.stdout | ||
io.popen io.read io.lines io.write | ||
|
||
math.abs math.acos math.asin math.atan math.atan2 | ||
math.ceil math.cos math.cosh math.deg math.exp math.floor | ||
math.fmod math.frexp math.ldexp math.log math.pi | ||
math.log10 math.max math.min math.modf math.pow | ||
math.rad math.random math.randomseed math.huge | ||
math.sin math.sinh math.sqrt math.tan math.tanh | ||
|
||
os.clock os.date os.time os.difftime | ||
os.execute os.getenv | ||
os.rename os.tmpname os.remove | ||
|
||
string.byte string.char string.dump string.find | ||
string.format string.lower string.upper string.len | ||
string.gmatch string.match string.gsub string.sub | ||
string.rep string.reverse | ||
|
||
table.concat table.foreach table.foreachi table.getn | ||
table.insert table.maxn table.move table.pack | ||
table.remove table.sort table.unpack | ||
|
||
ngx | ||
]], {}) | ||
|
||
_M.env._G = _M.env | ||
|
||
-- add debug functions only when nginx was compiled --with-debug | ||
if ngx.config.debug then | ||
_M.env = export([[ debug.debug debug.getfenv debug.gethook debug.getinfo | ||
debug.getlocal debug.getmetatable debug.getregistry | ||
debug.getupvalue debug.getuservalue debug.setfenv | ||
debug.sethook debug.setlocal debug.setmetatable | ||
debug.setupvalue debug.setuservalue debug.upvalueid debug.upvaluejoin | ||
]], _M.env) | ||
end | ||
|
||
local mt = { | ||
__call = function(loader, ...) return loader.env.require(...) end | ||
} | ||
|
||
function _M.new(name, version, dir) | ||
local apicast_dir = dir or getenv('APICAST_DIR') or '.' | ||
|
||
local path = { | ||
-- first path contains | ||
format('%s/policies/%s/%s/?.lua', apicast_dir, name, version), | ||
} | ||
|
||
if version == 'builtin' then | ||
insert(path, format('%s/src/apicast/policy/%s/?.lua', apicast_dir, name)) | ||
end | ||
|
||
-- need to create global variable package that mimics the native one | ||
local package = { | ||
loaded = {}, | ||
preload = preload, | ||
searchers = {}, -- http://www.lua.org/manual/5.2/manual.html#pdf-package.searchers | ||
searchpath = searchpath, | ||
path = concat(path, ';'), | ||
cpath = '', -- no C libraries allowed in policies | ||
} | ||
|
||
-- creating new env for each policy means they can't accidentaly share global variables | ||
local env = setmetatable({ | ||
require = gen_require(package), | ||
package = package, | ||
}, { __index = _M.env }) | ||
|
||
-- The first searcher simply looks for a loader in the package.preload table. | ||
insert(package.searchers, function(modname) return package.preload[modname] end) | ||
-- The second searcher looks for a loader as a Lua library, using the path stored at package.path. | ||
-- The search is done as described in function package.searchpath. | ||
insert(package.searchers, function(modname) | ||
local file, err = searchpath(modname, package.path) | ||
local loader | ||
|
||
if file then | ||
loader, err = loadfile(file, 'bt', env) | ||
|
||
ngx.log(ngx.DEBUG, 'loading file: ', file) | ||
|
||
if loader then return loader, file end | ||
end | ||
|
||
return err | ||
end) | ||
|
||
local self = { | ||
env = env, | ||
name = name, | ||
version = version, | ||
} | ||
|
||
return setmetatable(self, mt) | ||
end | ||
|
||
function _M:call(name, version, dir) | ||
local v = version or 'builtin' | ||
local loader = self.new(name, v, dir) | ||
|
||
ngx.log(ngx.DEBUG, 'loading policy: ', name, ' version: ', v) | ||
|
||
-- passing the "exclusive" flag for the require so it does not fallback to native require | ||
-- it should load only policies and not other code and fail if there is no such policy | ||
return loader('policy', true) | ||
end | ||
|
||
return setmetatable(_M, { __call = _M.call }) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
return { | ||
'1.0 dependency' | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
local Policy = require('apicast.policy').new('Test', '1.0.0-0') | ||
|
||
Policy.dependency = require('dependency') | ||
|
||
return Policy |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
return { | ||
'2.0 dependency' | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
local Policy = require('apicast.policy').new('Test', '1.0.0-0') | ||
|
||
Policy.dependency = require('dependency') | ||
|
||
return Policy |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This implies that we will not be offering several versions of the same policy in a given version of Apicast. Not saying I don't agree with that, just mentioning it so we're aware of this important decision.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, no two versions of builtin policies. For now. This is quite easy change to make if we want to.
IMO it would be quite difficult to maintain > 1 versions of builtin policies, so I think it is not worth the effort. Internals of this method could be changed if we change that in the future.