Merge pull request #17556 from esphome/bump-2026.7.0b3

2026.7.0b3
This commit is contained in:
Jesse Hills
2026-07-14 16:45:55 +12:00
committed by GitHub
40 changed files with 1026 additions and 88 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.7.0b2
PROJECT_NUMBER = 2026.7.0b3
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+46
View File
@@ -79,6 +79,48 @@ These *are* security bugs in this repo, and we want to hear about them privately
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
below their documented guarantees.
## The web server is an open HTTP API by design
The `web_server` component exposes a plain HTTP interface for viewing and
controlling entities, and, when the `web_server` OTA platform is enabled, for
uploading firmware at `/update`. Its only access controls are the optional
`web_server` `auth:` credentials and the network the device sits on.
When `auth:` is not configured, every endpoint is reachable by any client that
can reach the device. This is intentional; enabling `web_server` without `auth:`
is choosing an open control surface, in the same way that running native OTA
without a password leaves OTA open. The API is documented and is meant to be
called by other devices, scripts, and pages.
As defense-in-depth, the web server checks the `Origin` header on browser requests
to its entity control and state endpoints: a request whose `Origin` does not match
the address the device is served on is rejected, and the `allowed_origins` option
widens that list. This blocks the common "confused deputy" (CSRF) case where a page
the operator visits drives the device through their browser. It is **not** an
authentication boundary: it only constrains browsers. Any client that omits the
`Origin` header — `curl`, scripts, or other non-browser callers on the same
network — reaches every endpoint exactly as before. The check also does not cover
the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
validation. The following are therefore **not** vulnerabilities in this repository:
- Requests without an `Origin` header (for example `curl`) reaching the control
endpoints, whether or not `web_server` `auth:` is set.
- Requests from an origin the operator added to `allowed_origins`.
- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
covered by the `Origin` check; this is the same exposure as running OTA without a
password.
The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
a native OTA password), and keeping devices on a trusted, segmented network. See
the security best practices guide linked above.
What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
and any memory-safety or protocol bug in the server reachable without credentials.
This section documents the current design and scope; it is not a judgment that the
design is optimal or that it will not change.
## Explicitly out of scope
- Local attackers who already have shell access on the host that runs `esphome`.
@@ -86,6 +128,10 @@ These *are* security bugs in this repo, and we want to hear about them privately
- Operator-supplied hostile YAML (covered above — config authoring is trusted).
- Attacks that require an already-authenticated device peer (someone who already
holds the API key / OTA / web credentials).
- Access to the device web server or its web OTA endpoint by non-browser clients
(those that send no `Origin` header). The web server is an open HTTP API by
design (see above); browser cross-origin requests are blocked by default, but the
real controls are `web_server` `auth:` and network isolation.
- Anything in the dashboard / device-builder — report that in its own repository
(linked at the top).
- Deployments where the operator removed protections or exposed credentials. See
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.4.3
RUN uv pip install --no-cache-dir esphome-device-builder==1.5.0
RUN \
platformio settings set enable_telemetry No \
+2 -2
View File
@@ -5,7 +5,7 @@ from esphome.const import (
CONF_CLEAR,
CONF_GAIN,
CONF_ID,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_EMPTY,
ICON_BRIGHTNESS_5,
STATE_CLASS_MEASUREMENT,
)
@@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_5,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
)
+76 -13
View File
@@ -1160,6 +1160,74 @@ def _ota_downgrade_protection_errors(
return errs
_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_SIGNING_KEY): cv.file_,
cv.Optional(CONF_VERIFICATION_KEY): cv.file_,
cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of(
*SIGNING_SCHEMES, lower=True
),
}
)
@schema_extractor("schema")
def _validate_signed_ota_verification(value):
if value is SCHEMA_EXTRACT:
# Expose the inner schema so the language-schema dumper can walk the
# signing_key / verification_key / signing_scheme options.
return _SIGNED_OTA_VERIFICATION_SCHEMA
if value is None:
# A bare `signed_ota_verification:` block is valid: the default V2
# scheme needs no keys (verify externally-signed binaries).
value = {}
return _validate_signed_ota_keys(_SIGNED_OTA_VERIFICATION_SCHEMA(value))
def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
"""Validate the signing/verification key combination for the selected scheme.
A verification key is only used by the Secure Boot V1 scheme (ecdsa_v1):
the public key is compiled into the app so it can verify externally-signed
images. ESP-IDF's CONFIG_SECURE_BOOT_VERIFICATION_KEY only takes effect
when the V1 ECDSA scheme is selected and binaries are not signed during
the build (see SECURE_BOOT_VERIFICATION_KEY in the bootloader Kconfig).
The V2 schemes (rsa3072, ecdsa256) embed the public key in the signature
block appended to each image, so verifying externally-signed binaries
needs no key in the config at all -- omitting both keys selects that
external-signing mode.
"""
has_signing_key = CONF_SIGNING_KEY in config
has_verification_key = CONF_VERIFICATION_KEY in config
scheme = config[CONF_SIGNING_SCHEME]
if has_signing_key and has_verification_key:
raise cv.Invalid(
f"Provide at most one of '{CONF_SIGNING_KEY}' and "
f"'{CONF_VERIFICATION_KEY}', not both.",
path=[CONF_VERIFICATION_KEY],
)
if scheme == "ecdsa_v1":
if not has_signing_key and not has_verification_key:
raise cv.Invalid(
f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' "
f"(to sign binaries during the build) or '{CONF_VERIFICATION_KEY}' "
f"(to verify binaries signed externally).",
path=[CONF_SIGNING_KEY],
)
elif has_verification_key:
raise cv.Invalid(
f"'{CONF_VERIFICATION_KEY}' is only used with signing scheme "
f"'ecdsa_v1'. With '{scheme}' the public key is embedded in each "
f"image's signature block, so no key file is needed to verify "
f"externally-signed binaries: remove '{CONF_VERIFICATION_KEY}', and "
f"set '{CONF_SIGNING_KEY}' only if binaries should be signed during "
f"the build.",
path=[CONF_VERIFICATION_KEY],
)
return config
def final_validate(config):
# Imported locally to avoid circular import issues
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
@@ -1361,7 +1429,7 @@ def final_validate(config):
)
else:
_LOGGER.info(
"Signed OTA verification is configured with a public verification key. "
"Signed OTA verification is enabled without a signing key. "
"Binaries will NOT be signed automatically during build. "
"You must sign them externally before flashing."
)
@@ -1640,18 +1708,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False
): cv.boolean,
cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All(
cv.Schema(
{
cv.Optional(CONF_SIGNING_KEY): cv.file_,
cv.Optional(CONF_VERIFICATION_KEY): cv.file_,
cv.Optional(
CONF_SIGNING_SCHEME, default="rsa3072"
): cv.one_of(*SIGNING_SCHEMES, lower=True),
}
),
cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY),
),
CONF_SIGNED_OTA_VERIFICATION
): _validate_signed_ota_verification,
cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema(
{
# eFuse key block (0-5) that stores the HMAC key from
@@ -2498,8 +2557,12 @@ async def to_code(config):
signed_ota[CONF_SIGNING_KEY].resolve().as_posix(),
)
else:
# Public key mode — verification only, external signing required
# External signing mode — binaries must be signed after the build
add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False)
if CONF_VERIFICATION_KEY in signed_ota:
# V1 ECDSA only: the public key is compiled into the app to
# verify externally-signed images. V2 schemes carry the public
# key in each image's signature block and need no key here.
add_idf_sdkconfig_option(
"CONFIG_SECURE_BOOT_VERIFICATION_KEY",
signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(),
+15
View File
@@ -76,12 +76,27 @@ _BLE5_BK_SYS_CONFIG_OPTIONS = [
"CFG_SUPPORT_BLE=0",
]
# Board ids upstream LibreTiny renamed; configs written against the old id
# keep validating and building against the new one (with a warning).
# generic-ln882hki -> generic-ln882h: LibreTiny v1.13.0.
_RENAMED_BOARDS = {
"generic-ln882hki": "generic-ln882h",
}
def _detect_variant(value):
if KEY_LIBRETINY not in CORE.data:
raise cv.Invalid("Family component didn't populate core data properly!")
component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA]
board = value[CONF_BOARD]
if board not in component.boards and (renamed := _RENAMED_BOARDS.get(board)):
_LOGGER.warning(
"Board '%s' was renamed to '%s'; please update your configuration",
board,
renamed,
)
value = value.copy()
value[CONF_BOARD] = board = renamed
# read board-default family if not specified
if board not in component.boards:
if CONF_FAMILY not in value:
+5 -5
View File
@@ -15,7 +15,7 @@ from esphome.const import (
CONF_NAME,
CONF_REPEAT,
CONF_TYPE,
DEVICE_CLASS_DISTANCE,
DEVICE_CLASS_EMPTY,
DEVICE_CLASS_ILLUMINANCE,
ICON_BRIGHTNESS_5,
ICON_BRIGHTNESS_6,
@@ -159,7 +159,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_5,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
@@ -169,7 +169,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_7,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
@@ -179,7 +179,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_PROXIMITY,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DISTANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
@@ -188,7 +188,7 @@ CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(
icon=ICON_GAIN,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
+22 -6
View File
@@ -1,6 +1,7 @@
#include "nextion.h"
#include <cinttypes>
#include <new>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
@@ -354,6 +355,7 @@ void Nextion::loop() {
this->process_serial_(); // Receive serial data
this->process_nextion_commands_(); // Process nextion return commands
this->purge_stale_queue_entries_(); // Drop expired entries even when the display sends no data
if (!this->connection_state_.nextion_reports_is_setup_) {
if (this->started_ms_ == 0)
@@ -902,6 +904,11 @@ void Nextion::process_nextion_commands_() {
this->command_data_.erase(0, to_process_length + DELIMITER_SIZE + 1);
}
ESP_LOGN(TAG, "Loop end");
this->process_serial_();
} // Nextion::process_nextion_commands_()
void Nextion::purge_stale_queue_entries_() {
const uint32_t ms = App.get_loop_component_start_time();
if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() &&
@@ -927,10 +934,7 @@ void Nextion::process_nextion_commands_() {
}
}
}
ESP_LOGN(TAG, "Loop end");
// App.feed_wdt(); Remove before master merge
this->process_serial_();
} // Nextion::process_nextion_commands_()
}
void Nextion::set_nextion_sensor_state(int queue_type, const std::string &name, float state) {
this->set_nextion_sensor_state(static_cast<NextionQueueType>(queue_type), name, state);
@@ -1101,7 +1105,13 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
new (nextion_queue) nextion::NextionQueue();
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
nextion_queue->component = new nextion::NextionComponentBase;
nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase;
if (nextion_queue->component == nullptr) {
ESP_LOGW(TAG, "Component alloc failed");
nextion_queue->~NextionQueue();
allocator.deallocate(nextion_queue, 1);
return;
}
nextion_queue->component->set_variable_name(variable_name);
nextion_queue->queue_time = App.get_loop_component_start_time();
@@ -1157,7 +1167,13 @@ void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &va
}
new (nextion_queue) nextion::NextionQueue();
nextion_queue->component = new nextion::NextionComponentBase;
nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase;
if (nextion_queue->component == nullptr) {
ESP_LOGW(TAG, "Component alloc failed");
nextion_queue->~NextionQueue();
allocator.deallocate(nextion_queue, 1);
return;
}
nextion_queue->component->set_variable_name(variable_name);
nextion_queue->queue_time = App.get_loop_component_start_time();
nextion_queue->pending_command = command; // Store command for retry
+4
View File
@@ -1486,6 +1486,10 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
void process_nextion_commands_();
void process_serial_();
/// Drop queue entries older than max_q_age_ms_. Called from loop() so it also runs when the
/// display sends no data at all (disconnected or asleep), which would otherwise grow the queue
/// without bound.
void purge_stale_queue_entries_();
uint16_t touch_sleep_timeout_ = 0;
uint8_t wake_up_page_ = 255;
#ifdef USE_NEXTION_CONF_START_UP_PAGE
+3 -2
View File
@@ -14,6 +14,7 @@ from esphome.const import (
CONF_INFRARED,
CONF_INTEGRATION_TIME,
CONF_NAME,
DEVICE_CLASS_EMPTY,
DEVICE_CLASS_ILLUMINANCE,
ICON_BRIGHTNESS_5,
ICON_BRIGHTNESS_6,
@@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_6,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
@@ -111,7 +112,7 @@ CONFIG_SCHEMA = cv.All(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_7,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
),
key=CONF_NAME,
+77 -5
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import gzip
import logging
import re
import esphome.codegen as cg
from esphome.components import web_server_base
@@ -24,6 +25,7 @@ from esphome.const import (
CONF_OTA,
CONF_PASSWORD,
CONF_PORT,
CONF_TYPE,
CONF_USERNAME,
CONF_VERSION,
CONF_WEB_SERVER,
@@ -43,9 +45,13 @@ _LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["json", "web_server_base"]
AUTH_TYPE_BASIC = "basic"
AUTH_TYPE_DIGEST = "digest"
CONF_SORTING_GROUP_ID = "sorting_group_id"
CONF_SORTING_GROUPS = "sorting_groups"
CONF_SORTING_WEIGHT = "sorting_weight"
CONF_ALLOWED_ORIGINS = "allowed_origins"
web_server_ns = cg.esphome_ns.namespace("web_server")
@@ -83,6 +89,19 @@ def validate_version_deprecated(config: ConfigType) -> ConfigType:
return config
def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType:
# Remove before 2027.1.0: the default auth scheme changes from basic to digest.
if CONF_TYPE not in auth:
_LOGGER.warning(
"The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the "
"password over the network in an easily reversible form. The default will change "
"to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set "
"'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to "
"adopt the more secure scheme."
)
return auth
def validate_local(config: ConfigType) -> ConfigType:
if CONF_LOCAL in config and config[CONF_VERSION] == 1:
raise cv.Invalid("'local' is not supported in version 1")
@@ -104,6 +123,41 @@ def validate_ota(config: ConfigType) -> ConfigType:
return config
# An Origin header is always "scheme://host[:port]" with no path or trailing slash.
_ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$")
def validate_origin(value: str) -> str:
# "*" is the wildcard that allows any origin.
if value == "*":
return value
value = cv.string_strict(value)
if not _ORIGIN_RE.match(value):
raise cv.Invalid(
f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no "
f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin."
)
# Browsers send the scheme and host lowercased in the Origin header, so normalize to match.
return value.lower()
def validate_private_network_access(config: ConfigType) -> ConfigType:
# PNA preflights are always cross-origin, so they can only be authorized against the
# allowed_origins list. Enabling PNA without any origins would deny every PNA request.
if (
config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]
and config.get(CONF_ALLOWED_ORIGINS) is None
):
raise cv.Invalid(
f"'{CONF_ALLOWED_ORIGINS}' must be set when "
f"'{CONF_ENABLE_PRIVATE_NETWORK_ACCESS}' is enabled. List each origin that is "
f"allowed to reach the device (e.g. 'https://example.com'). '*' allows any origin "
f"but is not recommended.",
path=[CONF_ENABLE_PRIVATE_NETWORK_ACCESS],
)
return config
def validate_sorting_groups(config: ConfigType) -> ConfigType:
if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3:
raise cv.Invalid(
@@ -201,8 +255,12 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_CSS_INCLUDE): cv.file_,
cv.Optional(CONF_JS_URL): cv.string,
cv.Optional(CONF_JS_INCLUDE): cv.file_,
cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=True): cv.boolean,
cv.Optional(CONF_AUTH): cv.Schema(
cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=False): cv.boolean,
cv.Optional(CONF_ALLOWED_ORIGINS): cv.All(
cv.ensure_list(validate_origin), cv.Length(min=1)
),
cv.Optional(CONF_AUTH): cv.All(
cv.Schema(
{
cv.Required(CONF_USERNAME): cv.All(
cv.string_strict, cv.Length(min=1)
@@ -210,8 +268,13 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_PASSWORD): cv.sensitive(
cv.All(cv.string_strict, cv.Length(min=1))
),
cv.Optional(CONF_TYPE): cv.one_of(
AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True
),
}
),
validate_auth_type_deprecated,
),
cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id(
web_server_base.WebServerBase
),
@@ -238,6 +301,7 @@ CONFIG_SCHEMA = cv.All(
validate_local,
validate_sorting_groups,
validate_ota,
validate_private_network_access,
_consume_web_server_sockets,
)
@@ -334,10 +398,18 @@ async def to_code(config):
request_log_listener() # Request a log listener slot for web server log streaming
if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")
if CONF_AUTH in config:
if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None:
cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS")
cg.add(var.set_allowed_origins(allowed_origins))
if (auth := config.get(CONF_AUTH)) is not None:
cg.add_define("USE_WEBSERVER_AUTH")
cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME]))
cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD]))
# The scheme is fixed at build time so the unused Basic/Digest code path is compiled
# out. Basic is the current default (the absence of this define); an explicit
# 'type: digest' opts in early. Default changes to digest in 2027.1.0.
if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST:
cg.add_define("USE_WEBSERVER_AUTH_DIGEST")
cg.add(paren.set_auth_username(auth[CONF_USERNAME]))
cg.add(paren.set_auth_password(auth[CONF_PASSWORD]))
if CONF_CSS_INCLUDE in config:
cg.add_define("USE_WEBSERVER_CSS_INCLUDE")
path = CORE.relative_config_path(config[CONF_CSS_INCLUDE])
+64 -7
View File
@@ -456,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) {
}
#endif
// Read a request header value portably across the Arduino and ESP-IDF web servers.
// Returns an empty string when the header is absent (only allocates when a value is present).
static std::string get_request_header(AsyncWebServerRequest *request, const char *name) {
#ifdef USE_ESP32
// ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend.
optional<std::string> value = request->get_header(name);
return value.has_value() ? std::move(*value) : std::string();
#else
// ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend.
const AsyncWebHeader *header = request->getHeader(name);
return header != nullptr ? std::string(header->value().c_str()) : std::string();
#endif
}
bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) {
// No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow.
if (origin.empty())
return true;
// Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to.
// This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time.
const size_t scheme_sep = origin.find("://");
if (scheme_sep != std::string::npos) {
const std::string host = get_request_header(request, "Host");
if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0)
return true;
}
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
// Otherwise the origin must be explicitly allowed via configuration.
for (const char *allowed_origin : this->allowed_origins_) {
// A single "*" entry allows any origin.
if (allowed_origin[0] == '*' && allowed_origin[1] == '\0')
return true;
if (origin == allowed_origin)
return true;
}
#endif
return false;
}
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) {
const std::string origin = get_request_header(request, "Origin");
if (!this->is_request_origin_allowed_(request, origin)) {
request->send(403);
return;
}
AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F(""));
// Echo the specific origin back so the response is valid even when auth (credentials) is enabled.
response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str());
response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true"));
response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str());
char mac_s[18];
@@ -2448,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
return;
}
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
// Private Network Access preflight carries a cross-origin Origin by design; its handler does the
// origin check itself, so let it run before the general enforcement below.
if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) {
this->handle_pna_cors_request(request);
return;
}
#endif
// Reject cross-origin browser requests unless the origin is explicitly allowed.
if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) {
request->send(403);
return;
}
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
if (url == ESPHOME_F("/events")) {
this->events_.add_new_client(this, request);
@@ -2469,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
}
#endif
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) {
this->handle_pna_cors_request(request);
return;
}
#endif
// Parse URL for component routing
// Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action)
UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST);
@@ -242,6 +242,22 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
*/
void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; }
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
/** Set the origins that browsers are allowed to make cross-origin requests from.
*
* Requests without an `Origin` header (e.g. non-browser clients like curl or the native API)
* are always allowed. Requests whose `Origin` matches the address the device is served on
* (same-origin) are always allowed. Any other browser origin must appear in this list, or the
* request is rejected. A single "*" entry allows any origin. Each other entry must exactly match
* the requesting page's `Origin` header (e.g. "https://example.com").
*
* This list is also used to authorize Private Network Access requests when that feature is enabled.
*
* @param origins The list of allowed origins.
*/
void set_allowed_origins(std::initializer_list<const char *> origins) { this->allowed_origins_ = origins; }
#endif
// ========== INTERNAL METHODS ==========
// (In most use cases you won't need these)
/// Setup the internal web server and register handlers.
@@ -593,6 +609,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
const char *js_include_{nullptr};
#endif
bool expose_log_{true};
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
// Extra origins allowed to make cross-origin browser requests ("*" means any origin).
// Only compiled when allowed_origins is configured; same-origin is always allowed regardless.
FixedVector<const char *> allowed_origins_;
#endif
/// Check whether the given request Origin is permitted. Same-origin (matching the Host the
/// request was sent to) and requests without an Origin header are always allowed; any other
/// origin must be listed in allowed_origins. The caller passes the already-read Origin header.
bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin);
private:
#ifdef USE_SENSOR
@@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler {
bool check_auth(AsyncWebServerRequest *request) {
bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str());
if (!success) {
// The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is
// compiled out. On ESP32 our own server picks the scheme internally.
#if USE_ESP32
request->requestAuthentication();
#elif defined(USE_WEBSERVER_AUTH_DIGEST)
request->requestAuthentication(nullptr, true);
#else
request->requestAuthentication(nullptr, false);
#endif
}
return success;
}
@@ -16,6 +16,11 @@
#include "utils.h"
#include "web_server_idf.h"
#ifdef USE_WEBSERVER_AUTH_DIGEST
#include <esp_random.h>
#include <esp_rom_md5.h>
#endif
#ifdef USE_WEBSERVER_OTA
#include <multipart_parser.h>
#include "multipart.h" // For parse_multipart_boundary and other utils
@@ -372,6 +377,135 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code
}
#ifdef USE_WEBSERVER_AUTH
#ifdef USE_WEBSERVER_AUTH_DIGEST
namespace {
// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated.
void bytes_to_hex(const uint8_t *data, size_t len, char *out) {
static const char HEX[] = "0123456789abcdef";
for (size_t i = 0; i < len; i++) {
out[i * 2] = HEX[data[i] >> 4];
out[i * 2 + 1] = HEX[data[i] & 0x0f];
}
out[len * 2] = '\0';
}
// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated
// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent.
// Only whole parameter names match, so "nc" does not match inside "cnonce".
StringRef digest_param(StringRef params, const char *key) {
size_t key_len = strlen(key);
const char *base = params.c_str();
size_t n = params.size();
size_t i = 0;
while (i < n) {
while (i < n && (base[i] == ' ' || base[i] == ','))
i++;
size_t name_start = i;
while (i < n && base[i] != '=' && base[i] != ',')
i++;
if (i >= n || base[i] == ',')
continue; // token without a '=', skip it
size_t name_len = i - name_start;
while (name_len > 0 && base[name_start + name_len - 1] == ' ')
name_len--;
i++; // consume '='
const char *val_start;
size_t val_len;
if (i < n && base[i] == '"') {
i++;
val_start = base + i;
while (i < n && base[i] != '"')
i++;
val_len = (base + i) - val_start;
if (i < n)
i++; // consume closing quote
} else {
val_start = base + i;
while (i < n && base[i] != ',')
i++;
val_len = (base + i) - val_start;
}
if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0)
return StringRef(val_start, val_len);
while (i < n && base[i] != ',')
i++;
}
return StringRef();
}
// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which
// matches the ESPAsyncWebServer backend used on the Arduino platforms.
bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) {
const size_t prefix_len = sizeof("Digest ") - 1;
StringRef params(header.c_str() + prefix_len, header.size() - prefix_len);
if (digest_param(params, "username") != username)
return false;
StringRef realm = digest_param(params, "realm");
StringRef nonce = digest_param(params, "nonce");
StringRef uri = digest_param(params, "uri");
StringRef qop = digest_param(params, "qop");
StringRef nc = digest_param(params, "nc");
StringRef cnonce = digest_param(params, "cnonce");
StringRef response = digest_param(params, "response");
if (response.size() != 32)
return false;
// Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so
// nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters.
md5_context_t ctx;
uint8_t digest[16];
// HA1 = MD5(username:realm:password) -- uses the realm the client echoed back.
char ha1[33];
esp_rom_md5_init(&ctx);
esp_rom_md5_update(&ctx, username, strlen(username));
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, realm.c_str(), realm.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, password, strlen(password));
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), ha1);
// HA2 = MD5(method:uri) -- uses the uri the client echoed back.
char ha2[33];
esp_rom_md5_init(&ctx);
esp_rom_md5_update(&ctx, method, strlen(method));
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, uri.c_str(), uri.size());
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), ha2);
// expected = MD5(HA1:nonce:nc:cnonce:qop:HA2)
char expected[33];
esp_rom_md5_init(&ctx);
esp_rom_md5_update(&ctx, ha1, 32);
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, nc.c_str(), nc.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, qop.c_str(), qop.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, ha2, 32);
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), expected);
// Constant-time comparison of the two 32-char hex digests.
uint8_t result = 0;
for (size_t i = 0; i < 32; i++)
result |= static_cast<uint8_t>(expected[i] ^ response[i]);
return result == 0;
}
} // namespace
#endif // USE_WEBSERVER_AUTH_DIGEST
bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const {
if (username == nullptr || password == nullptr || *username == 0) {
return true;
@@ -383,9 +517,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
auto *auth_str = auth.value().c_str();
#ifdef USE_WEBSERVER_AUTH_DIGEST
// The build fixed the scheme to Digest, so the Basic path is compiled out entirely.
const auto auth_prefix_len = sizeof("Digest ") - 1;
if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) {
ESP_LOGW(TAG, "Only Digest authorization supported");
return false;
}
return check_digest_auth(username, password, auth.value(), http_method_str(this->method()));
#else
const auto auth_prefix_len = sizeof("Basic ") - 1;
if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) {
ESP_LOGW(TAG, "Only Basic authorization supported yet");
ESP_LOGW(TAG, "Only Basic authorization supported");
return false;
}
@@ -434,16 +577,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
result |= static_cast<uint8_t>(digest[i] ^ provided_ch);
}
return result == 0;
#endif // USE_WEBSERVER_AUTH_DIGEST
}
void AsyncWebServerRequest::requestAuthentication(const char *realm) const {
void AsyncWebServerRequest::requestAuthentication() const {
httpd_resp_set_hdr(*this, "Connection", "keep-alive");
// Note: realm is never configured in ESPHome, always nullptr -> "Login Required"
(void) realm; // Unused - always use default
#ifdef USE_WEBSERVER_AUTH_DIGEST
// Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and
// does not defend against replay -- its purpose is to keep the password off the wire.
// The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer
// lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy).
uint8_t random_bytes[16];
char nonce[33];
char opaque[33];
char header[160];
esp_fill_random(random_bytes, sizeof(random_bytes));
bytes_to_hex(random_bytes, sizeof(random_bytes), nonce);
esp_fill_random(random_bytes, sizeof(random_bytes));
bytes_to_hex(random_bytes, sizeof(random_bytes), opaque);
snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce,
opaque);
httpd_resp_set_hdr(*this, "WWW-Authenticate", header);
#else
httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\"");
#endif // USE_WEBSERVER_AUTH_DIGEST
httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr);
}
#endif
#endif // USE_WEBSERVER_AUTH
AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) {
// Check cache first - only successful lookups are cached
@@ -129,7 +129,7 @@ class AsyncWebServerRequest {
#ifdef USE_WEBSERVER_AUTH
bool authenticate(const char *username, const char *password) const;
// NOLINTNEXTLINE(readability-identifier-naming)
void requestAuthentication(const char *realm = nullptr) const;
void requestAuthentication() const;
#endif
void redirect(const std::string &url);
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.7.0b2"
__version__ = "2026.7.0b3"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+4
View File
@@ -298,10 +298,12 @@
#define USE_VOICE_ASSISTANT
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_OTA
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_WEBSERVER_GZIP
#define USE_WEBSERVER_SORTING
#define USE_WEBSERVER_ALLOWED_ORIGINS
#define WEB_SERVER_DEFAULT_HEADERS_COUNT 1
#define USE_CAPTIVE_PORTAL_GZIP
#define USE_WIFI_11KV_SUPPORT
@@ -407,6 +409,7 @@
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_PORT 80 // NOLINT
#endif
@@ -437,6 +440,7 @@
#define USE_LWIP_FAST_SELECT
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_ESPHOME_TASK_LOG_BUFFER
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
+19 -2
View File
@@ -263,22 +263,39 @@ def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> d
else {}
)
packages_value = comp_data.get("packages")
if isinstance(packages_value, dict):
# Expand component-specific package includes inline. A package include may
# itself pull in further component-specific packages (e.g. web_server's test
# includes common_v2, which includes common with the wifi/network config), so
# keep expanding until only common bus packages remain -- otherwise the nested
# includes are silently dropped when the packages key is removed below.
common_bus_packages = get_common_bus_packages()
while True:
packages_value = comp_data.get("packages")
expanded = False
if isinstance(packages_value, dict):
for pkg_name, pkg_value in list(packages_value.items()):
if pkg_name in common_bus_packages:
continue
# Drop before merging so a nested packages dict introduced by the
# include does not re-add this same key on the next iteration.
del packages_value[pkg_name]
if isinstance(pkg_value, yaml_util.IncludeFile):
pkg_value = pkg_value.load()
if isinstance(pkg_value, dict):
comp_data = merge_config(comp_data, pkg_value)
expanded = True
elif isinstance(packages_value, list):
# List-style packages never contain common bus packages, so expand
# them all and drop the key entirely.
comp_data.pop("packages", None)
for pkg_value in packages_value:
if isinstance(pkg_value, yaml_util.IncludeFile):
pkg_value = pkg_value.load()
if isinstance(pkg_value, dict):
comp_data = merge_config(comp_data, pkg_value)
expanded = True
if not expanded:
break
# Common bus packages are re-added once by the caller; drop them here.
comp_data.pop("packages", None)
+75
View File
@@ -665,3 +665,78 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None:
# No project version and no signing -> two distinct errors.
errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False)
assert len(errs) == 2
@pytest.mark.parametrize(
"config",
[
# V2 schemes: signing key (sign during build) or no key at all
# (external signing; the public key travels in the signature block).
{"signing_scheme": "rsa3072", "signing_key": "key.pem"},
{"signing_scheme": "rsa3072"},
{"signing_scheme": "ecdsa256", "signing_key": "key.pem"},
{"signing_scheme": "ecdsa256"},
# V1 ECDSA: exactly one of signing key / verification key.
{"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"},
{"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"},
],
)
def test_signed_ota_keys_valid_combinations(config: dict) -> None:
from esphome.components.esp32 import _validate_signed_ota_keys
assert _validate_signed_ota_keys(config) is config
@pytest.mark.parametrize("value", [None, {}])
def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -> None:
"""A bare `signed_ota_verification:` block is valid: the default V2
scheme embeds the public key in the signature block, so verifying
externally-signed binaries needs no keys in the config."""
from esphome.components.esp32 import _validate_signed_ota_verification
config = _validate_signed_ota_verification(value)
assert config == {"signing_scheme": "rsa3072"}
@pytest.mark.parametrize(
("config", "match"),
[
# A verification key is meaningless with the V2 schemes -- the public
# key is embedded in each image's signature block.
(
{"signing_scheme": "rsa3072", "verification_key": "key.bin"},
"only used with signing scheme 'ecdsa_v1'",
),
(
{"signing_scheme": "ecdsa256", "verification_key": "key.bin"},
"only used with signing scheme 'ecdsa_v1'",
),
# V1 ECDSA needs a key either way.
(
{"signing_scheme": "ecdsa_v1"},
"Signing scheme 'ecdsa_v1' requires either",
),
# Never both keys at once.
(
{
"signing_scheme": "rsa3072",
"signing_key": "key.pem",
"verification_key": "key.bin",
},
"not both",
),
(
{
"signing_scheme": "ecdsa_v1",
"signing_key": "key.pem",
"verification_key": "key.bin",
},
"not both",
),
],
)
def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
from esphome.components.esp32 import _validate_signed_ota_keys
with pytest.raises(cv.Invalid, match=match):
_validate_signed_ota_keys(config)
@@ -0,0 +1,86 @@
"""Tests for web_server Private Network Access / allowed_origins validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.web_server import (
CONF_ALLOWED_ORIGINS,
validate_origin,
validate_private_network_access,
)
from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS
from esphome.types import ConfigType
def test_pna_enabled_without_origins_fails() -> None:
"""Enabling PNA without allowed_origins must fail validation."""
config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True}
with pytest.raises(cv.Invalid) as exc_info:
validate_private_network_access(config)
error_msg = str(exc_info.value)
assert CONF_ALLOWED_ORIGINS in error_msg
assert "must be set" in error_msg
def test_pna_enabled_with_origins_passes() -> None:
"""Enabling PNA with at least one allowed origin passes validation."""
config: ConfigType = {
CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True,
CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"],
}
assert validate_private_network_access(config) == config
def test_origins_without_pna_passes() -> None:
"""allowed_origins can be set without enabling PNA (they are independent)."""
config: ConfigType = {
CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False,
CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"],
}
assert validate_private_network_access(config) == config
def test_pna_disabled_without_origins_passes() -> None:
"""PNA disabled and no origins specified passes validation."""
config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False}
assert validate_private_network_access(config) == config
def test_validate_origin_wildcard() -> None:
"""The '*' wildcard is accepted as-is."""
assert validate_origin("*") == "*"
@pytest.mark.parametrize(
"value",
[
"https://example.com",
"http://example.com:8080",
"https://192.168.1.5",
],
)
def test_validate_origin_valid(value: str) -> None:
"""Well-formed origins pass through unchanged."""
assert validate_origin(value) == value
def test_validate_origin_lowercased() -> None:
"""Scheme and host are normalized to lowercase to match the browser Origin header."""
assert validate_origin("HTTPS://App.Example.com") == "https://app.example.com"
@pytest.mark.parametrize(
"value",
[
"https://example.com/", # trailing slash
"https://example.com/path", # path segment
"example.com", # missing scheme
"", # empty
],
)
def test_validate_origin_invalid(value: str) -> None:
"""Malformed origins are rejected at config time instead of silently 403ing."""
with pytest.raises(cv.Invalid, match="not a valid origin"):
validate_origin(value)
@@ -0,0 +1,65 @@
"""Tests for web_server authentication codegen."""
from collections.abc import Callable
import pytest
from esphome.core import CORE
_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0"
def _has_define(name: str) -> bool:
return any(d.name == name for d in CORE.defines)
def test_web_server_auth_default_is_basic_with_deprecation_warning(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth without an explicit type builds Basic and warns about the upcoming default change."""
main_cpp = generate_main(
"tests/component_tests/web_server/web_server_auth_default.yaml"
)
assert '->set_auth_username("admin");' in main_cpp
assert '->set_auth_password("password");' in main_cpp
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING in caplog.text
def test_web_server_auth_explicit_basic_no_warning(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth type basic builds Basic and does not warn."""
generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml")
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
def test_web_server_auth_explicit_digest(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth type digest builds Digest and does not warn."""
generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml")
assert _has_define("USE_WEBSERVER_AUTH")
assert _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
def test_web_server_without_auth(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Without an auth block, no auth is compiled in and no warning is emitted."""
generate_main("tests/component_tests/web_server/web_server_no_auth.yaml")
assert not _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: basic
@@ -0,0 +1,17 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: digest
@@ -0,0 +1,14 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
@@ -0,0 +1,11 @@
# Secure Boot V2 schemes carry the public key inside each image's signature
# block, so verifying externally-signed binaries needs no key in the config:
# a bare block enables verification with the default rsa3072 scheme.
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
<<: !include common.yaml
@@ -1,5 +1,5 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
xl9535:
@@ -10,6 +10,9 @@ display:
id: gsl3670_display
spi_id: spi_bus
model: t-display-s3-pro
# The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL
# pin, so override it onto a free pin for this test.
dc_pin: GPIO5
psram:
mode: quad
@@ -5,3 +5,6 @@ web_server:
port: 8080
version: 2
compression: br
enable_private_network_access: true
allowed_origins:
- https://app.esphome.io
@@ -4,6 +4,9 @@ packages:
web_server:
port: 8080
version: 3
# allowed_origins can be set independently of Private Network Access
allowed_origins:
- https://app.esphome.io
sorting_groups:
- id: sorting_group_1
name: "Group 1 Diplayed Last"
@@ -1 +1,2 @@
<<: !include common_v2.yaml
packages:
web_server: !include common_v2.yaml
@@ -1,6 +1,8 @@
<<: !include common_v2.yaml
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: digest
@@ -1 +1,8 @@
<<: !include common_v2.yaml
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: digest
@@ -1 +1,8 @@
<<: !include common_v2.yaml
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: basic
@@ -1 +1,2 @@
<<: !include common_v1.yaml
packages:
web_server: !include common_v1.yaml
@@ -1 +1,2 @@
<<: !include common_v1.yaml
packages:
web_server: !include common_v1.yaml
@@ -1 +1,2 @@
<<: !include common_v3.yaml
packages:
web_server: !include common_v3.yaml
@@ -0,0 +1,8 @@
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: basic
@@ -10,7 +10,10 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve(
import merge_component_configs # noqa: E402
from esphome import yaml_util # noqa: E402
deduplicate_by_id = merge_component_configs.deduplicate_by_id
prepare_component_body = merge_component_configs.prepare_component_body
def test_identical_duplicate_ids_collapse() -> None:
@@ -99,3 +102,56 @@ def test_nested_lists_are_checked() -> None:
}
with pytest.raises(ValueError, match="dup"):
deduplicate_by_id(data)
def test_nested_package_includes_are_fully_expanded(tmp_path: Path) -> None:
"""A package include that itself pulls in another package expands fully.
Mirrors web_server's tests, where test.yaml includes common_v2, which
includes common (holding the wifi/network config). Without recursive
expansion the nested include is dropped and network config is lost.
"""
(tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n")
(tmp_path / "common_v2.yaml").write_text(
"packages:\n device_base: !include common.yaml\nweb_server:\n port: 8080\n"
)
(tmp_path / "test.yaml").write_text(
"packages:\n web_server: !include common_v2.yaml\n"
"web_server:\n auth:\n username: admin\n"
)
comp_data = yaml_util.load_yaml(tmp_path / "test.yaml")
result = prepare_component_body(comp_data, "web_server", tmp_path)
assert "packages" not in result
assert result["wifi"] == {"ssid": "MySSID"}
assert result["web_server"] == {"port": 8080, "auth": {"username": "admin"}}
def test_common_bus_package_is_left_for_caller(tmp_path: Path) -> None:
"""Common bus packages are not expanded inline; the caller re-adds them."""
comp_data = {
"packages": {
"i2c": {"sda": 21, "scl": 22},
"device_base": {"wifi": {"ssid": "MySSID"}},
},
}
result = prepare_component_body(comp_data, "mycomp", tmp_path)
# The bus package's body must not be merged in, and the packages key is
# dropped entirely for the caller to re-add the common bus package.
assert "packages" not in result
assert "sda" not in result
assert result["wifi"] == {"ssid": "MySSID"}
def test_list_style_packages_are_expanded(tmp_path: Path) -> None:
"""List-style package includes are expanded and the key removed."""
(tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n")
(tmp_path / "test.yaml").write_text("packages:\n - !include common.yaml\n")
comp_data = yaml_util.load_yaml(tmp_path / "test.yaml")
result = prepare_component_body(comp_data, "mycomp", tmp_path)
assert "packages" not in result
assert result["wifi"] == {"ssid": "MySSID"}
@@ -0,0 +1,52 @@
"""Tests for LibreTiny board detection, including renamed-board migration."""
import pytest
from esphome.components.libretiny import _detect_variant
from esphome.components.libretiny.const import (
FAMILY_LN882H,
KEY_COMPONENT_DATA,
KEY_LIBRETINY,
)
from esphome.components.ln882x import COMPONENT_DATA
import esphome.config_validation as cv
from esphome.const import CONF_BOARD, CONF_FAMILY
from esphome.core import CORE
@pytest.fixture
def ln882x_core_data() -> None:
"""Populate CORE the way the ln882x component schema does."""
CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA}
def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None:
"""A current board id resolves its family without warnings."""
result = _detect_variant({CONF_BOARD: "generic-ln882h"})
assert result[CONF_BOARD] == "generic-ln882h"
assert result[CONF_FAMILY] == FAMILY_LN882H
def test_detect_variant_renamed_board_migrates(
ln882x_core_data: None, caplog: pytest.LogCaptureFixture
) -> None:
"""A pre-rename board id validates against the new id, with a warning."""
result = _detect_variant({CONF_BOARD: "generic-ln882hki"})
assert result[CONF_BOARD] == "generic-ln882h"
assert result[CONF_FAMILY] == FAMILY_LN882H
assert "renamed to 'generic-ln882h'" in caplog.text
def test_detect_variant_renamed_board_does_not_mutate_input(
ln882x_core_data: None,
) -> None:
"""Migration copies the config; the caller's dict keeps the old id."""
value = {CONF_BOARD: "generic-ln882hki"}
_detect_variant(value)
assert value[CONF_BOARD] == "generic-ln882hki"
def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None:
"""Ids outside the rename map keep the family-override error."""
with pytest.raises(cv.Invalid, match="This board is unknown"):
_detect_variant({CONF_BOARD: "not-a-real-board"})