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

Compare commits

...

7 Commits

Author SHA1 Message Date
J. Nick Koston
f2e1c2c650 Derive provided_len from string size and bound loop to digest_len
- Use auth.value().size() instead of strlen() to avoid rescanning
  attacker-controlled header content.
- Iterate over digest_len (expected length) instead of max_len so a
  long Authorization header cannot force extra work. The full-width
  length XOR already rejects any length mismatch.
2026-02-08 07:39:09 -06:00
J. Nick Koston
bb2d7c9742 Use full-width accumulator and iterate max_len in constant-time compare
- Change result accumulator from volatile uint8_t to volatile size_t
  to prevent truncation bypass (e.g. digest_len + 256 XOR).
- Iterate over max(digest_len, provided_len) so trailing bytes in
  either string are also compared.
2026-02-08 07:25:50 -06:00
J. Nick Koston
999774889d Add comments explaining constant-time comparison logic 2026-02-08 07:17:24 -06:00
J. Nick Koston
c90ca4df87 Use encoder output length and remove early return on length mismatch
Use the out length from esp_crypto_base64_encode instead of strlen.
Fold length mismatch into the accumulator to make comparison fully
constant-time without early return.
2026-02-08 07:16:19 -06:00
J. Nick Koston
1dcffdc872 [web_server_idf] Use constant-time comparison for Basic Auth
Replace strcmp() with a constant-time XOR accumulation loop
for comparing base64-encoded credentials in HTTP Basic Auth.
2026-02-08 06:51:52 -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
5 changed files with 46 additions and 4 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

@@ -354,7 +354,26 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
esp_crypto_base64_encode(reinterpret_cast<uint8_t *>(digest.get()), n, &out,
reinterpret_cast<const uint8_t *>(user_info), user_info_len);
return strcmp(digest.get(), auth_str + auth_prefix_len) == 0;
// Constant-time comparison to avoid timing side channels.
// No early return on length mismatch — the length difference is folded
// into the accumulator so any mismatch is rejected.
const char *provided = auth_str + auth_prefix_len;
size_t digest_len = out; // length from esp_crypto_base64_encode
// Derive provided_len from the already-sized std::string rather than
// rescanning with strlen (avoids attacker-controlled scan length).
size_t provided_len = auth.value().size() - auth_prefix_len;
// Use full-width XOR so any bit difference in the lengths is preserved
// (uint8_t truncation would miss differences in higher bytes, e.g.
// digest_len vs digest_len + 256).
volatile size_t result = digest_len ^ provided_len;
// Iterate over the expected digest length only — the full-width length
// XOR above already rejects any length mismatch, and bounding the loop
// prevents a long Authorization header from forcing extra work.
for (size_t i = 0; i < digest_len; i++) {
char provided_ch = (i < provided_len) ? provided[i] : 0;
result |= static_cast<uint8_t>(digest.get()[i] ^ provided_ch);
}
return result == 0;
}
void AsyncWebServerRequest::requestAuthentication(const char *realm) const {

View File

@@ -317,6 +317,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

@@ -29,7 +29,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 +1654,25 @@ 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()