Merge pull request #20956 from gpanders/lua-readsecure

feat: implement vim.secure.read() and use it for 'exrc'
This commit is contained in:
Gregory Anders
2022-11-17 08:47:41 -07:00
committed by GitHub
19 changed files with 366 additions and 57 deletions

View File

@@ -128,10 +128,6 @@ NORMAL COMMANDS
OPTIONS
- *cpo-<* *:menu-<special>* *:menu-special* *:map-<special>* *:map-special*
`<>` notation is always enabled.
- *'exrc'* *'ex'* Security risk: downloaded files could include
a malicious .nvimrc or .exrc file. See 'secure'.
Recommended alternative: define an autocommand in your
|vimrc| to set options for a matching directory.
- 'gdefault' Enables the |:substitute| flag 'g' by default.
- *'fe'* 'fenc'+'enc' before Vim 6.0; no longer used.
- *'highlight'* *'hl'* Names of builtin |highlight-groups| cannot be changed.

View File

@@ -2354,4 +2354,20 @@ parents({start}) *vim.fs.parents()*
Return: ~
(function) Iterator
==============================================================================
Lua module: secure *lua-secure*
read({path}) *vim.secure.read()*
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.
Parameters: ~
• {path} (string) Path to a file to read.
Return: ~
(string|nil) The contents of the given file if it exists and is
trusted, or nil otherwise.
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.read()| reads a file and prompts the user if it should be
trusted and, if so, returns the file's contents.
• When using Nvim inside tmux 3.2 or later, the default clipboard provider
will now copy to the system clipboard. |provider-clipboard|
@@ -57,6 +60,8 @@ CHANGED FEATURES *news-changes*
The following changes to existing APIs or features add new behavior.
• 'exrc' is no longer marked deprecated.
==============================================================================
REMOVED FEATURES *news-removed*

View File

@@ -2264,6 +2264,20 @@ A jump table for the options with a short description can be found at |Q_op|.
This option is reset when the 'paste' option is set and restored when
the 'paste' option is reset.
*'exrc'* *'ex'* *'noexrc'* *'noex'*
'exrc' 'ex' boolean (default off)
global
Enables the reading of .nvimrc and .exrc files in the current
directory.
The file is only sourced if the user indicates the file is trusted. If
it is, the SHA256 hash of the file contents and the full path of the
file are persisted to a trust database. The user is only prompted
again if the file contents change. See |vim.secure.read()|.
This option cannot be set from a |modeline| or in the |sandbox|, for
security reasons.
*'fileencoding'* *'fenc'* *E213*
'fileencoding' 'fenc' string (default: "")
local to buffer
@@ -5115,19 +5129,6 @@ A jump table for the options with a short description can be found at |Q_op|.
two letters (See |object-motions|). The default makes a section start
at the nroff macros ".SH", ".NH", ".H", ".HU", ".nh" and ".sh".
*'secure'* *'nosecure'* *E523*
'secure' boolean (default off)
global
When on, ":autocmd", shell and write commands are not allowed in
".nvimrc" and ".exrc" in the current directory and map commands are
displayed. Switch it off only if you know that you will not run into
problems, or when the 'exrc' option is off. On Unix this option is
only used if the ".nvimrc" or ".exrc" is not owned by you. This can be
dangerous if the systems allows users to do a "chown". You better set
'secure' at the end of your |init.vim| then.
This option cannot be set from a |modeline| or in the |sandbox|, for
security reasons.
*'selection'* *'sel'*
'selection' 'sel' string (default "inclusive")
global

View File

@@ -417,6 +417,8 @@ Options:
'jumpoptions' "view" tries to restore the |mark-view| when moving through
the |jumplist|, |changelist|, |alternate-file| or using |mark-motions|.
'shortmess' the "F" flag does not affect output from autocommands
'exrc' searches for ".nvimrc" or ".exrc" files. The user is prompted whether
to trust the file.
Shell:
Shell output (|:!|, |:make|, …) is always routed through the UI, so it
@@ -639,6 +641,9 @@ Options:
*'prompt'* *'noprompt'*
*'remap'* *'noremap'*
*'restorescreen'* *'rs'* *'norestorescreen'* *'nors'*
*'secure'*
Everything is allowed in 'exrc' files since they must be explicitly marked
trusted.
*'shelltype'*
*'shortname'* *'sn'* *'noshortname'* *'nosn'*
*'swapsync'* *'sws'*

View File

@@ -36,6 +36,7 @@ for k, v in pairs({
ui = true,
health = true,
fs = true,
secure = true,
}) do
vim._submodules[k] = v
end

106
runtime/lua/vim/secure.lua Normal file
View File

