git: 87b18b611ec9 - stable/15 - nuageinit: fix command injection and related issues

From: Baptiste Daroussin <bapt_at_FreeBSD.org>
Date: Wed, 13 May 2026 09:10:26 UTC
The branch stable/15 has been updated by bapt:

URL: https://cgit.FreeBSD.org/src/commit/?id=87b18b611ec9a70347fdd239345fa23977bcb2d0

commit 87b18b611ec9a70347fdd239345fa23977bcb2d0
Author:     Baptiste Daroussin <bapt@FreeBSD.org>
AuthorDate: 2026-05-07 18:22:14 +0000
Commit:     Baptiste Daroussin <bapt@FreeBSD.org>
CommitDate: 2026-05-13 09:10:20 +0000

    nuageinit: fix command injection and related issues
    
    - Add shell_escape() helper to safely escape shell arguments
    - Apply shell_escape to all user-controlled values in shell commands:
      adduser (usershow, useradd, lock, primary_group, groups)
      addgroup (groupshow, groupadd, members)
      exec_change_password (usermod)
      settimezone (tzsetup root and timezone)
      install_package (pkg package names)
    - Escape double quotes in hostname when writing rc.conf.d/hostname
    - Add missing 'local' declaration for resolvconf_command in nameservers()
    - Escape interface name in resolvconf -a command
    - Change open_resolvconf_conf() from 'w' to 'a' mode to prevent
      data loss when nameservers() is called multiple times
    - Clean up stale resolvconf.conf at the start of each boot
      (skip on postnet to preserve config written by first call)
    
    MFC After: 1 day
    
    (cherry picked from commit 8b70a203be10411c560ed303ab25713d70b316e9)
---
 libexec/nuageinit/nuage.lua          | 43 +++++++++++++++++++++++-------------
 libexec/nuageinit/nuageinit          | 17 ++++++++++++--
 libexec/nuageinit/tests/nuageinit.sh |  6 ++---
 3 files changed, 46 insertions(+), 20 deletions(-)

diff --git a/libexec/nuageinit/nuage.lua b/libexec/nuageinit/nuage.lua
index 2d962b540b23..f3c23a7c3eb8 100644
--- a/libexec/nuageinit/nuage.lua
+++ b/libexec/nuageinit/nuage.lua
@@ -52,6 +52,10 @@ local function decode_base64(input)
 	return table.concat(result)
 end
 
+local function shell_escape(s)
+	return "'" .. string.gsub(s, "'", "'\\''") .. "'"
+end
+
 local function warnmsg(str, prepend)
 	if not str then
 		return
@@ -121,7 +125,7 @@ local function sethostname(hostname)
 		warnmsg("Impossible to open " .. hostnamepath .. ":" .. err)
 		return
 	end
-	f:write('hostname="' .. hostname .. '"\n')
+	f:write('hostname="' .. hostname:gsub('"', '\\"') .. '"\n')
 	f:close()
 end
 
@@ -199,7 +203,7 @@ local function adduser(pwd)
 	if root then
 		cmd = cmd .. "-R " .. root .. " "
 	end
-	local f = io.popen(cmd .. " usershow " .. pwd.name .. " -7 2> /dev/null")
+	local f = io.popen(cmd .. " usershow " .. shell_escape(pwd.name) .. " -7 2> /dev/null")
 	local pwdstr = f:read("*a")
 	f:close()
 	if pwdstr:len() ~= 0 then
@@ -220,13 +224,17 @@ local function adduser(pwd)
 		-- a warning but creates the user anyway.
 		list = purge_group(list)
 		if #list > 0 then
-			extraargs = " -G " .. table.concat(list, ",")
+			local escaped_list = {}
+			for _, g in ipairs(list) do
+				table.insert(escaped_list, shell_escape(g))
+			end
+			extraargs = " -G " .. table.concat(escaped_list, ",")
 		end
 	end
 	-- pw will automatically create a group named after the username
 	-- do not add a -g option in this case
 	if pwd.primary_group and pwd.primary_group ~= pwd.name then
