1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 16:51:52 +00:00

Compare commits

...

6 Commits

Author SHA1 Message Date
J. Nick Koston
4795971f1c Use usefixtures for tests that don't reference mock_auth_settings
Replace unused mock_auth_settings parameter with
@pytest.mark.usefixtures decorator to avoid PLW0613 lint warnings.
2026-02-08 07:25:19 -06:00
J. Nick Koston
82d9616f1b Add explicit binascii.Error catch and bad-padding test
binascii.Error is already a subclass of ValueError, but listing it
explicitly makes the intent clear. Added test for incorrect base64
padding (e.g. "Basic abc").
2026-02-08 07:18:29 -06:00
J. Nick Koston
806a86a6ad Add test coverage for is_authenticated base64 handling
Tests malformed base64, invalid UTF-8, missing colon separator,
valid credentials, wrong credentials, and auth-disabled cases.
2026-02-08 07:06:24 -06:00
J. Nick Koston
2829f7b485 [dashboard] Handle malformed Basic Auth headers gracefully
Wrap base64 decode and split in try/except so malformed
Authorization headers return a clean 401 instead of an
unhandled exception producing a 500 response with stack
trace in logs.

Catches ValueError (covers binascii.Error from b64decode)
and UnicodeDecodeError (from .decode()).
2026-02-08 06:47:49 -06:00
schrob
7b40e8afcb [epaper_spi] Declare leaf classes final (#13776) 2026-02-07 19:21:37 -06:00
J. Nick Koston
a43e3e5948 [dashboard] Close WebSocket after process exit to prevent zombie connections (#13834) 2026-02-07 15:19:20 -06:00
4 changed files with 114 additions and 5 deletions

View File

@@ -4,7 +4,7 @@
namespace esphome::epaper_spi {
class EPaperSpectraE6 : public EPaperBase {
class EPaperSpectraE6 final : public EPaperBase {
public:
EPaperSpectraE6(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)

View File

@@ -6,7 +6,7 @@ namespace esphome::epaper_spi {
/**
* An epaper display that needs LUTs to be sent to it.
*/
class EpaperWaveshare : public EPaperMono {
class EpaperWaveshare final : public EPaperMono {
public:
EpaperWaveshare(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length, const uint8_t *lut, size_t lut_length, const uint8_t *partial_lut,

View File

@@ -120,8 +120,11 @@ def is_authenticated(handler: BaseHandler) -> bool:
if auth_header := handler.request.headers.get("Authorization"):
assert isinstance(auth_header, str)
if auth_header.startswith("Basic "):
auth_decoded = base64.b64decode(auth_header[6:]).decode()
username, password = auth_decoded.split(":", 1)
try:
auth_decoded = base64.b64decode(auth_header[6:]).decode()
username, password = auth_decoded.split(":", 1)
except (binascii.Error, ValueError, UnicodeDecodeError):
return False
return settings.check_password(username, password)
return handler.get_secure_cookie(AUTH_COOKIE_NAME) == COOKIE_AUTHENTICATED_YES
@@ -317,6 +320,7 @@ class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandl
# Check if the proc was not forcibly closed
_LOGGER.info("Process exited with return code %s", returncode)
self.write_message({"event": "exit", "code": returncode})
self.close()
def on_close(self) -> None:
# Check if proc exists (if 'start' has been run)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from argparse import Namespace
import asyncio
import base64
from collections.abc import Generator
from contextlib import asynccontextmanager
import gzip
@@ -29,7 +30,7 @@ from esphome.dashboard.entries import (
bool_to_entry_state,
)
from esphome.dashboard.models import build_importable_device_dict
from esphome.dashboard.web_server import DashboardSubscriber
from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket
from esphome.zeroconf import DiscoveredImport
from .common import get_fixture_path
@@ -1654,3 +1655,107 @@ async def test_websocket_check_origin_multiple_trusted_domains(
assert data["event"] == "initial_state"
finally:
ws.close()
def test_proc_on_exit_calls_close() -> None:
"""Test _proc_on_exit sends exit event and closes the WebSocket."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = False
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_called_once_with({"event": "exit", "code": 0})
handler.close.assert_called_once()
def test_proc_on_exit_skips_when_already_closed() -> None:
"""Test _proc_on_exit does nothing when WebSocket is already closed."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = True
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_not_called()
handler.close.assert_not_called()
def _make_auth_handler(auth_header: str | None = None) -> Mock:
"""Create a mock handler with the given Authorization header."""
handler = Mock()
handler.request = Mock()
if auth_header is not None:
handler.request.headers = {"Authorization": auth_header}
else:
handler.request.headers = {}
handler.get_secure_cookie = Mock(return_value=None)
return handler
@pytest.fixture
def mock_auth_settings(mock_dashboard_settings: MagicMock) -> MagicMock:
"""Fixture to configure mock dashboard settings with auth enabled."""
mock_dashboard_settings.using_auth = True
mock_dashboard_settings.on_ha_addon = False
return mock_dashboard_settings
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_malformed_base64() -> None:
"""Test that invalid base64 in Authorization header returns False."""
handler = _make_auth_handler("Basic !!!not-valid-base64!!!")
assert web_server.is_authenticated(handler) is False
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_bad_base64_padding() -> None:
"""Test that incorrect base64 padding (binascii.Error) returns False."""
handler = _make_auth_handler("Basic abc")
assert web_server.is_authenticated(handler) is False
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_invalid_utf8() -> None:
"""Test that base64 decoding to invalid UTF-8 returns False."""
# \xff\xfe is invalid UTF-8
bad_payload = base64.b64encode(b"\xff\xfe").decode("ascii")
handler = _make_auth_handler(f"Basic {bad_payload}")
assert web_server.is_authenticated(handler) is False
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_no_colon() -> None:
"""Test that base64 payload without ':' separator returns False."""
no_colon = base64.b64encode(b"nocolonhere").decode("ascii")
handler = _make_auth_handler(f"Basic {no_colon}")
assert web_server.is_authenticated(handler) is False
def test_is_authenticated_valid_credentials(
mock_auth_settings: MagicMock,
) -> None:
"""Test that valid Basic auth credentials are checked."""
creds = base64.b64encode(b"admin:secret").decode("ascii")
mock_auth_settings.check_password.return_value = True
handler = _make_auth_handler(f"Basic {creds}")
assert web_server.is_authenticated(handler) is True
mock_auth_settings.check_password.assert_called_once_with("admin", "secret")
def test_is_authenticated_wrong_credentials(
mock_auth_settings: MagicMock,
) -> None:
"""Test that valid Basic auth with wrong credentials returns False."""
creds = base64.b64encode(b"admin:wrong").decode("ascii")
mock_auth_settings.check_password.return_value = False
handler = _make_auth_handler(f"Basic {creds}")
assert web_server.is_authenticated(handler) is False
def test_is_authenticated_no_auth_configured(
mock_dashboard_settings: MagicMock,
) -> None:
"""Test that requests pass when auth is not configured."""
mock_dashboard_settings.using_auth = False
mock_dashboard_settings.on_ha_addon = False
handler = _make_auth_handler()
assert web_server.is_authenticated(handler) is True