@@ -0,0 +1,106 @@
local M = {}
--- 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.
---
---@param path (string) Path to a file to read.
---
---@return (string|nil) The contents of the given file if it exists and is
--- trusted, or nil otherwise.
function M.read(path)
vim.validate({ path = { path, 's' } })
local fullpath = vim.loop.fs_realpath(vim.fs.normalize(path))
if not fullpath then
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
if trust[fullpath] == '!' then
-- File is denied
return nil
end
local contents
do
local f = io.open(fullpath, 'r')
if not f then
return nil
end
contents = f:read('*a')
f:close()
end
local hash = vim.fn.sha256(contents)
if trust[fullpath] == hash then
-- File already exists in trust database
return contents
end
-- File either does not exist in trust database or the hash does not match
local choice = vim.fn.confirm(
string.format('%s is not trusted.', fullpath),
'&ignore\n&view\n&deny\n&allow',
1
)
if choice == 0 or choice == 1 then
-- Cancelled or ignored
return nil
elseif choice == 2 then
-- View
vim.cmd('new')
local buf = vim.api.nvim_get_current_buf()
local lines = vim.split(string.gsub(contents, '\n$', ''), '\n')
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].bufhidden = 'hide'
vim.bo[buf].buftype = 'nofile'
vim.bo[buf].swapfile = false
vim.bo[buf].modeline = false
vim.bo[buf].buflisted = false
vim.bo[buf].readonly = true
vim.bo[buf].modifiable = false
return nil
elseif choice == 3 then
-- Deny
trust[fullpath] = '!'
contents = nil
elseif choice == 4 then
-- Allow
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
return contents
end
return M

View File