-		extraargs = extraargs .. " -g " .. pwd.primary_group
+		extraargs = extraargs .. " -g " .. shell_escape(pwd.primary_group)
 	end
 	if not pwd.no_create_home then
 		extraargs = extraargs .. " -m "
@@ -248,9 +256,9 @@ local function adduser(pwd)
 	if root then
 		cmd = cmd .. "-R " .. root .. " "
 	end
-	cmd = cmd .. "useradd -n " .. pwd.name .. " -M 0755 -w none "
-	cmd = cmd .. extraargs .. " -c '" .. pwd.gecos
-	cmd = cmd .. "' -d '" .. pwd.homedir .. "' -s " .. pwd.shell .. postcmd
+	cmd = cmd .. "useradd -n " .. shell_escape(pwd.name) .. " -M 0755 -w none "
+	cmd = cmd .. extraargs .. " -c " .. shell_escape(pwd.gecos)
+	cmd = cmd .. " -d " .. shell_escape(pwd.homedir) .. " -s " .. shell_escape(pwd.shell) .. postcmd
 
 	f = io.popen(cmd, "w")
 	if input then
@@ -267,7 +275,7 @@ local function adduser(pwd)
 		if root then
 			cmd = cmd .. "-R " .. root .. " "
 		end
-		cmd = cmd .. "lock " .. pwd.name
+		cmd = cmd .. "lock " .. shell_escape(pwd.name)
 		os.execute(cmd)
 	end
 	return pwd.homedir
@@ -283,7 +291,7 @@ local function addgroup(grp)
 	if root then
 		cmd = cmd .. "-R " .. root .. " "
 	end
-	local f = io.popen(cmd .. " groupshow " .. grp.name .. " 2> /dev/null")
+	local f = io.popen(cmd .. " groupshow " .. shell_escape(grp.name) .. " 2> /dev/null")
 	local grpstr = f:read("*a")
 	f:close()
 	if grpstr:len() ~= 0 then
@@ -292,13 +300,17 @@ local function addgroup(grp)
 	local extraargs = ""
 	if grp.members then
 		local list = splitlist(grp.members)
-		extraargs = " -M " .. table.concat(list, ",")
+		local escaped_list = {}
+		for _, m in ipairs(list) do
+			table.insert(escaped_list, shell_escape(m))
+		end
+		extraargs = " -M " .. table.concat(escaped_list, ",")
 	end
 	cmd = "pw "
 	if root then
 		cmd = cmd .. "-R " .. root .. " "
 	end
-	cmd = cmd .. "groupadd -n " .. grp.name .. extraargs
+	cmd = cmd .. "groupadd -n " .. shell_escape(grp.name) .. extraargs
 	local r = os.execute(cmd)
 	if not r then
 		warnmsg("fail to add group " .. grp.name)
@@ -484,7 +496,7 @@ local function exec_change_password(user, password, type, expire)
 			postcmd = " -w random"
 		end
 	end
-	cmd = cmd .. "usermod " .. user .. postcmd
+	cmd = cmd .. "usermod " .. shell_escape(user) .. postcmd
 	if expire then
 		cmd = cmd .. " -p 1"
 	else
@@ -577,7 +589,7 @@ local function settimezone(timezone)
 		root = "/"
 	end
 
-	local f, _, rc = os.execute("tzsetup -s -C " .. root .. " " .. timezone)
+	local f, _, rc = os.execute("tzsetup -s -C " .. shell_escape(root) .. " " .. shell_escape(timezone))
 
 	if not f then
 		warnmsg("Impossible to configure time zone ( rc = " .. rc .. " )")
@@ -600,8 +612,8 @@ local function install_package(package)
 	if package == nil then
 		return true
 	end
