Validate zip symlinks and backslash paths before extraction
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

- treat `\` as a separator in safe_relative so `\abs` and `..\..\x` are refused
- read zip symlink targets with `unzip -p` off the `unzip -Z -l` listing and reject escapes before unpacking
This commit is contained in:
2026-09-05 16:59:11 +10:00
parent ca55c9dd9f
commit 6e1b018adb
4 changed files with 227 additions and 44 deletions
+85
View File
@@ -142,6 +142,14 @@ describe('safe_relative', function()
assert_nil(fsmod.safe_relative('a/../../evil'))
assert_nil(fsmod.safe_relative(''))
end)
it('treats a backslash as a separator', function()
assert_nil(fsmod.safe_relative('\\absolute_backslash.txt'))
assert_nil(fsmod.safe_relative('..\\..\\..\\deep_escape.txt'))
assert_nil(fsmod.safe_relative('a\\..\\..\\evil'))
assert_nil(fsmod.safe_relative('C:\\windows\\evil'))
assert_eq('lua/plug.lua', fsmod.safe_relative('lua\\plug.lua'))
end)
end)
describe('extract', function()
@@ -257,6 +265,83 @@ describe('extract', function()
assert_match('plug/lua/escape', result.err)
end)
local function sh(root, script)
vim.fn.system({ 'sh', '-c', 'cd ' .. vim.fn.shellescape(root) .. ' && ' .. script })
return vim.v.shell_error == 0
end
-- A symlink out of the destination followed by a member written *through* it.
-- unzip would create the link and only then notice, so the whole archive has
-- to be refused off the listing, before a single byte lands.
it('refuses a zip that writes through an escaping symlink', function()
local root = tmpdir()
local made = sh(
root,
'mkdir -p src && cd src && ln -s ../../../outside link && zip -qy ../evil.zip link'
.. ' && rm link && mkdir link && echo pwned > link/pwned.txt && zip -qgy ../evil.zip link/pwned.txt'
)
if not made then
return
end
local result = wait_extract(root .. '/evil.zip', root .. '/dest')
assert_true(not result.ok)
assert_match('refusing to extract', result.err)
assert_match('is a symlink to', result.err)
assert_match('../../../outside', result.err)
assert_true(not fsmod.exists(root .. '/dest'))
assert_true(not fsmod.exists(root .. '/outside'))
assert_true(not fsmod.exists(vim.fs.dirname(root) .. '/outside'))
end)
it('refuses a zip whose symlink points at an absolute path', function()
local root = tmpdir()
if not sh(root, 'mkdir -p src/plug && ln -s /etc/passwd src/plug/escape && cd src && zip -qry ../abs.zip plug') then
return
end
local result = wait_extract(root .. '/abs.zip', root .. '/dest')
assert_true(not result.ok)
assert_match('refusing to extract', result.err)
assert_match('plug/escape', result.err)
assert_true(not fsmod.exists(root .. '/dest'))
end)
it('allows a zip symlink that stays inside the destination', function()
local root = tmpdir()
if
not sh(
root,
'mkdir -p src/plug/lua && echo "return {}" > src/plug/lua/plug.lua'
.. ' && ln -s lua/plug.lua src/plug/alias && cd src && zip -qry ../ok.zip plug'
)
then
return
end
local result = wait_extract(root .. '/ok.zip', root .. '/dest')
assert_true(result.ok, tostring(result.err))
assert_true(fsmod.exists(root .. '/dest/plug/alias'))
end)
it('reads the symlink target of a member whose name looks like a glob', function()
local root = tmpdir()
if
not sh(
root,
'mkdir -p src/plug && ln -s ../../../outside "src/plug/od[d]*link" && cd src && zip -qry ../glob.zip plug'
)
then
return
end
local result = wait_extract(root .. '/glob.zip', root .. '/dest')
assert_true(not result.ok)
assert_match('refusing to extract', result.err)
assert_match('is a symlink to', result.err)
assert_true(not fsmod.exists(root .. '/dest'))
end)
it('allows a symlink that stays inside the destination', function()
local root = tmpdir()
vim.fn.mkdir(root .. '/src/plug/lua', 'p')