Lua: read-only table

balus

2022-01-29

Preface

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: 感觉可能其实第一种方式会更简洁?

Lua 实现

那么如何让 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 元方法:

由于 proxy table 为空,所以任何对它的改动(新增 OR 修改)都会调用 __newindex 元方法抛出异常;而由于 proxy table 的元表中的 __index 元方法为原始的 table,所以任何对它的访问都会访问到原始的 table。

通过使用 proxy table,我们完美地解决了这个问题,这再次印证了计算机科学领域中一句名言:

All problems in computer science can be solved by another level of indirection.

C/C++ 实现

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)";

  lua_createtable(L, 0 /* narr */, 1 /* nrec */); /* original proxy */
  lua_createtable(L, 0 /* narr */, 3 /* nrec */); /* original proxy mt */

  rc = luaL_loadbuffer(L, buf, sizeof(buf) - 1,
                       "=read-only metatable"); /* original proxy mt closure */
  if (rc != 0) {
    lua_pop(L, 3); /* 3: proxy + mt + error message */
    return;
  }

  lua_setfield(L, -2, "__newindex");       /* original proxy mt */
  lua_rotate(L, -3, 2);                    /* proxy mt original */
  lua_setfield(L, -2, "__index");          /* proxy mt */
  lua_pushliteral(L, "not your business"); /* proxy mt warn */
  lua_setfield(L, -2, "__metatable");      /* proxy mt */
  lua_setmetatable(L, -2);                 /* proxy */
}

以上 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)

  WRAP_READ_ONLY_INTERNAL_LIB("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);
}

rawset 函数

事情还没有完,我们虽然把 API table 隐藏在 proxy table 内,但是对于 proxy table,用户通过 rawset 函数还是可以避开 __newindex 修改它。

可以选择禁用 rawset 函数,但是考虑到我们已经提供了 setmetatablegetmetatable 函数,没理由禁用 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 逐个比对即可。

Reference