diff --git a/docs/changelog.rst b/docs/changelog.rst index b511d9f02..b0bf738a9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -321,6 +321,8 @@ Detailed list of changes - edit-in-kitty: harden the code used to parse messages from clients +- Linux: :option:`kitty --single-instance`: Only accept commands from processes running as the same user + - Clipboard protocol: Report an ``EFBIG`` error to programs that try to write more data to the clipboard than allowed by :opt:`clipboard_max_size`, instead of silently truncating their data. Also fix :opt:`clipboard_max_size` being diff --git a/kitty/boss.py b/kitty/boss.py index 03a1cdcac..7228de459 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -457,7 +457,6 @@ class Boss: DumpCommands(args) if args.dump_commands or args.dump_bytes else None, talk_fd, listen_fd, - self.listening_on.startswith('unix:'), ) self.args: CLIOptions = args self.mouse_handler: Callable[[WindowSystemMouseEvent], None] | None = None diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index 47e8cddb1..cc2fdc963 100644 --- a/kitty/child-monitor.c +++ b/kitty/child-monitor.c @@ -55,6 +55,9 @@ typedef struct { pthread_t io_thread, talk_thread; int talk_fd, listen_fd; + // Peer credentials can only be checked for UNIX sockets, so remember + // which of the listening sockets are UNIX sockets. + bool verify_talk_peer_uid, verify_listen_peer_uid; int benchmark_wakeup_fd; Message *messages; size_t messages_capacity, messages_count; @@ -151,8 +154,6 @@ mask_kitty_signals_process_wide(PyObject *self UNUSED, PyObject *a UNUSED) { Py_RETURN_NONE; } -static int verify_peer_uid = false; - static PyObject * new_childmonitor_object(PyTypeObject *type, PyObject *args, PyObject UNUSED *kwds) { ChildMonitor *self; @@ -164,7 +165,7 @@ new_childmonitor_object(PyTypeObject *type, PyObject *args, PyObject UNUSED *kwd PyErr_SetString(PyExc_RuntimeError, "Can have only a single ChildMonitor instance"); return NULL; } - if (!PyArg_ParseTuple(args, "OO|iip", &death_notify, &dump_callback, &talk_fd, &listen_fd, &verify_peer_uid)) return NULL; + if (!PyArg_ParseTuple(args, "OO|ii", &death_notify, &dump_callback, &talk_fd, &listen_fd)) return NULL; if ((ret = pthread_mutex_init(&children_lock, NULL)) != 0) { PyErr_Format(PyExc_RuntimeError, "Failed to create children_lock mutex: %s", strerror(ret)); return NULL; @@ -177,6 +178,12 @@ new_childmonitor_object(PyTypeObject *type, PyObject *args, PyObject UNUSED *kwd if (!init_loop_data(&self->io_loop_data, KITTY_HANDLED_SIGNALS)) return PyErr_SetFromErrno(PyExc_OSError); self->talk_fd = talk_fd; self->listen_fd = listen_fd; + // Only processes running as the same user as us are allowed to talk to us + // over UNIX sockets. In particular, the single instance socket is an + // abstract UNIX socket on Linux, which any process on the system can + // connect to. + self->verify_talk_peer_uid = talk_fd > -1 && peer_credentials_are_available(talk_fd); + self->verify_listen_peer_uid = listen_fd > -1 && peer_credentials_are_available(listen_fd); self->benchmark_wakeup_fd = -1; if (self == NULL) return PyErr_NoMemory(); self->death_notify = death_notify; @@ -1976,22 +1983,7 @@ add_peer(int peer, bool is_remote_control_peer) { } static bool -getpeerid(int fd, uid_t *euid, gid_t *egid) { -#ifdef __linux__ - struct ucred cr; - socklen_t sz = sizeof(cr); - if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cr, &sz) != 0) return false; - *euid = cr.uid; - *egid = cr.gid; -#else - if (getpeereid(fd, euid, egid) != 0) return false; -#endif - return true; -} - - -static bool -accept_peer(int listen_fd, bool shutting_down, bool is_remote_control_peer) { +accept_peer(int listen_fd, bool shutting_down, bool is_remote_control_peer, bool verify_peer_uid) { int peer = accept(listen_fd, NULL, NULL); if (UNLIKELY(peer == -1)) { if (errno == EINTR) return true; @@ -2001,7 +1993,7 @@ accept_peer(int listen_fd, bool shutting_down, bool is_remote_control_peer) { if (verify_peer_uid) { uid_t peer_uid; gid_t peer_gid; - if (!getpeerid(peer, &peer_uid, &peer_gid)) { + if (!get_peer_credentials(peer, &peer_uid, &peer_gid)) { log_error("Denying access to peer because failed to get uid and gid for peer: %d with error: %s", peer, strerror(errno)); shutdown(peer, SHUT_RDWR); safe_close(peer, __FILE__, __LINE__); @@ -2254,7 +2246,9 @@ talk_loop(void *data) { if (ret > 0) { for (size_t i = 0; i < num_listen_fds - 1; i++) { if (fds[i].revents & POLLIN) { - if (!accept_peer(fds[i].fd, self->shutting_down, fds[i].fd == self->listen_fd)) goto end; + const bool is_remote_control_peer = fds[i].fd == self->listen_fd; + const bool verify_peer_uid = is_remote_control_peer ? self->verify_listen_peer_uid : self->verify_talk_peer_uid; + if (!accept_peer(fds[i].fd, self->shutting_down, is_remote_control_peer, verify_peer_uid)) goto end; } } if (fds[num_listen_fds - 1].revents & POLLIN) { diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 7788531c6..6c505eb57 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1278,7 +1278,6 @@ class ChildMonitor: dump_callback: Optional[Callable[[int, str, Any], None]], talk_fd: int = -1, listen_fd: int = -1, - verify_peer_uid: bool = False, ): pass diff --git a/kitty/launcher/single-instance.c b/kitty/launcher/single-instance.c index 36a06902d..be071b269 100644 --- a/kitty/launcher/single-instance.c +++ b/kitty/launcher/single-instance.c @@ -243,19 +243,45 @@ bind_unix_socket(int s, const char *basename, struct sockaddr_un *addr, cleanup_ if (safe_lockf(fd, F_TLOCK, 0) != 0) { int saved_errno = errno; safe_close(fd, __FILE__, __LINE__); + cleanup->close_fd2 = false; errno = saved_errno; if (errno == EAGAIN || errno == EACCES) errno = EADDRINUSE; // client return false; } // First unlink the socket file and then try to bind it. if (unlink(addr->sun_path) != 0 && errno != ENOENT) return false; - if (safe_bind(s, (struct sockaddr *)addr, sizeof(*addr)) > -1) { + // Create the socket file accessible only to us, as a defense in depth + // measure, the peer uid is checked at both ends of the connection as well. + mode_t old_umask = umask(S_IRWXG | S_IRWXO); + bool bound = safe_bind(s, (struct sockaddr *)addr, sizeof(*addr)) > -1; + int saved_errno = errno; + umask(old_umask); + errno = saved_errno; + if (bound) { snprintf(cleanup->path1, sizeof(cleanup->path1), "%s", addr->sun_path); return true; } return false; } +// Verify that the process at the other end of the specified connected socket +// is running as the same user as us. Needed because on Linux abstract UNIX +// sockets are accessible to every process on the system. +static bool +peer_is_same_user(int fd) { + uid_t peer_uid; + gid_t peer_gid; + if (!get_peer_credentials(fd, &peer_uid, &peer_gid)) { + fprintf(stderr, "Failed to get the credentials of the process at the other end of the socket with error: %s\n", strerror(errno)); + return false; + } + if (peer_uid != geteuid()) { + fprintf(stderr, "Refusing to talk to a process running as the user with uid: %d rather than our uid: %d\n", (int)peer_uid, (int)geteuid()); + return false; + } + return true; +} + static int create_unix_socket(void) { int s = socket(AF_UNIX, SOCK_STREAM, 0); @@ -352,6 +378,9 @@ talk_to_instance(int s, struct sockaddr_un *server_addr, int argc, char *argv[], if (!server_addr->sun_path[0]) addr_len += 1 + strlen(server_addr->sun_path + 1); else addr_len = sizeof(*server_addr); if (safe_connect(s, (struct sockaddr *)server_addr, addr_len) != 0) { fail_on_errno("Failed to connect to single instance socket"); } + // Check the identity of the listening process before sending it our + // environment, cwd and session data. + if (!peer_is_same_user(s)) do_exit(1); size_t pos = 0; while (pos < output.used) { errno = 0; @@ -365,17 +394,31 @@ talk_to_instance(int s, struct sockaddr_un *server_addr, int argc, char *argv[], if (pos < output.used) fail_on_errno("Failed to write message to single instance socket"); shutdown(s, SHUT_RDWR); safe_close(s, __FILE__, __LINE__); + cleanup_entries.si.close_fd1 = false; if (notify_socket > -1) { - int fd = safe_accept(notify_socket, NULL, NULL); - if (fd < 0) fail_on_errno("Failed to accept connection on notify socket"); - char rbuf; while (true) { - ssize_t n = recv(notify_socket, &rbuf, 1, 0); - if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue; + int fd = safe_accept(notify_socket, NULL, NULL); + if (fd < 0) fail_on_errno("Failed to accept connection on notify socket"); + // Ignore connections from processes not running as us, they cannot + // tell us anything about the death of our OS Window. + if (!peer_is_same_user(fd)) { + shutdown(fd, SHUT_RDWR); + safe_close(fd, __FILE__, __LINE__); + continue; + } + char rbuf; + while (true) { + ssize_t n = recv(fd, &rbuf, 1, 0); + if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue; + break; + } + shutdown(fd, SHUT_RDWR); + safe_close(fd, __FILE__, __LINE__); break; } shutdown(notify_socket, SHUT_RDWR); safe_close(notify_socket, __FILE__, __LINE__); + cleanup_entries.notify.close_fd1 = false; } } diff --git a/kitty/safe-wrappers.h b/kitty/safe-wrappers.h index e2a5b26f2..89f12fcd0 100644 --- a/kitty/safe-wrappers.h +++ b/kitty/safe-wrappers.h @@ -136,3 +136,34 @@ safe_dup2(int a, int b) { while ((ret = dup2(a, b)) < 0 && errno == EINTR); return ret; } + +// Get the credentials of the process at the other end of the specified +// connected UNIX socket. Note that these are the credentials the peer had when +// it called connect()/bind() and the uid/gid are translated into the user +// namespace of the calling process, so they can be safely compared with +// geteuid()/getegid(). +static inline bool +get_peer_credentials(int fd, uid_t *euid, gid_t *egid) { +#ifdef __linux__ + struct ucred cr; + socklen_t sz = sizeof(cr); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cr, &sz) != 0) return false; + *euid = cr.uid; + *egid = cr.gid; +#else + if (getpeereid(fd, euid, egid) != 0) return false; +#endif + return true; +} + +// Can the credentials of processes connecting to the specified listening +// socket be obtained? Only possible for AF_UNIX sockets. Fails closed, i.e. if +// the socket family cannot be determined we claim credentials are available, +// which means peers will be denied since getting their credentials will fail. +static inline bool +peer_credentials_are_available(int fd) { + struct sockaddr_storage addr = {0}; + socklen_t sz = sizeof(addr); + if (getsockname(fd, (struct sockaddr *)&addr, &sz) != 0) return true; + return addr.ss_family == AF_UNIX; +} diff --git a/kitty_tests/single_instance.py b/kitty_tests/single_instance.py new file mode 100644 index 000000000..a66873780 --- /dev/null +++ b/kitty_tests/single_instance.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# License: GPL v3 Copyright: 2026, Kovid Goyal + +import json +import os +import socket +import subprocess +import sys +from contextlib import closing + +from kitty.constants import kitty_exe + +from .base import BaseTest + + +class TestSingleInstance(BaseTest): + def test_single_instance_client(self): + # Test that a client can talk to a single instance socket belonging to + # the same user. The abstract socket namespace used below exists only + # on Linux. + if not sys.platform.startswith('linux'): + self.skipTest('Abstract UNIX sockets are only available on Linux') + group = f'test-{os.getpid()}' + with closing(socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)) as server: + server.bind('\0' + f'kitty-ipc-{os.geteuid()}-{group}') + server.listen() + env = os.environ.copy() + env['KITTY_SINGLE_INSTANCE_TEST'] = 'xyz' + p = subprocess.Popen( + [kitty_exe(), '--single-instance', f'--instance-group={group}', '--title', 'si-test-title'], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + try: + with closing(server.accept()[0]) as peer: + data = b'' + while True: + d = peer.recv(8192) + if not d: + break + data += d + output = p.communicate(timeout=30)[0].decode() + finally: + if p.poll() is None: + p.kill() + p.wait() + self.assertEqual(p.returncode, 0, f'kitty --single-instance failed with output: {output}') + msg = json.loads(data.decode('utf-8')) + self.assertEqual(msg['cmd'], 'new_instance') + self.assertIn('si-test-title', msg['args']) + self.assertEqual(msg['cwd'], os.getcwd()) + self.assertEqual(msg['environ'].get('KITTY_SINGLE_INSTANCE_TEST'), 'xyz')