Harden greeter against threading issues, path traversal, and edge cases
Security: - Fix path traversal in _save/_load_last_session by rejecting usernames starting with dot (blocks '..' and hidden file creation) - Add avatar file size limit (10 MB) to prevent DoS via large ~/.face - Add session_name length validation on write (symmetric with read) - Add payload size check to send_message (symmetric with recv_message) - Set log level to INFO in production (was DEBUG) Quality: - Eliminate main-thread blocking on user switch: _cancel_pending_session now sets a cancellation event and closes the socket instead of doing blocking IPC. The login worker checks the event after each step. - Move power actions (reboot/shutdown) to background threads - Catch TimeoutExpired in addition to CalledProcessError for power actions - Consolidate socket cleanup in _login_worker via finally block, remove redundant _close_greetd_sock calls from error callbacks - Fix _select_initial_user to return False for GLib.idle_add deregistration - Fix context manager leak in resolve_wallpaper_path on exception - Pass Config object to GreeterWindow instead of loading it twice
This commit is contained in:
@@ -245,6 +245,101 @@ class TestLoginFlow:
|
||||
assert mock.received[1] == {"type": "cancel_session"}
|
||||
|
||||
|
||||
class TestSessionCancellation:
|
||||
"""Tests for cancelling an in-progress greetd session during user switch."""
|
||||
|
||||
def test_cancel_closes_socket_and_sets_event(self, tmp_path: Path) -> None:
|
||||
"""_cancel_pending_session should close the socket and set the cancelled event."""
|
||||
from moongreet.greeter import GreeterWindow
|
||||
|
||||
win = GreeterWindow.__new__(GreeterWindow)
|
||||
win._greetd_sock_lock = threading.Lock()
|
||||
win._login_cancelled = threading.Event()
|
||||
|
||||
# Create a real socket pair to verify close
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock_path = tmp_path / "test.sock"
|
||||
server.bind(str(sock_path))
|
||||
server.listen(1)
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(sock_path))
|
||||
server.close()
|
||||
|
||||
win._greetd_sock = client
|
||||
win._cancel_pending_session()
|
||||
|
||||
assert win._login_cancelled.is_set()
|
||||
assert win._greetd_sock is None
|
||||
|
||||
def test_cancel_is_noop_without_socket(self) -> None:
|
||||
"""_cancel_pending_session should be safe to call when no socket exists."""
|
||||
from moongreet.greeter import GreeterWindow
|
||||
|
||||
win = GreeterWindow.__new__(GreeterWindow)
|
||||
win._greetd_sock_lock = threading.Lock()
|
||||
win._login_cancelled = threading.Event()
|
||||
win._greetd_sock = None
|
||||
|
||||
win._cancel_pending_session()
|
||||
|
||||
assert win._login_cancelled.is_set()
|
||||
assert win._greetd_sock is None
|
||||
|
||||
def test_cancel_does_not_block_main_thread(self, tmp_path: Path) -> None:
|
||||
"""_cancel_pending_session must not do blocking I/O — only close the socket."""
|
||||
from moongreet.greeter import GreeterWindow
|
||||
|
||||
win = GreeterWindow.__new__(GreeterWindow)
|
||||
win._greetd_sock_lock = threading.Lock()
|
||||
win._login_cancelled = threading.Event()
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
win._greetd_sock = sock
|
||||
|
||||
# Should complete nearly instantly (no IPC calls)
|
||||
import time
|
||||
start = time.monotonic()
|
||||
win._cancel_pending_session()
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert elapsed < 0.1 # No blocking I/O
|
||||
|
||||
def test_worker_exits_silently_when_cancelled(self, tmp_path: Path) -> None:
|
||||
"""_login_worker should exit without showing an error when cancelled mid-flight."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from moongreet.greeter import GreeterWindow
|
||||
from moongreet.users import User
|
||||
|
||||
win = GreeterWindow.__new__(GreeterWindow)
|
||||
win._greetd_sock_lock = threading.Lock()
|
||||
win._login_cancelled = threading.Event()
|
||||
win._greetd_sock = None
|
||||
win._failed_attempts = {}
|
||||
win._strings = MagicMock()
|
||||
|
||||
# Set cancelled before the worker runs
|
||||
win._login_cancelled.set()
|
||||
|
||||
# Create a socket that will fail (simulating closed socket)
|
||||
sock_path = tmp_path / "greetd.sock"
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(sock_path))
|
||||
server.listen(1)
|
||||
|
||||
user = User(username="dom", uid=1000, gecos="Dominik", home=Path("/home/dom"), shell="/bin/zsh")
|
||||
|
||||
with patch("moongreet.greeter.GLib.idle_add") as mock_idle:
|
||||
win._login_worker(user, "pw", MagicMock(exec_cmd="niri-session"), str(sock_path))
|
||||
|
||||
# Should NOT have scheduled any error callback
|
||||
for call in mock_idle.call_args_list:
|
||||
func = call[0][0]
|
||||
assert func != win._on_login_error, "Worker should not show error when cancelled"
|
||||
assert func != win._on_login_auth_error, "Worker should not show auth error when cancelled"
|
||||
|
||||
server.close()
|
||||
|
||||
|
||||
class TestFaillockWarning:
|
||||
"""Tests for the faillock warning message logic."""
|
||||
|
||||
@@ -361,3 +456,23 @@ class TestLastSession:
|
||||
|
||||
# Should not have created any file
|
||||
assert not (tmp_path / "../../etc/evil").exists()
|
||||
|
||||
def test_regex_rejects_dot_dot_username(self) -> None:
|
||||
"""Username '..' must not pass VALID_USERNAME validation."""
|
||||
from moongreet.greeter import VALID_USERNAME
|
||||
assert VALID_USERNAME.match("..") is None
|
||||
|
||||
def test_regex_rejects_dot_username(self) -> None:
|
||||
"""Username '.' must not pass VALID_USERNAME validation."""
|
||||
from moongreet.greeter import VALID_USERNAME
|
||||
assert VALID_USERNAME.match(".") is None
|
||||
|
||||
def test_regex_allows_dot_in_middle(self) -> None:
|
||||
"""Usernames like 'first.last' must still be valid."""
|
||||
from moongreet.greeter import VALID_USERNAME
|
||||
assert VALID_USERNAME.match("first.last") is not None
|
||||
|
||||
def test_regex_rejects_leading_dot(self) -> None:
|
||||
"""Usernames starting with '.' are rejected (hidden files)."""
|
||||
from moongreet.greeter import VALID_USERNAME
|
||||
assert VALID_USERNAME.match(".hidden") is None
|
||||
|
||||
@@ -99,6 +99,14 @@ class TestSendMessage:
|
||||
assert decoded == msg
|
||||
|
||||
|
||||
def test_rejects_oversized_payload(self) -> None:
|
||||
sock = FakeSocket()
|
||||
msg = {"type": "huge", "data": "x" * 100000}
|
||||
|
||||
with pytest.raises(ValueError, match="Payload too large"):
|
||||
send_message(sock, msg)
|
||||
|
||||
|
||||
class TestRecvMessage:
|
||||
"""Tests for receiving and decoding length-prefixed JSON messages."""
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ class TestReboot:
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
reboot()
|
||||
|
||||
@patch("moongreet.power.subprocess.run")
|
||||
def test_raises_on_timeout(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
reboot()
|
||||
|
||||
|
||||
class TestShutdown:
|
||||
"""Tests for the shutdown power action."""
|
||||
@@ -45,3 +52,10 @@ class TestShutdown:
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
shutdown()
|
||||
|
||||
@patch("moongreet.power.subprocess.run")
|
||||
def test_raises_on_timeout(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
shutdown()
|
||||
|
||||
Reference in New Issue
Block a user