neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

commit b40880f88ffb5efb636b001faed6241a1cae91f7
parent e77a69c6e9b3b04cc1c3dfde812dad1a87e193e2
Author: zeertzjq <zeertzjq@outlook.com>
Date:   Thu, 29 Jan 2026 13:02:36 +0800

fix(terminal): heap UAF if buffer deleted during TermRequest (#37612)

Problem:  Heap UAF if a terminal buffer is deleted during TermRequest in
          Normal mode.
Solution: Increment terminal refcount before triggering TermRequest, and
          destroy the terminal if the buffer is closed during that.
Diffstat:
Msrc/nvim/terminal.c | 9+++++++++
Mtest/functional/autocmd/termxx_spec.lua | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 70 insertions(+), 0 deletions(-)

diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c @@ -268,8 +268,10 @@ static void emit_termrequest(void **argv) terminator == VTERM_TERMINATOR_BEL ? STATIC_CSTR_AS_OBJ("\x07") : STATIC_CSTR_AS_OBJ("\x1b\\")); + term->refcount++; apply_autocmds_group(EVENT_TERMREQUEST, NULL, NULL, true, AUGROUP_ALL, buf, NULL, &DICT_OBJ(data)); + term->refcount--; xfree(sequence); StringBuilder *term_pending_send = term->pending.send; @@ -282,6 +284,13 @@ static void emit_termrequest(void **argv) term->pending.send = term_pending_send; } xfree(pending_send); + + // Terminal buffer closed during TermRequest in Normal mode: destroy the terminal. + // In Terminal mode term->refcount should still be non-zero here. + if (term->buf_handle == 0 && !term->refcount) { + term->destroy = true; + term->opts.close_cb(term->opts.data); + } } static void schedule_termrequest(Terminal *term) diff --git a/test/functional/autocmd/termxx_spec.lua b/test/functional/autocmd/termxx_spec.lua @@ -353,3 +353,64 @@ describe('autocmd TextChangedT,WinResized', function() eq({}, api.nvim_get_chan_info(term2)) -- Channel should've been cleaned up. end) end) + +describe('no crash if :bwipe from TermClose is processed by', function() + local oldwin --- @type integer + local chan --- @type integer + + before_each(function() + clear() + command('autocmd! nvim.terminal') + oldwin = api.nvim_get_current_win() + command('new') + local buf = api.nvim_get_current_buf() + chan = api.nvim_open_term(buf, {}) + api.nvim_set_var('chan', chan) + command(('autocmd TermClose <buffer> bwipe! %d'):format(buf)) + command('let g:done = 0') + feed('i') + eq({ mode = 't', blocking = false }, api.nvim_get_mode()) + end) + + --- @param event string Event name. + --- @param trigger_cmd string The Ex command to trigger the event. + local function test_case(event, trigger_cmd) + api.nvim_create_autocmd( + event, + { nested = true, once = true, command = 'sleep 40m | let g:done = 1' } + ) + exec_lua(function() + vim.cmd(trigger_cmd) + vim.defer_fn(function() + vim.fn.chanclose(chan) + end, 25) + end) + retry(nil, 1000, function() + eq(1, api.nvim_get_var('done')) + end) + assert_alive() + eq({ mode = 'n', blocking = false }, api.nvim_get_mode()) + eq({ oldwin }, api.nvim_list_wins()) + feed('<Ignore>') -- Add input to separate two RPC requests. + -- Channel should have been released. + eq({}, api.nvim_get_chan_info(chan)) + end + + it('WinResized autocommand in Terminal mode', function() + test_case('WinResized', 'vsplit') + end) + + it('TextChangedT autocommand in Terminal mode', function() + test_case('TextChangedT', [[call chansend(g:chan, "foo\r\nbar")]]) + end) + + it('TermRequest autocommand in Terminal mode', function() + test_case('TermRequest', [[call chansend(g:chan, "\x1b]11;?\x1b\\")]]) + end) + + it('TermRequest autocommand in Normal mode', function() + feed([[<C-\><C-N>]]) + eq({ mode = 'nt', blocking = false }, api.nvim_get_mode()) + test_case('TermRequest', [[call chansend(g:chan, "\x1b]11;?\x1b\\")]]) + end) +end)