2022-01-29
Lua 中的 API 都是以 Lua table 的方式存储并提供给用户使用,拿
OpenResty 中的 ngx.say
API 为例:其中 ngx
是全局环境(也是一个 Lua table)中一个名为 ngx
的 key 对应的
value(一个 table),而 say
,则是 ngx
这个 table
中一个名为 say
的 key 对应的 value(一个 C function);而 Lua
标准库也是如此。
在 OpenResty 的实现中,每个 Nginx worker 启动时会创建一个 Lua
虚拟机(即 lua_State
),在初始化该 vm 时,会将 OpenResty
提供的 API 注入 Lua 的全局环境,以及替换掉标准库的一些实现(比如
coroutine API),具体逻辑在 ngx_http_lua_new_state()
以及
ngx_http_lua_init_globals()
这两个函数中;逻辑很简单,主要是一些 Lua table
相关的操作,就不贴代码了。而后每次请求到来时,OpenResty 都会在本进程的
Lua vm 之上创建一个 coroutine 用于服务该请求,而该 coroutine
会使用之前创建好了的全局环境(具体代码可见
ngx_http_lua_content_by_lua()
函数);这样用户就可以在脚本中使用 OpenResty 提供的所有 API。
作为一个开源产品,OpenResty 只需要提供业务无关的基础、通用功能,所以这样就足够了;但是我们需要将 Lua 脚本作为一个 CDN 产品推出给外部客户使用,所以需要考虑更多事情,而其中很关键的一部分就是安全,而安全中很重要的一个点就是不能让不同的脚本相互影响,而上面则存在这个隐患。
举一个脚本之间互相影响的例子,比如:
location /foo {
content_by_lua_block {
ngx.say("hello)
ngx.say = nil
}
}
location /bar {
content_by_lua_block {
ngx.say("world")
}
}
如果先请求 /foo
然后再请求
/bar
,那么势必会在请求 /bar
时出错:因为
/foo
和 /bar
中的 Lua 脚本都是共用一个环境,而
/foo
中将全局环境中的 ngx.say
API 置为
nil
了。
除此之外,对全局环境不正当的使用还可能导致其它异常,比如:
location /foo {
content_by_lua_block {
ngx.req.[ngx.time()] = "hello"
}
}
以上这个配置,每次访问该 location 都会在 ngx.req
这个
table 中新增一个 entry,时间一长内存占用就高了,影响的也是所有的 Lua
脚本。
那么如何解决这个问题呢?有两种容易想到的办法:
考虑到代码的简洁,我并没有采用第一种方法:它需要每次初始化 coroutine 时都创建 table 并注册 API,而第二种方法只需要在之前代码的基础上增加一个 read-only 逻辑即可。
balus: 感觉可能其实第一种方式会更简洁?
那么如何让 table 成为只读的呢?很容易想到 Lua
中的元表与元方法,毕竟它就是让我们控制 table 行为的工具;Lua 中提供了
__newindex
元方法用于控制 table
中新增元素的行为,通过它我们可以禁止用户新增数据。但是怎么禁止用户修改已有内容呢?PIL 中提供了一个 Lua 语言的标准实现:
function readOnly (t)
local proxy = {}
local mt = { -- create metatable
__index = t,
__newindex = function (t,k,v)
error("attempt to update a read-only table", 2)
end
}
setmetatable(proxy, mt)
return proxy
end
这里并不是简单地为原始的 table 新增一个 metatable,而是使用了两个
table 并结合 __index
和 __newindex
元方法:
__index
元方法设置为原始的 table,其 __newindex
元方法只是简单地调用 error
函数抛出异常由于 proxy table 为空,所以任何对它的改动(新增 OR 修改)都会调用
__newindex
元方法抛出异常;而由于 proxy table 的元表中的
__index
元方法为原始的
table,所以任何对它的访问都会访问到原始的 table。
通过使用 proxy table,我们完美地解决了这个问题,这再次印证了计算机科学领域中一句名言:
All problems in computer science can be solved by another level of indirection.
static void ngx_http_lua_wrap_read_only_table(lua_State *L) {
/* precondition: original table is on the top of the stack
* postcondition: proxy table, which wraps the original table, is on the top of the stack */
int rc;
const char buf[] = "error('attempt to update a read-only table', 2)";
(L, 0 /* narr */, 1 /* nrec */); /* original proxy */
lua_createtable(L, 0 /* narr */, 3 /* nrec */); /* original proxy mt */
lua_createtable
= luaL_loadbuffer(L, buf, sizeof(buf) - 1,
rc "=read-only metatable"); /* original proxy mt closure */
if (rc != 0) {
(L, 3); /* 3: proxy + mt + error message */
lua_popreturn;
}
(L, -2, "__newindex"); /* original proxy mt */
lua_setfield(L, -3, 2); /* proxy mt original */
lua_rotate(L, -2, "__index"); /* proxy mt */
lua_setfield(L, "not your business"); /* proxy mt warn */
lua_pushliteral(L, -2, "__metatable"); /* proxy mt */
lua_setfield(L, -2); /* proxy */
lua_setmetatable}
以上 C 代码基本上是 Lua 代码的翻译版本:该函数假定栈定元素为需要被设置为 read-only 的 table,当函数结束时,栈顶元素被替换为已经被设置为 read-only 的 table 了
除此之外,该函数还设置了 __metatable
元方法,这样用户就无法获取、修改元表了。
我们已经知道了如何将一个 table 置为
read-only,后面需要做的就是将系统暴露给用户的 table
都修改一遍。开始想的是遍历 _G
中的所有元素,找到类型为
LUA_TTABLE
的,并将其修改为
read-only;但是试了一下发现没法用一种简洁的办法做到,因为在修改完毕之后我们需要将
read-only table 设置会原 table 所属的 table 以替换它;但是这些 table
之间可能存在着依赖关系,比如说所有的顶层 API table
都需要被放入全局环境(_G
或者 _ENV
) 以及
package.loaded
中,而 package
也在
_G
中,遍历时倘若 package
先被 read-only
了,那么后续所有 read-only API table 都不能放进去了。
static void ngx_http_lua_wrap_read_only_lib(lua_State *L) {
#define WRAP_READ_ONLY_INTERNAL_LIB(fk, sk) \
/* fk: first-level key, sk: second-level key */ \
do { \
if (sk) { \
lua_getglobal(L, fk); \
lua_getfield(L, -1, sk); \
ngx_http_lua_wrap_read_only_table(L); \
lua_setfield(L, -2, sk); \
lua_pop(L, 1); \
} else { \
luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE); \
lua_getfield(L, -1, fk); \
ngx_http_lua_wrap_read_only_table(L); \
lua_pushvalue(L, -1); \
lua_setfield(L, -3, fk); \
lua_setglobal(L, fk); \
lua_pop(L, 1); \
} \
} while (0)
("ngx", "req");
WRAP_READ_ONLY_INTERNAL_LIB("ngx", "resp");
WRAP_READ_ONLY_INTERNAL_LIB("ngx", "json");
WRAP_READ_ONLY_INTERNAL_LIB("ngx", "re");
WRAP_READ_ONLY_INTERNAL_LIB("ngx", "location");
WRAP_READ_ONLY_INTERNAL_LIB("ngx", nullptr);
WRAP_READ_ONLY_INTERNAL_LIB
(LUA_TABLIBNAME, nullptr);
WRAP_READ_ONLY_INTERNAL_LIB(LUA_STRLIBNAME, nullptr);
WRAP_READ_ONLY_INTERNAL_LIB(LUA_UTF8LIBNAME, nullptr);
WRAP_READ_ONLY_INTERNAL_LIB(LUA_MATHLIBNAME, nullptr);
WRAP_READ_ONLY_INTERNAL_LIB(LUA_LOADLIBNAME, "loaded");
WRAP_READ_ONLY_INTERNAL_LIB(LUA_LOADLIBNAME, nullptr);
WRAP_READ_ONLY_INTERNAL_LIB}
rawset
函数事情还没有完,我们虽然把 API table 隐藏在 proxy table 内,但是对于
proxy table,用户通过 rawset
函数还是可以避开
__newindex
修改它。
可以选择禁用 rawset
函数,但是考虑到我们已经提供了
setmetatable
和 getmetatable
函数,没理由禁用
rawset
,所以打算提供我们自己的版本:
#define FORBID_UPDATING_INTERNAL_LIB(fk, sk) \
/* fk: first-level key, sk: second-level key */ \
do { \
if (sk) { \
lua_getglobal(L, fk); \
lua_getfield(L, -1, sk); \
} else { \
lua_getglobal(L, fk); \
} \
if (lua_rawequal(L, 1, -1)) { \
return luaL_error(L, "attempt to update a read-only table"); \
} \
lua_pop(L, sk ? 2 : 1); \
} while (0)
按照之前的思路,我们只需要将用户打算 rawset
的 table
和我们提供的所有 proxy table 逐个比对即可。