From a69656940c4fbd935a47390e29bbef2a9a16e8c7 Mon Sep 17 00:00:00 2001 From: omrzk <193513049+omrzk@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:14:19 -0600 Subject: [PATCH] Validate force-feedback upload data before copying it --- src/evdev/input.c | 8 +++++- tests/test_input.py | 62 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/test_input.py diff --git a/src/evdev/input.c b/src/evdev/input.c index 61e4d58..61cfbf2 100644 --- a/src/evdev/input.c +++ b/src/evdev/input.c @@ -482,7 +482,13 @@ upload_effect(PyObject *self, PyObject *args) ret = PyArg_ParseTuple(args, "iO", &fd, &effect_data); if (!ret) return NULL; - void* data = PyBytes_AsString(effect_data); + char* data; + Py_ssize_t data_size; + if (PyBytes_AsStringAndSize(effect_data, &data, &data_size) < 0) return NULL; + if (data_size < (Py_ssize_t)sizeof(struct ff_effect)) { + PyErr_SetString(PyExc_ValueError, "effect data is too short for struct ff_effect"); + return NULL; + } struct ff_effect effect = {}; memmove(&effect, data, sizeof(struct ff_effect)); diff --git a/tests/test_input.py b/tests/test_input.py new file mode 100644 index 0000000..b6d3dc4 --- /dev/null +++ b/tests/test_input.py @@ -0,0 +1,62 @@ +"""Validate the native input API without requiring an input device.""" + +import ctypes +import errno +import subprocess +import sys + +import pytest + +from evdev import _input, device, ff + + +@pytest.mark.parametrize("expression", ["None", "0", "'effect'", "[]", "bytearray(64)", "memoryview(bytes(64))"]) +def test_upload_effect_rejects_non_bytes(expression): + # A conversion failure used to dereference NULL. Keep a broken extension + # from crashing the test runner, and do not leave core dumps behind. + code = """ +import resource +import sys +from evdev import _input +resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) +try: + _input.upload_effect(-1, eval(sys.argv[1])) +except TypeError: + pass +else: + raise AssertionError('expected TypeError') +""" + result = subprocess.run([sys.executable, "-c", code, expression], check=False, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("size", [0, 1, ctypes.sizeof(ff.Effect) - 1]) +def test_upload_effect_rejects_short_bytes(size): + with pytest.raises(ValueError, match="effect data"): + _input.upload_effect(-1, bytes(size)) + + +class EffectBytes(bytes): + pass + + +@pytest.mark.parametrize("data", [bytes(ff.Effect()), bytes(ff.Effect()) + b"extra", EffectBytes(bytes(ff.Effect()))]) +def test_upload_effect_preserves_ioctl_errors(data): + # A complete effect must reach ioctl, including bytes subclasses and the + # previously accepted trailing bytes. Invalid descriptors remain EBADF. + with pytest.raises(OSError) as error: + _input.upload_effect(-1, data) + assert error.value.errno == errno.EBADF + + +def test_device_upload_effect_uses_complete_structure(): + input_device = device.InputDevice.__new__(device.InputDevice) + input_device.fd = -1 + effect = ff.Effect() + effect.id = -1 + with pytest.raises(OSError) as error: + input_device.upload_effect(effect) + assert error.value.errno == errno.EBADF + assert effect.id == -1 + # No descriptor is owned by this stand-in InputDevice. + input_device.fd = None