From 516ab539e1f81142f5acb234eceaded14c3c7d35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 14:37:23 -1000 Subject: [PATCH 01/20] [web_server] Fix URL collisions with UTF-8 names and sub-devices --- esphome/components/web_server/web_server.cpp | 119 +++++++++++++++--- esphome/components/web_server/web_server.h | 58 ++++++++- .../components/web_server/web_server_v1.cpp | 37 +++++- esphome/config_validation.py | 43 +++++++ esphome/core/config.py | 8 +- esphome/core/entity_base.h | 2 + 6 files changed, 237 insertions(+), 30 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index df8a5364cf..759bb5ecbf 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -53,8 +53,16 @@ static const char *const HEADER_CORS_ALLOW_PNA = "Access-Control-Allow-Private-N #endif // Parse URL and return match info +// URL formats: +// /{domain}/{entity_name} - main device, no method +// /{domain}/{entity_name}/{method} - main device with method +// /{domain}/{device_name}/{entity_name}/{method} - sub-device with method (USE_DEVICES only) static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) { UrlMatch match{}; +#ifdef USE_DEVICES + match.device_name = nullptr; + match.device_name_len = 0; +#endif // URL must start with '/' if (url_len < 2 || url_ptr[0] != '/') { @@ -81,34 +89,73 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) return match; } - // Parse ID if present + // Parse remaining segments if (domain_end + 1 >= end) { return match; // Nothing after domain slash } - const char *id_start = domain_end + 1; - const char *id_end = (const char *) memchr(id_start, '/', end - id_start); + // Find all remaining slashes to count segments + const char *seg1_start = domain_end + 1; + const char *seg1_end = (const char *) memchr(seg1_start, '/', end - seg1_start); - if (!id_end) { - // No more slashes, entire remaining string is ID - match.id = id_start; - match.id_len = end - id_start; + if (!seg1_end) { + // Only 1 segment after domain: /{domain}/{entity_name} + match.id = seg1_start; + match.id_len = end - seg1_start; return match; } - // Set ID - match.id = id_start; - match.id_len = id_end - id_start; + const char *seg2_start = seg1_end + 1; + const char *seg2_end = (seg2_start < end) ? (const char *) memchr(seg2_start, '/', end - seg2_start) : nullptr; - // Parse method if present - if (id_end + 1 < end) { - match.method = id_end + 1; - match.method_len = end - (id_end + 1); + if (!seg2_end) { + // 2 segments after domain: /{domain}/{X}/{Y} + // This is /{domain}/{entity_name}/{method} for main device + match.id = seg1_start; + match.id_len = seg1_end - seg1_start; + match.method = seg2_start; + match.method_len = end - seg2_start; + return match; } +#ifdef USE_DEVICES + // 3+ segments after domain: /{domain}/{device_name}/{entity_name}/{method} + const char *seg3_start = seg2_end + 1; + match.device_name = seg1_start; + match.device_name_len = seg1_end - seg1_start; + match.id = seg2_start; + match.id_len = seg2_end - seg2_start; + if (seg3_start < end) { + match.method = seg3_start; + match.method_len = end - seg3_start; + } +#else + // Without USE_DEVICES, treat extra segments as part of method (backward compat) + match.id = seg1_start; + match.id_len = seg1_end - seg1_start; + match.method = seg2_start; + match.method_len = end - seg2_start; +#endif + return match; } +bool UrlMatch::id_equals_entity(EntityBase *entity) const { + bool used_deprecated_format = false; + bool matches = this->matches_entity(entity, used_deprecated_format); + + if (matches && used_deprecated_format) { + // Log deprecation warning when old object_id URL format is used + ESP_LOGW(TAG, + "Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. " + "Object ID URLs will be removed in a future release.", + this->domain_len, this->domain, this->id_len, this->id, this->domain_len, this->domain, + entity->get_name().c_str()); + } + + return matches; +} + #if !defined(USE_ESP32) && defined(USE_ARDUINO) // helper for allowing only unique entries in the queue void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) { @@ -405,15 +452,47 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { #endif // Helper functions to reduce code size by avoiding macro expansion +// Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name} +// Uses names (not object_id) to avoid UTF-8 collision issues static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) { - char id_buf[160]; // prefix + dash + object_id (up to 128) + null - size_t len = strlen(prefix); - memcpy(id_buf, prefix, len); // NOLINT(bugprone-not-null-terminated-result) - null added by write_object_id_to - id_buf[len++] = '-'; - obj->write_object_id_to(id_buf + len, sizeof(id_buf) - len); + const StringRef &name = obj->get_name(); + size_t prefix_len = strlen(prefix); + size_t name_len = name.size(); + +#ifdef USE_DEVICES + Device *device = obj->get_device(); + const char *device_name = device ? device->get_name() : nullptr; + size_t device_len = device_name ? strlen(device_name) : 0; +#endif + + // Build id into stack buffer - ArduinoJson copies the string + // Format: {prefix}/{device?}/{name} + // Buffer size guaranteed by schema validation: domain(20) + "/" + device(120) + "/" + name(120) + null = 263 + char id_buf[280]; + char *p = id_buf; + memcpy(p, prefix, prefix_len); + p += prefix_len; + *p++ = '/'; +#ifdef USE_DEVICES + if (device_name) { + memcpy(p, device_name, device_len); + p += device_len; + *p++ = '/'; + } +#endif + memcpy(p, name.c_str(), name_len); + p[name_len] = '\0'; + root[ESPHOME_F("id")] = id_buf; + if (start_config == DETAIL_ALL) { - root[ESPHOME_F("name")] = obj->get_name(); + root[ESPHOME_F("domain")] = prefix; + root[ESPHOME_F("name")] = name; +#ifdef USE_DEVICES + if (device_name) { + root[ESPHOME_F("device")] = device_name; + } +#endif root[ESPHOME_F("icon")] = obj->get_icon_ref(); root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 0078146284..c80cce2e2c 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -39,30 +39,76 @@ namespace web_server { /// Internal helper struct that is used to parse incoming URLs struct UrlMatch { const char *domain; ///< Pointer to domain within URL, for example "sensor" - const char *id; ///< Pointer to id within URL, for example "living_room_fan" + const char *id; ///< Pointer to entity name/id within URL, for example "Temperature" const char *method; ///< Pointer to method within URL, for example "turn_on" +#ifdef USE_DEVICES + const char *device_name; ///< Pointer to device name within URL, or nullptr for main device +#endif uint8_t domain_len; ///< Length of domain string uint8_t id_len; ///< Length of id string uint8_t method_len; ///< Length of method string - bool valid; ///< Whether this match is valid +#ifdef USE_DEVICES + uint8_t device_name_len; ///< Length of device name string (0 for main device) +#endif + bool valid; ///< Whether this match is valid // Helper methods for string comparisons bool domain_equals(const char *str) const { return domain && domain_len == strlen(str) && memcmp(domain, str, domain_len) == 0; } - bool id_equals_entity(EntityBase *entity) const { - // Get object_id with zero heap allocation + /// Check if URL id segment matches a string (by pointer and length) + bool id_matches(const char *str, size_t len) const { return id && id_len == len && memcmp(id, str, len) == 0; } + + /// Match entity by name first, then fall back to object_id for backward compatibility + /// Returns true if entity matches. Sets used_deprecated_format to true if matched via object_id. + bool matches_entity(EntityBase *entity, bool &used_deprecated_format) const { + used_deprecated_format = false; + +#ifdef USE_DEVICES + // Check device match first (faster rejection) + Device *entity_device = entity->get_device(); + bool url_has_device = (device_name_len > 0); + bool entity_has_device = (entity_device != nullptr); + + if (url_has_device != entity_has_device) { + return false; // Mismatch: one has device, other doesn't + } + if (url_has_device) { + const char *entity_device_name = entity_device->get_name(); + if (device_name_len != strlen(entity_device_name) || + memcmp(device_name, entity_device_name, device_name_len) != 0) { + return false; // Device name doesn't match + } + } +#endif + + // Try matching by entity name first (new format) + const StringRef &name_ref = entity->get_name(); + if (id_matches(name_ref.c_str(), name_ref.size())) { + return true; + } + + // Fall back to object_id (deprecated format) char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = entity->get_object_id_to(object_id_buf); - return id && id_len == object_id.size() && memcmp(id, object_id.c_str(), id_len) == 0; + if (id_matches(object_id.c_str(), object_id.size())) { + used_deprecated_format = true; + return true; + } + + return false; } + /// Match entity by name first, then fall back to object_id with deprecation warning + bool id_equals_entity(EntityBase *entity) const; + bool method_equals(const char *str) const { return method && method_len == strlen(str) && memcmp(method, str, method_len) == 0; } - bool method_empty() const { return method_len == 0; } + /// Check if method is empty or "state" (for sub-device GET requests) + bool method_empty() const { return method_len == 0 || this->method_equals("state"); } }; #ifdef USE_WEBSERVER_SORTING diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index cbc25b9dec..405f8285b3 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -6,6 +6,29 @@ namespace esphome { namespace web_server { +// Write HTML-escaped text to stream (escapes ", &, <, >) +static void write_html_escaped(AsyncResponseStream *stream, const char *text) { + for (const char *p = text; *p; ++p) { + switch (*p) { + case '"': + stream->print("""); + break; + case '&': + stream->print("&"); + break; + case '<': + stream->print("<"); + break; + case '>': + stream->print(">"); + break; + default: + stream->write(*p); + break; + } + } +} + void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &klass, const std::string &action, const std::function &action_func = nullptr) { stream->print("print("-"); char object_id_buf[OBJECT_ID_MAX_LEN]; stream->print(obj->get_object_id_to(object_id_buf).c_str()); + // Add data attributes for hierarchical URL support + stream->print("\" data-domain=\""); + stream->print(klass.c_str()); + stream->print("\" data-name=\""); + write_html_escaped(stream, obj->get_name().c_str()); +#ifdef USE_DEVICES + Device *device = obj->get_device(); + if (device != nullptr) { + stream->print("\" data-device=\""); + write_html_escaped(stream, device->get_name()); + } +#endif stream->print("\">"); - stream->print(obj->get_name().c_str()); + write_html_escaped(stream, obj->get_name().c_str()); stream->print(""); stream->print(action.c_str()); if (action_func) { diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 08fffa6cec..c674e8feab 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1972,6 +1972,25 @@ MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend( ) +def _validate_no_slash(value): + """Validate that a name does not contain '/' characters. + + The '/' character is used as a path separator in web server URLs, + so it cannot be used in entity or device names. + """ + if "/" in value: + raise Invalid( + f"Name cannot contain '/' character (used as URL path separator): {value}" + ) + return value + + +# Maximum length for entity, device, and area names +# This ensures web server URL IDs fit in a 280-byte buffer: +# domain(20) + "/" + device(120) + "/" + name(120) + null = 263 bytes +NAME_MAX_LENGTH = 120 + + def _validate_entity_name(value): value = string(value) try: @@ -1982,9 +2001,33 @@ def _validate_entity_name(value): requires_friendly_name( "Name cannot be None when esphome->friendly_name is not set!" )(value) + if value is not None: + # Validate length for web server URL compatibility + if len(value) > NAME_MAX_LENGTH: + raise Invalid( + f"Name is too long ({len(value)} chars). " + f"Maximum length is {NAME_MAX_LENGTH} characters." + ) + # Validate no '/' in name for web server URL compatibility + _validate_no_slash(value) return value +def string_no_slash(value): + """Validate a string that cannot contain '/' characters. + + Used for device and area names where '/' is reserved as a URL path separator. + Also enforces maximum length for web server URL compatibility. + """ + value = string(value) + if len(value) > NAME_MAX_LENGTH: + raise Invalid( + f"Name is too long ({len(value)} chars). " + f"Maximum length is {NAME_MAX_LENGTH} characters." + ) + return _validate_no_slash(value) + + ENTITY_BASE_SCHEMA = Schema( { Optional(CONF_NAME): _validate_entity_name, diff --git a/esphome/core/config.py b/esphome/core/config.py index 5e32b9380d..f4cdc976e2 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -186,14 +186,14 @@ else: AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), - cv.Required(CONF_NAME): cv.string, + cv.Required(CONF_NAME): cv.string_no_slash, } ) DEVICE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Device), - cv.Required(CONF_NAME): cv.string, + cv.Required(CONF_NAME): cv.string_no_slash, cv.Optional(CONF_AREA_ID): cv.use_id(Area), } ) @@ -207,7 +207,9 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_NAME): cv.valid_name, - cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(cv.string, cv.Length(max=120)), + cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All( + cv.string_no_slash, cv.Length(max=120) + ), cv.Optional(CONF_AREA): validate_area_config, cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)), cv.Required(CONF_BUILD_PATH): cv.string, diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index eb1ba46c94..f9b7ed1828 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -91,6 +91,8 @@ class EntityBase { return this->device_->get_device_id(); } void set_device(Device *device) { this->device_ = device; } + // Get the device this entity belongs to (nullptr if main device) + Device *get_device() const { return this->device_; } #endif // Check if this entity has state From e468d5afd04b582b532bbf3c3e74845b5bbe94dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 14:46:42 -1000 Subject: [PATCH 02/20] cover --- tests/unit_tests/test_config_validation.py | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index c9d7b7486e..b0252bebc4 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -502,3 +502,48 @@ def test_only_with_user_value_overrides_default() -> None: result = schema({"mqtt_id": "custom_id"}) assert result.get("mqtt_id") == "custom_id" + + +@pytest.mark.parametrize("value", ("hello", "Hello World", "test_name", "温度")) +def test_string_no_slash__valid(value: str) -> None: + actual = config_validation.string_no_slash(value) + assert actual == value + + +@pytest.mark.parametrize("value", ("has/slash", "a/b/c", "/leading", "trailing/")) +def test_string_no_slash__slash_rejected(value: str) -> None: + with pytest.raises(Invalid, match="cannot contain '/' character"): + config_validation.string_no_slash(value) + + +def test_string_no_slash__max_length() -> None: + # 120 chars should pass + assert config_validation.string_no_slash("x" * 120) == "x" * 120 + + # 121 chars should fail + with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"): + config_validation.string_no_slash("x" * 121) + + +def test_string_no_slash__empty() -> None: + assert config_validation.string_no_slash("") == "" + + +@pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器")) +def test_validate_entity_name__valid(value: str) -> None: + actual = config_validation._validate_entity_name(value) + assert actual == value + + +def test_validate_entity_name__slash_rejected() -> None: + with pytest.raises(Invalid, match="cannot contain '/' character"): + config_validation._validate_entity_name("has/slash") + + +def test_validate_entity_name__max_length() -> None: + # 120 chars should pass + assert config_validation._validate_entity_name("x" * 120) == "x" * 120 + + # 121 chars should fail + with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"): + config_validation._validate_entity_name("x" * 121) From 57afa8be6a109fa9294ab1e1d64bf9c17fbb9cd2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 14:48:33 -1000 Subject: [PATCH 03/20] tweaks --- esphome/components/web_server/web_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 759bb5ecbf..8838616e83 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -148,7 +148,7 @@ bool UrlMatch::id_equals_entity(EntityBase *entity) const { // Log deprecation warning when old object_id URL format is used ESP_LOGW(TAG, "Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. " - "Object ID URLs will be removed in a future release.", + "Object ID URLs will be removed in 2026.7.0.", this->domain_len, this->domain, this->id_len, this->id, this->domain_len, this->domain, entity->get_name().c_str()); } From 2f3ee0e15a0157ef4fc84104c9b3fc6102e3e953 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 15:06:53 -1000 Subject: [PATCH 04/20] tweaks --- esphome/config_validation.py | 7 +------ esphome/core/config.py | 4 ++-- tests/unit_tests/test_config_validation.py | 11 ++++------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index c674e8feab..a4b4b3c90d 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2017,14 +2017,9 @@ def string_no_slash(value): """Validate a string that cannot contain '/' characters. Used for device and area names where '/' is reserved as a URL path separator. - Also enforces maximum length for web server URL compatibility. + Use with cv.Length() to also enforce maximum length. """ value = string(value) - if len(value) > NAME_MAX_LENGTH: - raise Invalid( - f"Name is too long ({len(value)} chars). " - f"Maximum length is {NAME_MAX_LENGTH} characters." - ) return _validate_no_slash(value) diff --git a/esphome/core/config.py b/esphome/core/config.py index f4cdc976e2..f9c3011507 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -186,14 +186,14 @@ else: AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), - cv.Required(CONF_NAME): cv.string_no_slash, + cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)), } ) DEVICE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Device), - cv.Required(CONF_NAME): cv.string_no_slash, + cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)), cv.Optional(CONF_AREA_ID): cv.use_id(Area), } ) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index b0252bebc4..f157eec2c2 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -516,13 +516,10 @@ def test_string_no_slash__slash_rejected(value: str) -> None: config_validation.string_no_slash(value) -def test_string_no_slash__max_length() -> None: - # 120 chars should pass - assert config_validation.string_no_slash("x" * 120) == "x" * 120 - - # 121 chars should fail - with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"): - config_validation.string_no_slash("x" * 121) +def test_string_no_slash__long_string_allowed() -> None: + # string_no_slash doesn't enforce length - use cv.Length() separately + long_value = "x" * 200 + assert config_validation.string_no_slash(long_value) == long_value def test_string_no_slash__empty() -> None: From 48eaaa8be0e306d3ce5253a8194a9d6f8a275e1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 16:34:54 -1000 Subject: [PATCH 05/20] fix decode on idf --- esphome/components/web_server_idf/utils.cpp | 6 ++++-- esphome/components/web_server_idf/utils.h | 4 ++++ .../components/web_server_idf/web_server_idf.cpp | 15 +++++++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/esphome/components/web_server_idf/utils.cpp b/esphome/components/web_server_idf/utils.cpp index d5d34b520b..f27814062c 100644 --- a/esphome/components/web_server_idf/utils.cpp +++ b/esphome/components/web_server_idf/utils.cpp @@ -13,7 +13,8 @@ namespace web_server_idf { static const char *const TAG = "web_server_idf_utils"; -void url_decode(char *str) { +size_t url_decode(char *str) { + char *start = str; char *ptr = str, buf; for (; *str; str++, ptr++) { if (*str == '%') { @@ -31,7 +32,8 @@ void url_decode(char *str) { *ptr = *str; } } - *ptr = *str; + *ptr = '\0'; + return ptr - start; } bool request_has_header(httpd_req_t *req, const char *name) { return httpd_req_get_hdr_value_len(req, name); } diff --git a/esphome/components/web_server_idf/utils.h b/esphome/components/web_server_idf/utils.h index f70a5f0760..3a86aec7ac 100644 --- a/esphome/components/web_server_idf/utils.h +++ b/esphome/components/web_server_idf/utils.h @@ -8,6 +8,10 @@ namespace esphome { namespace web_server_idf { +/// Decode URL-encoded string in-place (e.g., %20 -> space, + -> space) +/// Returns the new length of the decoded string +size_t url_decode(char *str); + bool request_has_header(httpd_req_t *req, const char *name); optional request_get_header(httpd_req_t *req, const char *name); optional request_get_url_query(httpd_req_t *req); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 3d76b86a14..cb6c86f7eb 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -247,11 +247,18 @@ optional AsyncWebServerRequest::get_header(const char *name) const } std::string AsyncWebServerRequest::url() const { - auto *str = strchr(this->req_->uri, '?'); - if (str == nullptr) { - return this->req_->uri; + auto *query_start = strchr(this->req_->uri, '?'); + std::string result; + if (query_start == nullptr) { + result = this->req_->uri; + } else { + result = std::string(this->req_->uri, query_start - this->req_->uri); } - return std::string(this->req_->uri, str - this->req_->uri); + // Decode URL-encoded characters in-place (e.g., %20 -> space) + // This matches AsyncWebServer behavior on Arduino + size_t new_len = url_decode(&result[0]); + result.resize(new_len); + return result; } std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); } From af5ea0297861739a7e76ed750a965a0c280b7179 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 16:36:23 -1000 Subject: [PATCH 06/20] fix decode on idf --- esphome/components/web_server/web_server.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 8838616e83..1f9b4e0a35 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -467,8 +467,14 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J // Build id into stack buffer - ArduinoJson copies the string // Format: {prefix}/{device?}/{name} - // Buffer size guaranteed by schema validation: domain(20) + "/" + device(120) + "/" + name(120) + null = 263 + // Buffer size guaranteed by schema validation: + // With devices: domain(20) + "/" + device(120) + "/" + name(120) + null = 263 + // Without devices: domain(20) + "/" + name(120) + null = 142 +#ifdef USE_DEVICES char id_buf[280]; +#else + char id_buf[150]; +#endif char *p = id_buf; memcpy(p, prefix, prefix_len); p += prefix_len; From b51d9622cb2d82d71fe363b6465eb6ef19945207 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 16:38:24 -1000 Subject: [PATCH 07/20] edge cases --- esphome/components/web_server_idf/web_server_idf.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index cb6c86f7eb..5062aa1e6c 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -256,8 +256,10 @@ std::string AsyncWebServerRequest::url() const { } // Decode URL-encoded characters in-place (e.g., %20 -> space) // This matches AsyncWebServer behavior on Arduino - size_t new_len = url_decode(&result[0]); - result.resize(new_len); + if (!result.empty()) { + size_t new_len = url_decode(&result[0]); + result.resize(new_len); + } return result; } From c48ebe1ebbce94a834dfe355c538efdf5138e818 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 16:41:45 -1000 Subject: [PATCH 08/20] v1 --- esphome/components/web_server/web_server_v1.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index 405f8285b3..4c210f83e7 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -53,6 +53,13 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string & } #endif stream->print("\">"); +#ifdef USE_DEVICES + if (device != nullptr) { + stream->print("["); + write_html_escaped(stream, device->get_name()); + stream->print("] "); + } +#endif write_html_escaped(stream, obj->get_name().c_str()); stream->print(""); stream->print(action.c_str()); From 748a6d79a323d8544952da1766824cb4e18749e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 16:56:35 -1000 Subject: [PATCH 09/20] provide sub device url for dep --- esphome/components/web_server/web_server.cpp | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1f9b4e0a35..67c72fec06 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -146,11 +146,23 @@ bool UrlMatch::id_equals_entity(EntityBase *entity) const { if (matches && used_deprecated_format) { // Log deprecation warning when old object_id URL format is used - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - this->domain_len, this->domain, this->id_len, this->id, this->domain_len, this->domain, - entity->get_name().c_str()); +#ifdef USE_DEVICES + Device *device = entity->get_device(); + if (device != nullptr) { + ESP_LOGW(TAG, + "Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. " + "Object ID URLs will be removed in 2026.7.0.", + this->domain_len, this->domain, this->device_name_len, this->device_name, this->id_len, this->id, + this->domain_len, this->domain, device->get_name(), entity->get_name().c_str()); + } else +#endif + { + ESP_LOGW(TAG, + "Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. " + "Object ID URLs will be removed in 2026.7.0.", + this->domain_len, this->domain, this->id_len, this->id, this->domain_len, this->domain, + entity->get_name().c_str()); + } } return matches; From 95428824642980046da54bd81c32f8003f98e3c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 16:57:31 -1000 Subject: [PATCH 10/20] document state --- esphome/components/web_server/web_server.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index c80cce2e2c..c7c0488a15 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -107,7 +107,9 @@ struct UrlMatch { return method && method_len == strlen(str) && memcmp(method, str, method_len) == 0; } - /// Check if method is empty or "state" (for sub-device GET requests) + /// Check if method is empty or "state" (for backward compatibility with old frontends) + /// Old frontends send /{domain}/{entity}/state for GET requests, so "state" is treated as no action. + /// This is safe because no ESPHome entity type has a "state" action - actions are like "turn_on", "toggle", etc. bool method_empty() const { return method_len == 0 || this->method_equals("state"); } }; From 3610c96177e58a359f138982f21d8ad99b4e06fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 16:58:31 -1000 Subject: [PATCH 11/20] document buffer size --- esphome/components/web_server/web_server.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 67c72fec06..3e03ea84f0 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -479,9 +479,9 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J // Build id into stack buffer - ArduinoJson copies the string // Format: {prefix}/{device?}/{name} - // Buffer size guaranteed by schema validation: - // With devices: domain(20) + "/" + device(120) + "/" + name(120) + null = 263 - // Without devices: domain(20) + "/" + name(120) + null = 142 + // Buffer size guaranteed by schema validation (NAME_MAX_LENGTH=120): + // With devices: domain(20) + "/" + device(120) + "/" + name(120) + null = 263, rounded up to 280 for safety margin + // Without devices: domain(20) + "/" + name(120) + null = 142, rounded up to 150 for safety margin #ifdef USE_DEVICES char id_buf[280]; #else From e5936e92a12883f1d420ce7c25d8f203f5197bf4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 16:59:00 -1000 Subject: [PATCH 12/20] handle no method segment --- esphome/components/web_server/web_server.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3e03ea84f0..456f6e6e8a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -128,6 +128,10 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) if (seg3_start < end) { match.method = seg3_start; match.method_len = end - seg3_start; + } else { + // No method segment - fields already zero-initialized by UrlMatch{} + match.method = nullptr; + match.method_len = 0; } #else // Without USE_DEVICES, treat extra segments as part of method (backward compat) From 91d52710e48c413943154d91e7ab18a53842f57d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 17:00:01 -1000 Subject: [PATCH 13/20] cross doc max name length --- esphome/components/web_server/web_server.h | 5 +++-- esphome/config_validation.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index c7c0488a15..3c285d4df4 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -37,6 +37,7 @@ namespace esphome { namespace web_server { /// Internal helper struct that is used to parse incoming URLs +/// Note: Length fields use uint8_t, so NAME_MAX_LENGTH in config_validation.py must stay < 255 struct UrlMatch { const char *domain; ///< Pointer to domain within URL, for example "sensor" const char *id; ///< Pointer to entity name/id within URL, for example "Temperature" @@ -45,10 +46,10 @@ struct UrlMatch { const char *device_name; ///< Pointer to device name within URL, or nullptr for main device #endif uint8_t domain_len; ///< Length of domain string - uint8_t id_len; ///< Length of id string + uint8_t id_len; ///< Length of id string (NAME_MAX_LENGTH must be < 255) uint8_t method_len; ///< Length of method string #ifdef USE_DEVICES - uint8_t device_name_len; ///< Length of device name string (0 for main device) + uint8_t device_name_len; ///< Length of device name string (NAME_MAX_LENGTH must be < 255) #endif bool valid; ///< Whether this match is valid diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a4b4b3c90d..2dcf9d1c6a 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1988,6 +1988,7 @@ def _validate_no_slash(value): # Maximum length for entity, device, and area names # This ensures web server URL IDs fit in a 280-byte buffer: # domain(20) + "/" + device(120) + "/" + name(120) + null = 263 bytes +# Note: Must be < 255 because web_server UrlMatch uses uint8_t for length fields NAME_MAX_LENGTH = 120 From 16ba98d2135a103ea9903442a941729e61c2f1c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 17:00:47 -1000 Subject: [PATCH 14/20] missing cover --- tests/unit_tests/test_config_validation.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index f157eec2c2..7e4e4fd031 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -544,3 +544,17 @@ def test_validate_entity_name__max_length() -> None: # 121 chars should fail with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"): config_validation._validate_entity_name("x" * 121) + + +def test_validate_entity_name__none_without_friendly_name() -> None: + # When name is "None" and friendly_name is not set, it should fail + CORE.config = {} # No friendly_name set + with pytest.raises(Invalid, match="friendly_name is not set"): + config_validation._validate_entity_name("None") + + +def test_validate_entity_name__none_with_friendly_name() -> None: + # When name is "None" but friendly_name is set, it should return None + CORE.config = {"esphome": {"friendly_name": "My Device"}} + result = config_validation._validate_entity_name("None") + assert result is None From 174b68712ecce592e15adb69e0d5f95cd0327b73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 17:01:16 -1000 Subject: [PATCH 15/20] missing cover --- tests/unit_tests/test_config_validation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 7e4e4fd031..94224f2364 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -548,13 +548,14 @@ def test_validate_entity_name__max_length() -> None: def test_validate_entity_name__none_without_friendly_name() -> None: # When name is "None" and friendly_name is not set, it should fail - CORE.config = {} # No friendly_name set + CORE.friendly_name = None with pytest.raises(Invalid, match="friendly_name is not set"): config_validation._validate_entity_name("None") def test_validate_entity_name__none_with_friendly_name() -> None: # When name is "None" but friendly_name is set, it should return None - CORE.config = {"esphome": {"friendly_name": "My Device"}} + CORE.friendly_name = "My Device" result = config_validation._validate_entity_name("None") assert result is None + CORE.friendly_name = None # Reset From 31f4e0ee48bbb7a7983c0fe89122ec0f588e0018 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 17:02:59 -1000 Subject: [PATCH 16/20] reject empty segments --- esphome/components/web_server/web_server.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 456f6e6e8a..c925a11322 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -102,6 +102,10 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) // Only 1 segment after domain: /{domain}/{entity_name} match.id = seg1_start; match.id_len = end - seg1_start; + // Reject empty segment (e.g., "/sensor/") + if (match.id_len == 0) { + return UrlMatch{}; + } return match; } @@ -115,6 +119,10 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) match.id_len = seg1_end - seg1_start; match.method = seg2_start; match.method_len = end - seg2_start; + // Reject empty segments (e.g., "/sensor//turn_on" or "/sensor/temp/") + if (match.id_len == 0 || match.method_len == 0) { + return UrlMatch{}; + } return match; } @@ -133,12 +141,22 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) match.method = nullptr; match.method_len = 0; } + + // Reject empty segments (e.g., "/sensor//entity/turn_on" or "/sensor/device//turn_on") + if (match.device_name_len == 0 || match.id_len == 0 || (match.method != nullptr && match.method_len == 0)) { + return UrlMatch{}; + } #else // Without USE_DEVICES, treat extra segments as part of method (backward compat) match.id = seg1_start; match.id_len = seg1_end - seg1_start; match.method = seg2_start; match.method_len = end - seg2_start; + + // Reject empty segments + if (match.id_len == 0 || match.method_len == 0) { + return UrlMatch{}; + } #endif return match; From 30cb8336f2e468dd55036741842c1b8c66120fe8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 17:05:39 -1000 Subject: [PATCH 17/20] we did not end up needing state --- esphome/components/web_server/web_server.h | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 3c285d4df4..7981801bc7 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -108,10 +108,7 @@ struct UrlMatch { return method && method_len == strlen(str) && memcmp(method, str, method_len) == 0; } - /// Check if method is empty or "state" (for backward compatibility with old frontends) - /// Old frontends send /{domain}/{entity}/state for GET requests, so "state" is treated as no action. - /// This is safe because no ESPHome entity type has a "state" action - actions are like "turn_on", "toggle", etc. - bool method_empty() const { return method_len == 0 || this->method_equals("state"); } + bool method_empty() const { return method_len == 0; } }; #ifdef USE_WEBSERVER_SORTING From d1ff959f4c1840e1aacc59fb7e205af60fa6cf15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 17:13:03 -1000 Subject: [PATCH 18/20] two segment sub dev --- esphome/components/web_server/web_server.cpp | 1 + esphome/components/web_server/web_server.h | 34 +++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index c925a11322..170b621d89 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -62,6 +62,7 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) #ifdef USE_DEVICES match.device_name = nullptr; match.device_name_len = 0; + match.matched_as_two_segment_subdevice = false; #endif // URL must start with '/' diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 7981801bc7..78d19fdb7e 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -50,6 +50,7 @@ struct UrlMatch { uint8_t method_len; ///< Length of method string #ifdef USE_DEVICES uint8_t device_name_len; ///< Length of device name string (NAME_MAX_LENGTH must be < 255) + mutable bool matched_as_two_segment_subdevice{false}; ///< Set when 2-segment URL matched as device/entity #endif bool valid; ///< Whether this match is valid @@ -67,20 +68,36 @@ struct UrlMatch { used_deprecated_format = false; #ifdef USE_DEVICES - // Check device match first (faster rejection) + // Check device match Device *entity_device = entity->get_device(); bool url_has_device = (device_name_len > 0); bool entity_has_device = (entity_device != nullptr); - if (url_has_device != entity_has_device) { - return false; // Mismatch: one has device, other doesn't - } if (url_has_device) { + // URL has explicit device segment - must match + if (!entity_has_device) { + return false; // URL has device but entity doesn't + } const char *entity_device_name = entity_device->get_name(); if (device_name_len != strlen(entity_device_name) || memcmp(device_name, entity_device_name, device_name_len) != 0) { return false; // Device name doesn't match } + } else if (entity_has_device && method_len > 0) { + // URL has 2 segments (id/method), entity has device + // Try interpreting as device/entity: id=device_name, method=entity_name + const char *entity_device_name = entity_device->get_name(); + if (id_len == strlen(entity_device_name) && memcmp(id, entity_device_name, id_len) == 0) { + // Device name matches, check if method matches entity name + const StringRef &name_ref = entity->get_name(); + if (method_len == name_ref.size() && memcmp(method, name_ref.c_str(), method_len) == 0) { + matched_as_two_segment_subdevice = true; // Mark for method_empty() check + return true; // Matched as device/entity (2-segment sub-device URL) + } + } + return false; // Entity has device but URL doesn't match as device/entity + } else if (entity_has_device) { + return false; // Entity has device but URL has no device info } #endif @@ -108,7 +125,14 @@ struct UrlMatch { return method && method_len == strlen(str) && memcmp(method, str, method_len) == 0; } - bool method_empty() const { return method_len == 0; } + bool method_empty() const { +#ifdef USE_DEVICES + // For 2-segment sub-device URLs, method field contains entity name, not actual method + if (matched_as_two_segment_subdevice) + return true; +#endif + return method_len == 0; + } }; #ifdef USE_WEBSERVER_SORTING From ddd43f466a75cce0f990ba0ddea4020a2eefe713 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 17:22:14 -1000 Subject: [PATCH 19/20] refactor to reduce complexity --- esphome/components/web_server/web_server.cpp | 153 +++++++++++++------ esphome/components/web_server/web_server.h | 75 +-------- 2 files changed, 114 insertions(+), 114 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 170b621d89..8213c035ba 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -62,7 +62,6 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) #ifdef USE_DEVICES match.device_name = nullptr; match.device_name_len = 0; - match.matched_as_two_segment_subdevice = false; #endif // URL must start with '/' @@ -163,12 +162,52 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) return match; } -bool UrlMatch::id_equals_entity(EntityBase *entity) const { - bool used_deprecated_format = false; - bool matches = this->matches_entity(entity, used_deprecated_format); +EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { + EntityMatchResult result{false, this->method_len == 0}; - if (matches && used_deprecated_format) { - // Log deprecation warning when old object_id URL format is used +#ifdef USE_DEVICES + Device *entity_device = entity->get_device(); + bool url_has_device = (this->device_name_len > 0); + bool entity_has_device = (entity_device != nullptr); + + if (url_has_device) { + // URL has explicit device segment (3+ segments) - must match device + if (!entity_has_device) + return result; + const char *entity_device_name = entity_device->get_name(); + if (this->device_name_len != strlen(entity_device_name) || + memcmp(this->device_name, entity_device_name, this->device_name_len) != 0) + return result; + } else if (entity_has_device) { + // Entity has device but URL has only 2 segments (id/method) + // Try interpreting as device/entity: id=device_name, method=entity_name + if (this->method_len == 0) + return result; // Need 2 segments for this interpretation + const char *entity_device_name = entity_device->get_name(); + if (this->id_len == strlen(entity_device_name) && memcmp(this->id, entity_device_name, this->id_len) == 0) { + const StringRef &name_ref = entity->get_name(); + if (this->method_len == name_ref.size() && memcmp(this->method, name_ref.c_str(), this->method_len) == 0) { + // Matched: id=device, method=entity_name, so method is effectively empty + return {true, true}; + } + } + return result; // No match + } +#endif + + // Try matching by entity name (new format) + const StringRef &name_ref = entity->get_name(); + if (this->id_matches(name_ref.c_str(), name_ref.size())) { + result.matched = true; + return result; + } + + // Fall back to object_id (deprecated format) + char object_id_buf[OBJECT_ID_MAX_LEN]; + StringRef object_id = entity->get_object_id_to(object_id_buf); + if (this->id_matches(object_id.c_str(), object_id.size())) { + result.matched = true; + // Log deprecation warning #ifdef USE_DEVICES Device *device = entity->get_device(); if (device != nullptr) { @@ -188,7 +227,7 @@ bool UrlMatch::id_equals_entity(EntityBase *entity) const { } } - return matches; + return result; } #if !defined(USE_ESP32) && defined(USE_ARDUINO) @@ -572,10 +611,11 @@ void WebServer::on_sensor_update(sensor::Sensor *obj) { } void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (sensor::Sensor *obj : App.get_sensors()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; // Note: request->method() is always HTTP_GET here (canHandle ensures this) - if (match.method_empty()) { + if (entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->sensor_json_(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -618,10 +658,11 @@ void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj) { } void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (text_sensor::TextSensor *obj : App.get_text_sensors()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; // Note: request->method() is always HTTP_GET here (canHandle ensures this) - if (match.method_empty()) { + if (entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->text_sensor_json_(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -660,10 +701,11 @@ void WebServer::on_switch_update(switch_::Switch *obj) { } void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (switch_::Switch *obj : App.get_switches()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->switch_json_(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -729,9 +771,10 @@ std::string WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail #ifdef USE_BUTTON void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (button::Button *obj : App.get_buttons()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->button_json_(obj, detail); request->send(200, "application/json", data.c_str()); @@ -773,10 +816,11 @@ void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) { } void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; // Note: request->method() is always HTTP_GET here (canHandle ensures this) - if (match.method_empty()) { + if (entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->binary_sensor_json_(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -814,10 +858,11 @@ void WebServer::on_fan_update(fan::Fan *obj) { } void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (fan::Fan *obj : App.get_fans()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->fan_json_(obj, detail); request->send(200, "application/json", data.c_str()); @@ -894,10 +939,11 @@ void WebServer::on_light_update(light::LightState *obj) { } void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (light::LightState *obj : App.get_lights()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->light_json_(obj, detail); request->send(200, "application/json", data.c_str()); @@ -972,10 +1018,11 @@ void WebServer::on_cover_update(cover::Cover *obj) { } void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (cover::Cover *obj : App.get_covers()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->cover_json_(obj, detail); request->send(200, "application/json", data.c_str()); @@ -1060,10 +1107,11 @@ void WebServer::on_number_update(number::Number *obj) { } void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_numbers()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->number_json_(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -1127,9 +1175,10 @@ void WebServer::on_date_update(datetime::DateEntity *obj) { } void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_dates()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->date_json_(obj, detail); request->send(200, "application/json", data.c_str()); @@ -1190,9 +1239,10 @@ void WebServer::on_time_update(datetime::TimeEntity *obj) { } void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_times()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->time_json_(obj, detail); request->send(200, "application/json", data.c_str()); @@ -1252,9 +1302,10 @@ void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) { } void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_datetimes()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->datetime_json_(obj, detail); request->send(200, "application/json", data.c_str()); @@ -1316,10 +1367,11 @@ void WebServer::on_text_update(text::Text *obj) { } void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_texts()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->text_json_(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -1372,10 +1424,11 @@ void WebServer::on_select_update(select::Select *obj) { } void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_selects()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->select_json_(obj, obj->has_state() ? obj->current_option() : "", detail); request->send(200, "application/json", data.c_str()); @@ -1429,10 +1482,11 @@ void WebServer::on_climate_update(climate::Climate *obj) { } void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_climates()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->climate_json_(obj, detail); request->send(200, "application/json", data.c_str()); @@ -1579,10 +1633,11 @@ void WebServer::on_lock_update(lock::Lock *obj) { } void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (lock::Lock *obj : App.get_locks()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->lock_json_(obj, obj->state, detail); request->send(200, "application/json", data.c_str()); @@ -1653,10 +1708,11 @@ void WebServer::on_valve_update(valve::Valve *obj) { } void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (valve::Valve *obj : App.get_valves()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->valve_json_(obj, detail); request->send(200, "application/json", data.c_str()); @@ -1737,10 +1793,11 @@ void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlP } void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->alarm_control_panel_json_(obj, obj->get_state(), detail); request->send(200, "application/json", data.c_str()); @@ -1818,11 +1875,12 @@ void WebServer::on_event(event::Event *obj) { void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (event::Event *obj : App.get_events()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; // Note: request->method() is always HTTP_GET here (canHandle ensures this) - if (match.method_empty()) { + if (entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->event_json_(obj, "", detail); request->send(200, "application/json", data.c_str()); @@ -1887,10 +1945,11 @@ void WebServer::on_update(update::UpdateEntity *obj) { } void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (update::UpdateEntity *obj : App.get_updates()) { - if (!match.id_equals_entity(obj)) + auto entity_match = match.match_entity(obj); + if (!entity_match.matched) continue; - if (request->method() == HTTP_GET && match.method_empty()) { + if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); std::string data = this->update_json_(obj, detail); request->send(200, "application/json", data.c_str()); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 78d19fdb7e..b443419351 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -36,6 +36,12 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE; namespace esphome { namespace web_server { +/// Result of matching a URL against an entity +struct EntityMatchResult { + bool matched; ///< True if entity matched the URL + bool action_is_empty; ///< True if no action in URL (or action field was used as entity name for 2-seg subdevice) +}; + /// Internal helper struct that is used to parse incoming URLs /// Note: Length fields use uint8_t, so NAME_MAX_LENGTH in config_validation.py must stay < 255 struct UrlMatch { @@ -50,7 +56,6 @@ struct UrlMatch { uint8_t method_len; ///< Length of method string #ifdef USE_DEVICES uint8_t device_name_len; ///< Length of device name string (NAME_MAX_LENGTH must be < 255) - mutable bool matched_as_two_segment_subdevice{false}; ///< Set when 2-segment URL matched as device/entity #endif bool valid; ///< Whether this match is valid @@ -62,77 +67,13 @@ struct UrlMatch { /// Check if URL id segment matches a string (by pointer and length) bool id_matches(const char *str, size_t len) const { return id && id_len == len && memcmp(id, str, len) == 0; } - /// Match entity by name first, then fall back to object_id for backward compatibility - /// Returns true if entity matches. Sets used_deprecated_format to true if matched via object_id. - bool matches_entity(EntityBase *entity, bool &used_deprecated_format) const { - used_deprecated_format = false; - -#ifdef USE_DEVICES - // Check device match - Device *entity_device = entity->get_device(); - bool url_has_device = (device_name_len > 0); - bool entity_has_device = (entity_device != nullptr); - - if (url_has_device) { - // URL has explicit device segment - must match - if (!entity_has_device) { - return false; // URL has device but entity doesn't - } - const char *entity_device_name = entity_device->get_name(); - if (device_name_len != strlen(entity_device_name) || - memcmp(device_name, entity_device_name, device_name_len) != 0) { - return false; // Device name doesn't match - } - } else if (entity_has_device && method_len > 0) { - // URL has 2 segments (id/method), entity has device - // Try interpreting as device/entity: id=device_name, method=entity_name - const char *entity_device_name = entity_device->get_name(); - if (id_len == strlen(entity_device_name) && memcmp(id, entity_device_name, id_len) == 0) { - // Device name matches, check if method matches entity name - const StringRef &name_ref = entity->get_name(); - if (method_len == name_ref.size() && memcmp(method, name_ref.c_str(), method_len) == 0) { - matched_as_two_segment_subdevice = true; // Mark for method_empty() check - return true; // Matched as device/entity (2-segment sub-device URL) - } - } - return false; // Entity has device but URL doesn't match as device/entity - } else if (entity_has_device) { - return false; // Entity has device but URL has no device info - } -#endif - - // Try matching by entity name first (new format) - const StringRef &name_ref = entity->get_name(); - if (id_matches(name_ref.c_str(), name_ref.size())) { - return true; - } - - // Fall back to object_id (deprecated format) - char object_id_buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = entity->get_object_id_to(object_id_buf); - if (id_matches(object_id.c_str(), object_id.size())) { - used_deprecated_format = true; - return true; - } - - return false; - } - /// Match entity by name first, then fall back to object_id with deprecation warning - bool id_equals_entity(EntityBase *entity) const; + /// Returns EntityMatchResult with match status and whether method is effectively empty + EntityMatchResult match_entity(EntityBase *entity) const; bool method_equals(const char *str) const { return method && method_len == strlen(str) && memcmp(method, str, method_len) == 0; } - - bool method_empty() const { -#ifdef USE_DEVICES - // For 2-segment sub-device URLs, method field contains entity name, not actual method - if (matched_as_two_segment_subdevice) - return true; -#endif - return method_len == 0; - } }; #ifdef USE_WEBSERVER_SORTING From fb3d287267f2e706eee144950b9c5f9df42661f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 17:33:34 -1000 Subject: [PATCH 20/20] just reject 3+ with no devices --- esphome/components/web_server/web_server.cpp | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 8213c035ba..6159be9a14 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -147,16 +147,8 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) return UrlMatch{}; } #else - // Without USE_DEVICES, treat extra segments as part of method (backward compat) - match.id = seg1_start; - match.id_len = seg1_end - seg1_start; - match.method = seg2_start; - match.method_len = end - seg2_start; - - // Reject empty segments - if (match.id_len == 0 || match.method_len == 0) { - return UrlMatch{}; - } + // Without USE_DEVICES, reject URLs with 3+ segments (device paths not supported) + return UrlMatch{}; #endif return match;