feat(secure): add :trust command and vim.secure.trust() (#21107)

Introduce vim.secure.trust() to programmatically manage the trust
database. Use this function in a new :trust ex command which can
be used as a simple frontend.

Resolves: https://github.com/neovim/neovim/issues/21092
Co-authored-by: Gregory Anders <greg@gpanders.com>
Co-authored-by: ii14 <ii14@users.noreply.github.com>
This commit is contained in:
Jlll1 2022-11-28 20:23:04 +01:00 committed by GitHub
parent 77a0f4a542
commit f004812b33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 541 additions and 29 deletions

View File

@ -1650,4 +1650,32 @@ There are three different types of searching:
currently work with 'path' items that contain a URL or use the double star
with depth limiter (/usr/**2) or upward search (;) notations.
==============================================================================
11. Trusted Files *trust*
Nvim has the ability to execute arbitrary code through the 'exrc' option. In
order to prevent executing code from untrusted sources, Nvim has the concept of
"trusted files". An untrusted file will not be executed without the user's
consent, and a user can permanently mark a file as trusted or untrusted using
the |:trust| command or the |vim.secure.read()| function.
*:trust* *E5570*
:trust [++deny] [++remove] [{file}]
Manage files in the trust database. Without any options
or arguments, :trust adds the file associated with the
current buffer to the trust database, along with the
SHA256 hash of its contents.
[++deny] marks the file associated with the current
buffer (or {file}, if given) as denied; no prompts will
be displayed to the user and the file will never be
executed.
[++remove] removes the file associated with the current
buffer (or {file}, if given) from the trust database.
Future attempts to read the file in a secure setting
(i.e. with 'exrc' or |vim.secure.read()|) will prompt
the user if the file is trusted.
vim:tw=78:ts=8:noet:ft=help:norl:

View File

@ -1633,6 +1633,7 @@ tag command action ~
|:topleft| :to[pleft] make split window appear at top or far left
|:tprevious| :tp[revious] jump to previous matching tag
|:trewind| :tr[ewind] jump to first matching tag
|:trust| :trust add or remove file from trust database
|:try| :try execute commands, abort on error or exception
|:tselect| :ts[elect] list matching tags and select one
|:tunmap| :tunma[p] like ":unmap" but for |Terminal-mode|

View File

@ -2371,4 +2371,28 @@ read({path}) *vim.secure.read()*
(string|nil) The contents of the given file if it exists and is
trusted, or nil otherwise.
See also: ~
|:trust|
trust({opts}) *vim.secure.trust()*
Manage the trust database.
The trust database is located at |$XDG_STATE_HOME|/nvim/trust.
Parameters: ~
• {opts} (table)
• action (string): "allow" to add a file to the trust database
and trust it, "deny" to add a file to the trust database and
deny it, "remove" to remove file from the trust database
• path (string|nil): Path to a file to update. Mutually
exclusive with {bufnr}. Cannot be used when {action} is
"allow".
• bufnr (number|nil): Buffer number to update. Mutually
exclusive with {path}.
Return: ~
(boolean, string) success, msg:
• true and full path of target file if operation was successful
• false and error message on failure
vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl:

View File

@ -39,6 +39,9 @@ NEW FEATURES *news-features*
The following new APIs or features were added.
• |vim.secure.trust()|, |:trust| allows the user to manage files in trust
database.
• |vim.diagnostic.open_float()| (and therefore |vim.diagnostic.config()|) now
accepts a `suffix` option which, by default, renders LSP error codes.
Similarly, the `virtual_text` configuration in |vim.diagnostic.config()| now

View File

@ -2275,6 +2275,8 @@ A jump table for the options with a short description can be found at |Q_op|.
file are persisted to a trust database. The user is only prompted
again if the file contents change. See |vim.secure.read()|.
Use |:trust| to manage the trusted file database.
This option cannot be set from a |modeline| or in the |sandbox|, for
security reasons.

View File

@ -1,9 +1,50 @@
local M = {}
---@private
--- Reads trust database from $XDG_STATE_HOME/nvim/trust.
---
---@return (table) Contents of trust database, if it exists. Empty table otherwise.
local function read_trust()
local trust = {}
local f = io.open(vim.fn.stdpath('state') .. '/trust', 'r')
if f then
local contents = f:read('*a')
if contents then
for line in vim.gsplit(contents, '\n') do
local hash, file = string.match(line, '^(%S+) (.+)$')
if hash and file then
trust[file] = hash
end
end
end
f:close()
end
return trust
end
---@private
--- Writes provided {trust} table to trust database at
--- $XDG_STATE_HOME/nvim/trust.
---
---@param trust (table) Trust table to write
local function write_trust(trust)
vim.validate({ trust = { trust, 't' } })
local f = assert(io.open(vim.fn.stdpath('state') .. '/trust', 'w'))
local t = {}
for p, h in pairs(trust) do
t[#t + 1] = string.format('%s %s\n', h, p)
end
f:write(table.concat(t))
f:close()
end
--- Attempt to read the file at {path} prompting the user if the file should be
--- trusted. The user's choice is persisted in a trust database at
--- $XDG_STATE_HOME/nvim/trust.
---
---@see |:trust|
---
---@param path (string) Path to a file to read.
---
---@return (string|nil) The contents of the given file if it exists and is
@ -15,22 +56,7 @@ function M.read(path)
return nil
end
local trust = {}
do
local f = io.open(vim.fn.stdpath('state') .. '/trust', 'r')
if f then
local contents = f:read('*a')
if contents then
for line in vim.gsplit(contents, '\n') do
local hash, file = string.match(line, '^(%S+) (.+)$')
if hash and file then
trust[file] = hash
end
end
end
f:close()
end
end
local trust = read_trust()
if trust[fullpath] == '!' then
-- File is denied
@ -86,21 +112,82 @@ function M.read(path)
trust[fullpath] = hash
end
do
local f, err = io.open(vim.fn.stdpath('state') .. '/trust', 'w')
if not f then
error(err)
end
local t = {}
for p, h in pairs(trust) do
t[#t + 1] = string.format('%s %s\n', h, p)
end
f:write(table.concat(t))
f:close()
end
write_trust(trust)
return contents
end
--- Manage the trust database.
---
--- The trust database is located at |$XDG_STATE_HOME|/nvim/trust.
---
---@param opts (table):
--- - action (string): "allow" to add a file to the trust database and trust it,
--- "deny" to add a file to the trust database and deny it,
--- "remove" to remove file from the trust database
--- - path (string|nil): Path to a file to update. Mutually exclusive with {bufnr}.
--- Cannot be used when {action} is "allow".
--- - bufnr (number|nil): Buffer number to update. Mutually exclusive with {path}.
---@return (boolean, string) success, msg:
--- - true and full path of target file if operation was successful
--- - false and error message on failure
function M.trust(opts)
vim.validate({
path = { opts.path, 's', true },
bufnr = { opts.bufnr, 'n', true },
action = {
opts.action,
function(m)
return m == 'allow' or m == 'deny' or m == 'remove'
end,
[["allow" or "deny" or "remove"]],
},
})
local path = opts.path
local bufnr = opts.bufnr
local action = opts.action
if path and bufnr then
error('path and bufnr are mutually exclusive', 2)
end
local fullpath
if path then
fullpath = vim.loop.fs_realpath(vim.fs.normalize(path))
else
local bufname = vim.api.nvim_buf_get_name(bufnr)
if bufname == '' then
return false, 'buffer is not associated with a file'
end
fullpath = vim.loop.fs_realpath(vim.fs.normalize(bufname))
end
if not fullpath then
return false, string.format('invalid path: %s', path)
end
local trust = read_trust()
if action == 'allow' then
assert(bufnr, 'bufnr is required when action is "allow"')
local newline = vim.bo[bufnr].fileformat == 'unix' and '\n' or '\r\n'
local contents = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), newline)
if vim.bo[bufnr].endofline then
contents = contents .. newline
end
local hash = vim.fn.sha256(contents)
trust[fullpath] = hash
elseif action == 'deny' then
trust[fullpath] = '!'
elseif action == 'remove' then
trust[fullpath] = nil
end
write_trust(trust)
return true, fullpath
end
return M

View File

@ -53,6 +53,7 @@
#include "nvim/highlight_group.h"
#include "nvim/indent.h"
#include "nvim/input.h"
#include "nvim/lua/executor.h"
#include "nvim/macros.h"
#include "nvim/main.h"
#include "nvim/mark.h"
@ -4960,3 +4961,29 @@ void ex_oldfiles(exarg_T *eap)
}
}
}
void ex_trust(exarg_T *eap)
{
const char *const p = skiptowhite(eap->arg);
char *arg1 = xmemdupz(eap->arg, (size_t)(p - eap->arg));
const char *action = "allow";
const char *path = skipwhite(p);
if (strcmp(arg1, "++deny") == 0) {
action = "deny";
} else if (strcmp(arg1, "++remove") == 0) {
action = "remove";
} else if (*arg1 != '\0') {
semsg(e_invarg2, arg1);
goto theend;
}
if (path[0] == '\0') {
path = NULL;
}
nlua_trust(action, path);
theend:
xfree(arg1);
}

View File

@ -2933,6 +2933,12 @@ module.cmds = {
addr_type='ADDR_OTHER',
func='ex_tag',
},
{
command='trust',
flags=bit.bor(EXTRA, FILE1, TRLBAR, LOCK_OK),
addr_type='ADDR_NONE',
func='ex_trust',
},
{
command='try',
flags=bit.bor(TRLBAR, SBOXOK, CMDWIN, LOCK_OK),

View File

@ -1827,6 +1827,7 @@ static bool skip_cmd(const exarg_T *eap)
case CMD_throw:
case CMD_tilde:
case CMD_topleft:
case CMD_trust:
case CMD_unlet:
case CMD_unlockvar:
case CMD_verbose:

View File

@ -1016,6 +1016,8 @@ EXTERN char e_highlight_group_name_too_long[] INIT(= N_("E1249: Highlight group
EXTERN char e_undobang_cannot_redo_or_move_branch[]
INIT(= N_("E5767: Cannot use :undo! to redo or move to a different undo branch"));
EXTERN char e_trustfile[] INIT(= N_("E5570: Cannot update trust file: %s"));
EXTERN char top_bot_msg[] INIT(= N_("search hit TOP, continuing at BOTTOM"));
EXTERN char bot_top_msg[] INIT(= N_("search hit BOTTOM, continuing at TOP"));

View File

@ -2217,3 +2217,51 @@ char *nlua_read_secure(const char *path)
return buf;
}
bool nlua_trust(const char *action, const char *path)
{
lua_State *const lstate = global_lstate;
lua_getglobal(lstate, "vim");
lua_getfield(lstate, -1, "secure");
lua_getfield(lstate, -1, "trust");
lua_newtable(lstate);
lua_pushstring(lstate, "action");
lua_pushstring(lstate, action);
lua_settable(lstate, -3);
if (path == NULL) {
lua_pushstring(lstate, "bufnr");
lua_pushnumber(lstate, 0);
lua_settable(lstate, -3);
} else {
lua_pushstring(lstate, "path");
lua_pushstring(lstate, path);
lua_settable(lstate, -3);
}
if (nlua_pcall(lstate, 1, 2)) {
nlua_error(lstate, _("Error executing vim.secure.trust: %.*s"));
return false;
}
bool success = lua_toboolean(lstate, -2);
const char *msg = lua_tostring(lstate, -1);
if (msg != NULL) {
if (success) {
if (strcmp(action, "allow") == 0) {
smsg("Allowed \"%s\" in trust database.", msg);
} else if (strcmp(action, "deny") == 0) {
smsg("Denied \"%s\" in trust database.", msg);
} else if (strcmp(action, "remove") == 0) {
smsg("Removed \"%s\" from trust database.", msg);
}
} else {
semsg(e_trustfile, msg);
}
}
// Pop return values, "vim" and "secure"
lua_pop(lstate, 4);
return success;
}

View File

@ -0,0 +1,176 @@
local helpers = require('test.functional.helpers')(after_each)
local Screen = require('test.functional.ui.screen')
local eq = helpers.eq
local clear = helpers.clear
local command = helpers.command
local pathsep = helpers.get_pathsep()
local is_os = helpers.is_os
local funcs = helpers.funcs
describe(':trust', function()
local xstate = 'Xstate'
setup(function()
helpers.mkdir_p(xstate .. pathsep .. (is_os('win') and 'nvim-data' or 'nvim'))
end)
teardown(function()
helpers.rmdir(xstate)
end)
before_each(function()
helpers.write_file('test_file', 'test')
clear{env={XDG_STATE_HOME=xstate}}
end)
after_each(function()
os.remove('test_file')
end)
it('trust then deny then remove a file using current buffer', function()
local screen = Screen.new(80, 8)
screen:attach()
screen:set_default_attr_ids({
[1] = {bold = true, foreground = Screen.colors.Blue1},
})
local cwd = funcs.getcwd()
local hash = funcs.sha256(helpers.read_file('test_file'))
command('edit test_file')
command('trust')
screen:expect([[
^test |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
"]] .. cwd .. pathsep .. [[test_file" trusted.{MATCH:%s+}|
]])
local trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('%s %s', hash, cwd .. pathsep .. 'test_file'), vim.trim(trust))
command('trust ++deny')
screen:expect([[
^test |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
"]] .. cwd .. pathsep .. [[test_file" denied.{MATCH:%s+}|
]])
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('! %s', cwd .. pathsep .. 'test_file'), vim.trim(trust))
command('trust ++remove')
screen:expect([[
^test |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
"]] .. cwd .. pathsep .. [[test_file" removed.{MATCH:%s+}|
]])
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format(''), vim.trim(trust))
end)
it('deny then trust then remove a file using current buffer', function()
local screen = Screen.new(80, 8)
screen:attach()
screen:set_default_attr_ids({
[1] = {bold = true, foreground = Screen.colors.Blue1},
})
local cwd = funcs.getcwd()
local hash = funcs.sha256(helpers.read_file('test_file'))
command('edit test_file')
command('trust ++deny')
screen:expect([[
^test |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
"]] .. cwd .. pathsep .. [[test_file" denied.{MATCH:%s+}|
]])
local trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('! %s', cwd .. pathsep .. 'test_file'), vim.trim(trust))
command('trust')
screen:expect([[
^test |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
"]] .. cwd .. pathsep .. [[test_file" trusted.{MATCH:%s+}|
]])
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('%s %s', hash, cwd .. pathsep .. 'test_file'), vim.trim(trust))
command('trust ++remove')
screen:expect([[
^test |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
"]] .. cwd .. pathsep .. [[test_file" removed.{MATCH:%s+}|
]])
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format(''), vim.trim(trust))
end)
it('deny then remove a file using file path', function()
local screen = Screen.new(80, 8)
screen:attach()
screen:set_default_attr_ids({
[1] = {bold = true, foreground = Screen.colors.Blue1},
})
local cwd = funcs.getcwd()
command('trust ++deny test_file')
screen:expect([[
^ |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
"]] .. cwd .. pathsep .. [[test_file" denied.{MATCH:%s+}|
]])
local trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('! %s', cwd .. pathsep .. 'test_file'), vim.trim(trust))
command('trust ++remove test_file')
screen:expect([[
^ |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
"]] .. cwd .. pathsep .. [[test_file" removed.{MATCH:%s+}|
]])
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format(''), vim.trim(trust))
end)
end)

View File

@ -168,4 +168,111 @@ describe('vim.secure', function()
eq(false, curbufmeths.get_option('modifiable'))
end)
end)
describe('trust()', function()
local xstate = 'Xstate'
setup(function()
helpers.mkdir_p(xstate .. pathsep .. (is_os('win') and 'nvim-data' or 'nvim'))
end)
teardown(function()
helpers.rmdir(xstate)
end)
before_each(function()
helpers.write_file('test_file', 'test')
end)
after_each(function()
os.remove('test_file')
end)
it('returns error when passing both path and bufnr', function()
eq('path and bufnr are mutually exclusive',
pcall_err(exec_lua, [[vim.secure.trust({action='deny', bufnr=0, path='test_file'})]]))
end)
it('trust then deny then remove a file using bufnr', function()
local cwd = funcs.getcwd()
local hash = funcs.sha256(helpers.read_file('test_file'))
local full_path = cwd .. pathsep .. 'test_file'
command('edit test_file')
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
local trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('%s %s', hash, full_path), vim.trim(trust))
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='deny', bufnr=0})}]]))
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('! %s', full_path), vim.trim(trust))
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='remove', bufnr=0})}]]))
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq('', vim.trim(trust))
end)
it('deny then trust then remove a file using bufnr', function()
local cwd = funcs.getcwd()
local hash = funcs.sha256(helpers.read_file('test_file'))
local full_path = cwd .. pathsep .. 'test_file'
command('edit test_file')
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='deny', bufnr=0})}]]))
local trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('! %s', full_path), vim.trim(trust))
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('%s %s', hash, full_path), vim.trim(trust))
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='remove', bufnr=0})}]]))
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq('', vim.trim(trust))
end)
it('trust using bufnr then deny then remove a file using path', function()
local cwd = funcs.getcwd()
local hash = funcs.sha256(helpers.read_file('test_file'))
local full_path = cwd .. pathsep .. 'test_file'
command('edit test_file')
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
local trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('%s %s', hash, full_path), vim.trim(trust))
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='deny', path='test_file'})}]]))
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('! %s', full_path), vim.trim(trust))
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='remove', path='test_file'})}]]))
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq('', vim.trim(trust))
end)
it('deny then trust then remove a file using bufnr', function()
local cwd = funcs.getcwd()
local hash = funcs.sha256(helpers.read_file('test_file'))
local full_path = cwd .. pathsep .. 'test_file'
command('edit test_file')
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='deny', path='test_file'})}]]))
local trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('! %s', full_path), vim.trim(trust))
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('%s %s', hash, full_path), vim.trim(trust))
eq({true, full_path}, exec_lua([[return {vim.secure.trust({action='remove', path='test_file'})}]]))
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq('', vim.trim(trust))
end)
it('trust returns error when buffer not associated to file', function()
command('new')
eq({false, 'buffer is not associated with a file'},
exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
end)
end)
end)