Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/docs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4461,6 +4461,7 @@ local doc = {
},
notes = {
'This is not a libuv function and is not supported on Windows.',
'When dropping privileges from root, calling `setuid()` alone is not sufficient — supplementary group IDs are not affected by `setuid()` or `setgid()` and must be dropped separately. Failure to do so is a security vulnerability (CERT POS36-C). `uv.setuid()` rejects root-to-non-root transitions until both primary group privileges and supplementary groups have already been dropped. The correct order is: `uv.setgroups({})` (or `uv.initgroups(user, gid)`), then `uv.setgid(gid)`, then `uv.setuid(uid)` (must be last, as it is irreversible).',
},
},
{
Expand All @@ -4469,6 +4470,36 @@ local doc = {
params = {
{ name = 'id', type = 'integer' },
},
notes = {
'This is not a libuv function and is not supported on Windows.',
'When dropping privileges, supplementary groups must be dropped before calling `setgid()` and `setuid()`. See the security warning in `uv.setuid()` for the correct privilege-dropping sequence.',
},
},
{
name = 'getgroups',
desc = 'Returns a table containing the list of supplementary group IDs for the process.',
returns = 'table',
notes = {
'This is not a libuv function and is not supported on Windows.',
},
},
{
name = 'setgroups',
desc = 'Sets the supplementary group IDs for the process. Pass an empty table `{}` to drop all supplementary groups. Requires appropriate privileges (typically root).',
params = {
{ name = 'groups', type = 'table' },
},
notes = {
'This is not a libuv function and is not supported on Windows.',
},
},
{
name = 'initgroups',
desc = 'Initializes the supplementary group access list. Sets the supplementary group IDs based on the group database (e.g., `/etc/group`) for the given `user`, plus the specified base `group` ID. Requires appropriate privileges (typically root).',
params = {
{ name = 'user', type = 'string' },
{ name = 'group', type = 'integer' },
},
notes = {
'This is not a libuv function and is not supported on Windows.',
},
Expand Down
338 changes: 222 additions & 116 deletions docs/docs.md

Large diffs are not rendered by default.

176 changes: 138 additions & 38 deletions docs/meta.lua

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/luv.c
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@ static const luaL_Reg luv_functions[] = {
{"setuid", luv_setuid},
{"getgid", luv_getgid},
{"setgid", luv_setgid},
{"getgroups", luv_getgroups},
{"setgroups", luv_setgroups},
{"initgroups", luv_initgroups},
#endif
{"getrusage", luv_getrusage},
#if LUV_UV_VERSION_GEQ(1, 50, 0)
Expand Down
110 changes: 109 additions & 1 deletion src/misc.c
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
#include "luv.h"
#ifdef _WIN32
#include <process.h>
#else
#include <grp.h>
#endif

static int luv_guess_handle(lua_State* L) {
Expand Down Expand Up @@ -475,7 +477,33 @@ static int luv_getgid(lua_State* L){

static int luv_setuid(lua_State* L){
int uid = luaL_checkinteger(L, 1);
int r = setuid(uid);
int r;

/* POS36-C safety check: when dropping privileges from root to an
unprivileged user, supplementary groups must be dropped first.
See: https://wiki.sei.cmu.edu/confluence/display/c/POS36-C */
if (geteuid() == 0 && uid != 0) {
if (getgid() == 0 || getegid() == 0) {
return luaL_error(L,
"Cannot setuid before dropping group privileges "
"(POS36-C security check). Call uv.setgid() before "
"dropping privileges with uv.setuid().");
}

int ngroups = getgroups(0, NULL);
if (ngroups == -1) {
return luaL_error(L, "Error getting number of groups");
}

if (ngroups > 0) {
return luaL_error(L,
"Cannot setuid while supplementary groups are still set "
"(POS36-C security check). Call uv.setgroups({}) or "
"uv.initgroups() before dropping privileges with uv.setuid().");
}
}

r = setuid(uid);
if (-1 == r) {
luaL_error(L, "Error setting UID");
}
Expand All @@ -491,6 +519,86 @@ static int luv_setgid(lua_State* L){
return 0;
}

static int luv_getgroups(lua_State* L) {
int ngroups, i;
gid_t* groups;

/* First call to get the number of groups */
ngroups = getgroups(0, NULL);
if (ngroups == -1) {
return luaL_error(L, "Error getting number of groups");
}

groups = NULL;
if (ngroups > 0) {
groups = (gid_t*)malloc(ngroups * sizeof(gid_t));
}
if (!groups) {
return luaL_error(L, "Error allocating memory for groups");
}

ngroups = getgroups(ngroups, groups);
if (ngroups == -1) {
free(groups);
return luaL_error(L, "Error getting groups");
}

lua_createtable(L, ngroups, 0);
for (i = 0; i < ngroups; i++) {
lua_pushinteger(L, groups[i]);
lua_rawseti(L, -2, i + 1);
}

free(groups);
return 1;
}

static int luv_setgroups(lua_State* L) {
int ngroups, i;
gid_t* groups;

luaL_checktype(L, 1, LUA_TTABLE);
ngroups = lua_rawlen(L, 1);

if (ngroups < 1) {
return 0;
}

groups = (gid_t*)malloc(ngroups * sizeof(gid_t));
if (!groups && ngroups > 0) {
return luaL_error(L, "Error allocating memory for groups");
}

for (i = 0; i < ngroups; i++) {
lua_rawgeti(L, 1, i + 1);
if (!lua_isnumber(L, -1)) {
free(groups);
return luaL_argerror(L, 1, "groups table entries must be integers");
}
groups[i] = (gid_t)lua_tointeger(L, -1);
lua_pop(L, 1);
}

if (setgroups(ngroups, groups) == -1) {
free(groups);
return luaL_error(L, "Error setting groups");
}

free(groups);
return 0;
}

static int luv_initgroups(lua_State* L) {
const char* user = luaL_checkstring(L, 1);
gid_t group = (gid_t)luaL_checkinteger(L, 2);

if (initgroups(user, group) == -1) {
return luaL_error(L, "Error initializing groups for user '%s'", user);
}

return 0;
}

#if LUV_UV_VERSION_GEQ(1, 8, 0)
static int luv_print_all_handles(lua_State* L){
luv_ctx_t* ctx = luv_context(L);
Expand Down
66 changes: 66 additions & 0 deletions tests/test-misc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,70 @@ return require('lib/tap')(function (test)
assert(uv.utf16_length_as_wtf8("") == 0)
end, "1.49.0")

test("uv.getgroups", function(print, p, expect, uv)
if not uv.getgroups then
print("skipping: uv.getgroups not available (Windows?)")
return
end
local groups = uv.getgroups()
p("supplementary groups", groups)
assert(type(groups) == "table")
for i, gid in ipairs(groups) do
assert(type(gid) == "number", "group ID must be a number")
end
end)

test("uv.setgroups", function(print, p, expect, uv)
if not uv.setgroups then
print("skipping: uv.setgroups not available (Windows?)")
return
end
-- setgroups requires root, so we just verify the function exists
-- and accepts a table argument
assert(type(uv.setgroups) == "function")
-- Only test actual setgroups if running as root
if uv.getuid and uv.getuid() == 0 then
local groups = uv.getgroups()
-- Round-trip: set the current groups back
uv.setgroups(groups)
local groups2 = uv.getgroups()
assert(#groups == #groups2, "group count mismatch after round-trip")
else
print("skipping setgroups round-trip: not running as root")
end
end)

test("uv.initgroups", function(print, p, expect, uv)
if not uv.initgroups then
print("skipping: uv.initgroups not available (Windows?)")
return
end
-- initgroups requires root, so we just verify the function exists
assert(type(uv.initgroups) == "function")
end)

test("uv.setuid POS36-C safety check", function(print, p, expect, uv)
if not uv.setuid or not uv.getuid then
print("skipping: uv.setuid or uv.getuid not available (Windows?)")
return
end
-- When not running as root, setuid to our own uid should succeed
-- (the POS36-C check only blocks root->non-root transitions)
local uid = uv.getuid()
if uid ~= 0 then
-- Non-root: setuid to own uid should work without issues
uv.setuid(uid)
print("non-root setuid to own uid succeeded (no POS36-C block expected)")
else
-- Root: setuid to non-root before dropping gid/groups should fail
local ok, err = pcall(uv.setuid, 65534)
if not ok then
assert(string.find(err, "POS36-C"), "expected POS36-C error, got: " .. tostring(err))
print("POS36-C safety check correctly blocked unsafe setuid ordering")
else
error("expected POS36-C safety check to block root->non-root setuid")
end
end
end)

end)
55 changes: 55 additions & 0 deletions tests/test-process.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
return require('lib/tap')(function (test)

local isWindows = require('lib/utils').isWindows
local privilege_drop_child_code = string.dump(function ()
local uv = require('luv')
local target_id = 1
local groups

assert(uv.getuid() == 0, "child must start as root")

uv.setgroups({})
uv.setgid(target_id)
uv.setuid(target_id)

groups = uv.getgroups()
assert(uv.getuid() == target_id)
assert(uv.getgid() == target_id)
assert(#groups == 0)

io.write(string.format("dropped:%d:%d:%d\n", uv.getuid(), uv.getgid(), #groups))
end)

test("test disable_stdio_inheritance", function (print, p, expect, uv)
uv.disable_stdio_inheritance()
Expand Down Expand Up @@ -146,4 +164,41 @@ return require('lib/tap')(function (test)
handle:kill("sigterm")
end, "1.19.0")

test("spawned child can drop privileges in POS36-C order", function (print, p, expect, uv)
local child, pid
local input, output

if isWindows or not uv.getuid or uv.getuid() ~= 0 then
print("skipping: requires Unix root privileges")
return
end

input = uv.new_pipe(false)
output = uv.new_pipe(false)

child, pid = assert(uv.spawn(uv.exepath(), {
args = {"-"},
stdio = {input, output, 2},
}, expect(function (code, signal)
p("exit", {pid = pid, code = code, signal = signal})
assert(code == 0)
assert(signal == 0)
uv.close(input)
uv.close(output)
uv.close(child)
end)))

uv.read_start(output, expect(function (err, chunk)
assert(not err, err)
if chunk then
p(chunk)
assert(chunk == "dropped:1:1:0\n")
uv.read_stop(output)
end
end, 1))

uv.write(input, privilege_drop_child_code)
uv.shutdown(input)
end)

end)
Loading