Files
LEDMatrix/test/test_wifi_connect_argument_safety.py
T
ChuckandClaude Opus 5 7e580dc005 fix(wifi): make Connect work from the setup AP (#571)
* fix(wifi): make Connect work from the setup AP

Joining a network from LEDMatrix-Setup has to take the AP down first, which
drops the phone that sent the request. The connect endpoint answered only
after the attempt finished, so the browser never got a reply and the WiFi
tab's Connect button appeared to do nothing.

- /wifi/connect answers 202 immediately while the AP is active and connects
  in a background thread; the result (never the password) is reported via
  /wifi/status as last_connect_attempt. A second connect while one is
  pending gets 409.
- connect_to_network holds a /tmp flag for the attempt; the monitor daemon
  skips AP management while it is fresh. Previously the daemon's
  disconnected counter, accumulated over the whole AP session, re-enabled
  the AP on its next tick in the middle of the connect.
- The WiFi tab and captive setup page explain the handoff up front, and on
  reopening show why the last attempt failed. The wrong-password message
  now works: the route sets the error_type the captive page checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(wifi): serialize connect attempts on both paths

Addresses CodeRabbit review on #571:

- Check for a pending attempt before branching on AP state. A background
  attempt takes the AP down long before it finishes, so a second click
  used to bypass the 409 and start a competing synchronous connect.
- Record pending for the synchronous (non-AP) path too, so two requests
  can't overlap and have the first clear the daemon's in-progress flag
  while the second is still connecting.
- Clear the pending state if the background thread fails to start, rather
  than refusing every later request until restart.
- Say the setup network returns "within a few minutes": a stale flag plus
  the daemon's grace period can take longer than one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:40:41 -04:00

156 lines
6.6 KiB
Python

"""The SSID and password reaching nmcli's argv come from an HTTP request body.
POST /api/v3/wifi/connect takes both verbatim and WiFiManager.connect_to_network
hands them to::
subprocess.run(["nmcli", "device", "wifi", "connect", ssid, "password", password])
There is no shell in that, so no metacharacter can start a second command --
CodeQL's py/command-line-injection alert overstates it on that point. What is
real is argument injection: nmcli reads a leading "-" as an option, so an SSID
of "--ask" or "-t" asks nmcli to *run differently* rather than to join a
network. Neither value was checked for shape at all before reaching argv.
These tests pin the validation without touching real networking: the
validators are classmethods, so nothing here constructs a WiFiManager.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.wifi_manager import WiFiManager # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
class TestSsidValidation:
@pytest.mark.parametrize("ssid", [
"HomeNet", "my wifi 5G", "Cafe-Guest", "café", "x" * 32, "-not-leading".lstrip("-"),
])
def test_ordinary_ssids_pass_through(self, ssid):
value, error = WiFiManager._validate_ssid(ssid)
assert error is None
assert value == ssid
def test_surrounding_whitespace_is_trimmed_not_rejected(self):
value, error = WiFiManager._validate_ssid(" HomeNet ")
assert error is None
assert value == "HomeNet"
@pytest.mark.parametrize("ssid", ["--ask", "-t", "-"])
def test_an_ssid_nmcli_would_read_as_an_option_is_refused(self, ssid):
value, error = WiFiManager._validate_ssid(ssid)
assert error is not None
assert value == ""
def test_an_ssid_over_32_octets_is_refused(self):
# 802.11 caps the SSID element at 32 octets, so a longer one could
# never name a real network.
_, error = WiFiManager._validate_ssid("x" * 33)
assert error is not None
# Multi-byte characters count as octets, not characters.
_, error = WiFiManager._validate_ssid("é" * 17)
assert error is not None
@pytest.mark.parametrize("ssid", ["a\nb", "a\rb", "a\x00b", "a\x7fb", "a\tb"])
def test_control_characters_are_refused(self, ssid):
_, error = WiFiManager._validate_ssid(ssid)
assert error is not None
@pytest.mark.parametrize("ssid", ["", " ", None, 42, ["HomeNet"]])
def test_empty_and_non_text_values_are_refused(self, ssid):
_, error = WiFiManager._validate_ssid(ssid)
assert error is not None
class TestPasswordValidation:
def test_an_empty_password_means_an_open_network(self):
value, error = WiFiManager._validate_wifi_password("")
assert error is None
assert value == ""
value, error = WiFiManager._validate_wifi_password(None)
assert error is None
assert value == ""
@pytest.mark.parametrize("password", ["hunter22", "a" * 63, "0" * 64, "AbCdEf0123" * 6 + "abcd"])
def test_valid_psk_lengths_pass_through(self, password):
value, error = WiFiManager._validate_wifi_password(password)
assert error is None, f"{password!r} rejected: {error}"
assert value == password
@pytest.mark.parametrize("password", ["short", "z" * 64, "a" * 200])
def test_lengths_that_could_never_authenticate_are_refused(self, password):
# 8-63 chars for a passphrase, or exactly 64 hex chars for a raw key.
# "z" * 64 is the right length for a key but is not hex, so it is
# neither -- note "a" * 64 *is* valid hex and must stay accepted.
_, error = WiFiManager._validate_wifi_password(password)
assert error is not None
@pytest.mark.parametrize("password", ["-password", "--ask"])
def test_a_password_nmcli_would_read_as_an_option_is_refused(self, password):
_, error = WiFiManager._validate_wifi_password(password)
assert error is not None
def test_control_characters_are_refused(self):
_, error = WiFiManager._validate_wifi_password("pass\nword")
assert error is not None
@pytest.mark.parametrize("password", ["pässword", "你好12345678"])
def test_non_ascii_passphrases_are_refused(self, password):
# NetworkManager accepts only printable ASCII WPA-PSK passphrases (or
# a 64-char hex key); a non-ASCII value would otherwise reach
# _connect_nmcli and be saved/attempted before nmcli itself rejects it.
_, error = WiFiManager._validate_wifi_password(password)
assert error is not None
class TestConnectRefusesBeforeRunningNmcli:
"""connect_to_network must not reach subprocess with a rejected value."""
@pytest.mark.parametrize("ssid,password", [
("--ask", "hunter22"),
("x" * 40, "hunter22"),
("Home\nNet", "hunter22"),
("HomeNet", "-secret1"),
("HomeNet", "short"),
])
def test_no_subprocess_runs_for_a_rejected_request(self, ssid, password):
manager = WiFiManager.__new__(WiFiManager) # no __init__: no real host access
with patch("src.wifi_manager.subprocess.run") as run:
ok, message = manager.connect_to_network(ssid, password)
assert ok is False
assert message
run.assert_not_called()
class TestConnectEndpointSurfacesTheRefusal:
URL = "/api/v3/wifi/connect"
@pytest.fixture
def wifi_manager(self):
with patch("src.wifi_manager.WiFiManager") as cls:
instance = MagicMock()
instance._is_ap_mode_active.return_value = False
cls.return_value = instance
yield instance
def test_a_rejected_ssid_comes_back_as_a_client_error(
self, api_v3_client, wifi_manager
):
# The route delegates the shape check to the manager, so mirror what
# the real one now returns rather than asserting on a mock's default.
wifi_manager.connect_to_network.return_value = (False, "SSID cannot start with '-'")
response = api_v3_client.post(self.URL, json={"ssid": "--ask", "password": "hunter22"})
assert response.status_code == 400
assert "-" in response.get_json()["message"]
def test_an_ordinary_request_is_unaffected(self, api_v3_client, wifi_manager):
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
assert response.status_code == 200
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "hunter22")