@@ -131,6 +131,7 @@ CONFIG = {
'filetype.lua',
'keymap.lua',
'fs.lua',
'secure.lua',
],
'files': [
'runtime/lua/vim/_editor.lua',
@@ -140,6 +141,7 @@ CONFIG = {
'runtime/lua/vim/filetype.lua',
'runtime/lua/vim/keymap.lua',
'runtime/lua/vim/fs.lua',
'runtime/lua/vim/secure.lua',
],
'file_patterns': '*.lua',
'fn_name_prefix': '',
@@ -166,6 +168,7 @@ CONFIG = {
'filetype': 'vim.filetype',
'keymap': 'vim.keymap',
'fs': 'vim.fs',
'secure': 'vim.secure',
},
'append_only': [
'shared.lua',

View File

@@ -1134,8 +1134,7 @@ void do_bang(int addr_count, exarg_T *eap, bool forceit, bool do_in, bool do_out
int scroll_save = msg_scroll;
//
// Disallow shell commands from .exrc and .vimrc in current directory for
// security reasons.
// Disallow shell commands in secure mode
//
if (check_secure()) {
return;
@@ -1477,8 +1476,7 @@ filterend:
/// @param flags may be SHELL_DOOUT when output is redirected
void do_shell(char *cmd, int flags)
{
// Disallow shell commands from .exrc and .vimrc in current directory for
// security reasons.
// Disallow shell commands in secure mode
if (check_secure()) {
msg_end();
return;
@@ -3215,8 +3213,7 @@ void ex_z(exarg_T *eap)
ex_no_reprint = true;
}
/// @return true if the secure flag is set (.exrc or .vimrc in current directory)
/// and also give an error message.
/// @return true if the secure flag is set and also give an error message.
/// Otherwise, return false.
bool check_secure(void)
{

View File

@@ -4223,8 +4223,7 @@ theend:
static void ex_autocmd(exarg_T *eap)
{
// Disallow autocommands from .exrc and .vimrc in current
// directory for security reasons.
// Disallow autocommands in secure mode.
if (secure) {
secure = 2;
eap->errmsg = _(e_curdir);

View File

@@ -2211,8 +2211,7 @@ int buf_write(buf_T *buf, char *fname, char *sfname, linenr_T start, linenr_T en
return FAIL;
}
// Disallow writing from .exrc and .vimrc in current directory for
// security reasons.
// Disallow writing in secure mode.
if (check_secure()) {
return FAIL;
}

View File

@@ -489,8 +489,7 @@ EXTERN int stdin_fd INIT(= -1);
// true when doing full-screen output, otherwise only writing some messages.
EXTERN int full_screen INIT(= false);
/// Non-zero when only "safe" commands are allowed, e.g. when sourcing .exrc or
/// .vimrc in current directory.
/// Non-zero when only "safe" commands are allowed
EXTERN int secure INIT(= 0);
/// Non-zero when changing text and jumping to another window or editing another buffer is not
@@ -864,7 +863,7 @@ EXTERN char e_api_spawn_failed[] INIT(= N_("E903: Could not spawn API job"));
EXTERN char e_argreq[] INIT(= N_("E471: Argument required"));
EXTERN char e_backslash[] INIT(= N_("E10: \\ should be followed by /, ? or &"));
EXTERN char e_cmdwin[] INIT(= N_("E11: Invalid in command-line window; <CR> executes, CTRL-C quits"));
EXTERN char e_curdir[] INIT(= N_("E12: Command not allowed from exrc/vimrc in current dir or tag search"));
EXTERN char e_curdir[] INIT(= N_("E12: Command not allowed in secure mode in current dir or tag search"));
EXTERN char e_command_too_recursive[] INIT(= N_("E169: Command too recursive"));
EXTERN char e_endif[] INIT(= N_("E171: Missing :endif"));
EXTERN char e_endtry[] INIT(= N_("E600: Missing :endtry"));

View File

@@ -2193,3 +2193,27 @@ plain:
kv_printf(str, "<Lua %d>", ref);
return str.items;
}
char *nlua_read_secure(const char *path)
{
lua_State *const lstate = global_lstate;
lua_getglobal(lstate, "vim");
lua_getfield(lstate, -1, "secure");
lua_getfield(lstate, -1, "read");
lua_pushstring(lstate, path);
lua_call(lstate, 1, 1);
size_t len = 0;
const char *contents = lua_tolstring(lstate, -1, &len);
char *buf = NULL;
if (contents != NULL) {
// Add one to include trailing null byte
buf = xcalloc(len + 1, sizeof(char));
memcpy(buf, contents, len + 1);
}
// Pop return value, "vim", and "secure"
lua_pop(lstate, 3);
return buf;
}

View File

@@ -1989,35 +1989,22 @@ static void source_startup_scripts(const mparm_T *const parmp)
do_system_initialization();
if (do_user_initialization()) {
// Read initialization commands from ".vimrc" or ".exrc" in current
// Read initialization commands from ".nvimrc" or ".exrc" in current
// directory. This is only done if the 'exrc' option is set.
// Because of security reasons we disallow shell and write commands
// now, except for unix if the file is owned by the user or 'secure'
// option has been reset in environment of global "exrc" or "vimrc".
// Only do this if VIMRC_FILE is not the same as vimrc file sourced in
// do_user_initialization.
#if defined(UNIX)
// If vimrc file is not owned by user, set 'secure' mode.
if (!os_file_owned(VIMRC_FILE)) // NOLINT(readability/braces)
#endif
secure = p_secure;
if (do_source(VIMRC_FILE, true, DOSO_VIMRC) == FAIL) {
#if defined(UNIX)
// if ".exrc" is not owned by user set 'secure' mode
if (!os_file_owned(EXRC_FILE)) {
secure = p_secure;
} else {
secure = 0;
char *str = nlua_read_secure(VIMRC_FILE);
if (str != NULL) {
do_source_str(str, VIMRC_FILE);
xfree(str);
} else {
str = nlua_read_secure(EXRC_FILE);
if (str != NULL) {
do_source_str(str, EXRC_FILE);
xfree(str);
}
#endif
(void)do_source(EXRC_FILE, false, DOSO_NONE);
}
}
if (secure == 2) {
need_wait_return = true;
}
secure = 0;
}
TIME_MSG("sourcing vimrc file(s)");
}

View File

@@ -2446,8 +2446,7 @@ void ex_abbreviate(exarg_T *eap)
/// ":map" and friends.
void ex_map(exarg_T *eap)
{
// If we are sourcing .exrc or .vimrc in current directory we
// print the mappings for security reasons.
// If we are in a secure mode we print the mappings for security reasons.
if (secure) {
secure = 2;
msg_outtrans(eap->cmd);

View File

@@ -2007,7 +2007,7 @@ return {
},
{
full_name='secure',
short_desc=N_("mode for reading .vimrc in current dir"),
short_desc=N_("No description"),
type='bool', scope={'global'},
secure=true,
varname='p_secure',

View File

@@ -1930,7 +1930,7 @@ int do_source(char *fname, int check_other, int is_vimrc)
cookie.fp = fopen_noinh_readbin(fname_exp);
if (cookie.fp == NULL && check_other) {
// Try again, replacing file name ".vimrc" by "_vimrc" or vice versa,
// Try again, replacing file name ".nvimrc" by "_nvimrc" or vice versa,
// and ".exrc" by "_exrc" or vice versa.
p = path_tail(fname_exp);
if ((*p == '.' || *p == '_')

View File

@@ -1024,6 +1024,7 @@ endfunc
" Test for using the 'exrc' option
func Test_exrc()
throw 'Skipped: Nvim requires user input for the exrc option'
let after =<< trim [CODE]
call assert_equal(1, &exrc)
call assert_equal(1, &secure)

View File

@@ -0,0 +1,171 @@
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 iswin = helpers.iswin()
local curbufmeths = helpers.curbufmeths
local exec_lua = helpers.exec_lua
local feed_command = helpers.feed_command
local feed = helpers.feed
local funcs = helpers.funcs
local pcall_err = helpers.pcall_err
describe('vim.secure', function()
describe('read()', function()
local xstate = 'Xstate'
setup(function()
helpers.mkdir_p(xstate .. pathsep .. (iswin and 'nvim-data' or 'nvim'))
end)
teardown(function()
helpers.rmdir(xstate)
end)
before_each(function()
helpers.write_file('Xfile', [[
let g:foobar = 42
]])
clear{env={XDG_STATE_HOME=xstate}}
end)
after_each(function()
os.remove('Xfile')
helpers.rmdir(xstate)
end)
it('works', function()
local screen = Screen.new(80, 8)
screen:attach()
screen:set_default_attr_ids({
[1] = {bold = true, foreground = Screen.colors.Blue1},
[2] = {bold = true, reverse = true},
[3] = {bold = true, foreground = Screen.colors.SeaGreen},
[4] = {reverse = true},
})
local cwd = funcs.getcwd()
-- Need to use feed_command instead of exec_lua because of the confirmation prompt
feed_command([[lua vim.secure.read('Xfile')]])
screen:expect{grid=[[
|
{1:~ }|
{1:~ }|
{1:~ }|
{2: }|
:lua vim.secure.read('Xfile') |
{3:]] .. cwd .. pathsep .. [[Xfile is untrusted}{MATCH:%s+}|
{3:[i]gnore, (v)iew, (d)eny, (a)llow: }^ |
]]}
feed('d')
screen:expect{grid=[[
^ |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
|
]]}
local trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('! %s', cwd .. pathsep .. 'Xfile'), vim.trim(trust))
eq(helpers.NIL, exec_lua([[return vim.secure.read('Xfile')]]))
os.remove(funcs.stdpath('state') .. pathsep .. 'trust')
feed_command([[lua vim.secure.read('Xfile')]])
screen:expect{grid=[[
|
{1:~ }|
{1:~ }|
{1:~ }|
{2: }|
:lua vim.secure.read('Xfile') |
{3:]] .. cwd .. pathsep .. [[Xfile is untrusted}{MATCH:%s+}|
{3:[i]gnore, (v)iew, (d)eny, (a)llow: }^ |
]]}
feed('a')
screen:expect{grid=[[
^ |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
|
]]}
local hash = funcs.sha256(helpers.read_file('Xfile'))
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(string.format('%s %s', hash, cwd .. pathsep .. 'Xfile'), vim.trim(trust))
eq(helpers.NIL, exec_lua([[vim.secure.read('Xfile')]]))
os.remove(funcs.stdpath('state') .. pathsep .. 'trust')
feed_command([[lua vim.secure.read('Xfile')]])
screen:expect{grid=[[
|
{1:~ }|
{1:~ }|
{1:~ }|
{2: }|
:lua vim.secure.read('Xfile') |
{3:]] .. cwd .. pathsep .. [[Xfile is untrusted}{MATCH:%s+}|
{3:[i]gnore, (v)iew, (d)eny, (a)llow: }^ |
]]}
feed('i')
screen:expect{grid=[[
^ |
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
{1:~ }|
|
]]}
-- Trust database is not updated
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(nil, trust)
feed_command([[lua vim.secure.read('Xfile')]])
screen:expect{grid=[[
|
{1:~ }|
{1:~ }|
{1:~ }|
{2: }|
:lua vim.secure.read('Xfile') |
{3:]] .. cwd .. pathsep .. [[Xfile is untrusted}{MATCH:%s+}|
{3:[i]gnore, (v)iew, (d)eny, (a)llow: }^ |
]]}
feed('v')
screen:expect{grid=[[
^ let g:foobar = 42 |
{1:~ }|
{1:~ }|
{2:]] .. cwd .. pathsep .. [[Xfile [RO]{MATCH:%s+}|
|
{1:~ }|
{4:[No Name] }|
|
]]}
-- Trust database is not updated
trust = helpers.read_file(funcs.stdpath('state') .. pathsep .. 'trust')
eq(nil, trust)
-- Cannot write file
pcall_err(command, 'write')
eq(false, curbufmeths.get_option('modifiable'))
end)
end)
end)