-	local install_cmd = "pkg install -y " .. package
-	local test_cmd = "pkg info -q " .. package
+	local install_cmd = "pkg install -y " .. shell_escape(package)
+	local test_cmd = "pkg info -q " .. shell_escape(package)
 	if os.getenv("NUAGE_RUN_TESTS") then
 		print(install_cmd)
 		print(test_cmd)
@@ -683,6 +695,7 @@ local function addfile(file, defer)
 end
 
 local n = {
+	shell_escape = shell_escape,
 	warn = warnmsg,
 	err = errmsg,
 	chmod = chmod,
diff --git a/libexec/nuageinit/nuageinit b/libexec/nuageinit/nuageinit
index a1ebd3f52b25..fc8d9582b9c6 100755
--- a/libexec/nuageinit/nuageinit
+++ b/libexec/nuageinit/nuageinit
@@ -67,7 +67,14 @@ local function open_resolv_conf()
 end
 
 local function open_resolvconf_conf()
-	return openat("/etc", "resolvconf.conf")
+	local path_dir = root .. "/etc"
+	local path_name = path_dir .. "/resolvconf.conf"
+	nuage.mkdir_p(path_dir)
+	local f, err = io.open(path_name, "a")
+	if not f then
+		nuage.err("unable to open " .. path_name .. ": " .. err)
+	end
+	return f, path_name
 end
 
 local function get_ifaces_by_mac()
@@ -271,8 +278,9 @@ local function nameservers(interface, obj)
 	end
 
 	-- Only call resolvconf with interface if interface is provided
+	local resolvconf_command
 	if interface then
-		resolvconf_command = "resolvconf -a " .. interface .. " < " .. resolv_conf
+		resolvconf_command = "resolvconf -a " .. nuage.shell_escape(interface) .. " < " .. resolv_conf
 	else
 		resolvconf_command = "resolvconf -u"
 	end
@@ -738,6 +746,11 @@ local function load_userdata()
 	return line, obj
 end
 
+-- Clean up stale resolvconf.conf from previous boot
+if citype ~= "postnet" then
+	os.remove(root .. "/etc/resolvconf.conf")
+end
+
 if citype == "config-2" then
 	-- network
 	config2_network(ni_path)
diff --git a/libexec/nuageinit/tests/nuageinit.sh b/libexec/nuageinit/tests/nuageinit.sh
index 1fd68d5a178e..89207fdf0aca 100644
--- a/libexec/nuageinit/tests/nuageinit.sh
+++ b/libexec/nuageinit/tests/nuageinit.sh
@@ -801,7 +801,7 @@ packages:
   - yeah/plop
 EOF
 	chmod 755 "${PWD}"/media/nuageinit/user_data
-	atf_check -s exit:0 -o inline:"pkg install -y yeah/plop\npkg info -q yeah/plop\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
+	atf_check -s exit:0 -o inline:"pkg install -y 'yeah/plop'\npkg info -q 'yeah/plop'\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
 
 	cat > media/nuageinit/user_data << 'EOF'
 #cloud-config
@@ -809,7 +809,7 @@ packages:
   - curl
 EOF
 	chmod 755 "${PWD}"/media/nuageinit/user_data
-	atf_check -o inline:"pkg install -y curl\npkg info -q curl\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
+	atf_check -o inline:"pkg install -y 'curl'\npkg info -q 'curl'\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
 
 	cat > media/nuageinit/user_data << 'EOF'
 #cloud-config
@@ -818,7 +818,7 @@ packages:
   - meh: bla
 EOF
 	chmod 755 "${PWD}"/media/nuageinit/user_data
-	atf_check -o inline:"pkg install -y curl\npkg info -q curl\n" -e inline:"nuageinit: Invalid type: table for packages entry number 2\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
+	atf_check -o inline:"pkg install -y 'curl'\npkg info -q 'curl'\n" -e inline:"nuageinit: Invalid type: table for packages entry number 2\n" /usr/libexec/nuageinit "${PWD}"/media/nuageinit postnet
 }
 
 config2_userdata_update_packages_body()