mirror of
https://github.com/esphome/esphome.git
synced 2026-05-28 21:59:59 +08:00
[web_server] Fix URL collisions with UTF-8 names and sub-devices (#12627)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -35,33 +35,29 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE;
|
|||||||
|
|
||||||
namespace esphome::web_server {
|
namespace esphome::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/method segment in URL
|
||||||
|
};
|
||||||
|
|
||||||
/// Internal helper struct that is used to parse incoming URLs
|
/// Internal helper struct that is used to parse incoming URLs
|
||||||
struct UrlMatch {
|
struct UrlMatch {
|
||||||
const char *domain; ///< Pointer to domain within URL, for example "sensor"
|
StringRef domain; ///< Domain within URL, for example "sensor"
|
||||||
const char *id; ///< Pointer to id within URL, for example "living_room_fan"
|
StringRef id; ///< Entity name/id within URL, for example "Temperature"
|
||||||
const char *method; ///< Pointer to method within URL, for example "turn_on"
|
StringRef method; ///< Method within URL, for example "turn_on"
|
||||||
uint8_t domain_len; ///< Length of domain string
|
#ifdef USE_DEVICES
|
||||||
uint8_t id_len; ///< Length of id string
|
StringRef device_name; ///< Device name within URL, empty for main device
|
||||||
uint8_t method_len; ///< Length of method string
|
#endif
|
||||||
bool valid; ///< Whether this match is valid
|
bool valid{false}; ///< Whether this match is valid
|
||||||
|
|
||||||
// Helper methods for string comparisons
|
// Helper methods for string comparisons
|
||||||
bool domain_equals(const char *str) const {
|
bool domain_equals(const char *str) const { return this->domain == str; }
|
||||||
return domain && domain_len == strlen(str) && memcmp(domain, str, domain_len) == 0;
|
bool method_equals(const char *str) const { return this->method == str; }
|
||||||
}
|
|
||||||
|
|
||||||
bool id_equals_entity(EntityBase *entity) const {
|
/// Match entity by name first, then fall back to object_id with deprecation warning
|
||||||
// Get object_id with zero heap allocation
|
/// Returns EntityMatchResult with match status and whether action segment is empty
|
||||||
char object_id_buf[OBJECT_ID_MAX_LEN];
|
EntityMatchResult match_entity(EntityBase *entity) const;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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; }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#ifdef USE_WEBSERVER_SORTING
|
#ifdef USE_WEBSERVER_SORTING
|
||||||
|
|||||||
@@ -5,6 +5,29 @@
|
|||||||
|
|
||||||
namespace esphome::web_server {
|
namespace esphome::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,
|
void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &klass, const std::string &action,
|
||||||
const std::function<void(AsyncResponseStream &stream, EntityBase *obj)> &action_func = nullptr) {
|
const std::function<void(AsyncResponseStream &stream, EntityBase *obj)> &action_func = nullptr) {
|
||||||
stream->print("<tr class=\"");
|
stream->print("<tr class=\"");
|
||||||
@@ -16,8 +39,27 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &
|
|||||||
stream->print("-");
|
stream->print("-");
|
||||||
char object_id_buf[OBJECT_ID_MAX_LEN];
|
char object_id_buf[OBJECT_ID_MAX_LEN];
|
||||||
stream->print(obj->get_object_id_to(object_id_buf).c_str());
|
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("\"><td>");
|
stream->print("\"><td>");
|
||||||
stream->print(obj->get_name().c_str());
|
#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("</td><td></td><td>");
|
stream->print("</td><td></td><td>");
|
||||||
stream->print(action.c_str());
|
stream->print(action.c_str());
|
||||||
if (action_func) {
|
if (action_func) {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ namespace web_server_idf {
|
|||||||
|
|
||||||
static const char *const TAG = "web_server_idf_utils";
|
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;
|
char *ptr = str, buf;
|
||||||
for (; *str; str++, ptr++) {
|
for (; *str; str++, ptr++) {
|
||||||
if (*str == '%') {
|
if (*str == '%') {
|
||||||
@@ -31,7 +32,8 @@ void url_decode(char *str) {
|
|||||||
*ptr = *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); }
|
bool request_has_header(httpd_req_t *req, const char *name) { return httpd_req_get_hdr_value_len(req, name); }
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
namespace esphome {
|
namespace esphome {
|
||||||
namespace web_server_idf {
|
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);
|
bool request_has_header(httpd_req_t *req, const char *name);
|
||||||
optional<std::string> request_get_header(httpd_req_t *req, const char *name);
|
optional<std::string> request_get_header(httpd_req_t *req, const char *name);
|
||||||
optional<std::string> request_get_url_query(httpd_req_t *req);
|
optional<std::string> request_get_url_query(httpd_req_t *req);
|
||||||
|
|||||||
@@ -247,11 +247,20 @@ optional<std::string> AsyncWebServerRequest::get_header(const char *name) const
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string AsyncWebServerRequest::url() const {
|
std::string AsyncWebServerRequest::url() const {
|
||||||
auto *str = strchr(this->req_->uri, '?');
|
auto *query_start = strchr(this->req_->uri, '?');
|
||||||
if (str == nullptr) {
|
std::string result;
|
||||||
return this->req_->uri;
|
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
|
||||||
|
if (!result.empty()) {
|
||||||
|
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(); }
|
std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); }
|
||||||
|
|||||||
@@ -1981,6 +1981,26 @@ 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
|
||||||
|
# Note: Must be < 255 because web_server UrlMatch uses uint8_t for length fields
|
||||||
|
NAME_MAX_LENGTH = 120
|
||||||
|
|
||||||
|
|
||||||
def _validate_entity_name(value):
|
def _validate_entity_name(value):
|
||||||
value = string(value)
|
value = string(value)
|
||||||
try:
|
try:
|
||||||
@@ -1991,9 +2011,28 @@ def _validate_entity_name(value):
|
|||||||
requires_friendly_name(
|
requires_friendly_name(
|
||||||
"Name cannot be None when esphome->friendly_name is not set!"
|
"Name cannot be None when esphome->friendly_name is not set!"
|
||||||
)(value)
|
)(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
|
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.
|
||||||
|
Use with cv.Length() to also enforce maximum length.
|
||||||
|
"""
|
||||||
|
value = string(value)
|
||||||
|
return _validate_no_slash(value)
|
||||||
|
|
||||||
|
|
||||||
ENTITY_BASE_SCHEMA = Schema(
|
ENTITY_BASE_SCHEMA = Schema(
|
||||||
{
|
{
|
||||||
Optional(CONF_NAME): _validate_entity_name,
|
Optional(CONF_NAME): _validate_entity_name,
|
||||||
|
|||||||
@@ -186,14 +186,14 @@ else:
|
|||||||
AREA_SCHEMA = cv.Schema(
|
AREA_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
cv.GenerateID(CONF_ID): cv.declare_id(Area),
|
cv.GenerateID(CONF_ID): cv.declare_id(Area),
|
||||||
cv.Required(CONF_NAME): cv.string,
|
cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
DEVICE_SCHEMA = cv.Schema(
|
DEVICE_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
cv.GenerateID(CONF_ID): cv.declare_id(Device),
|
cv.GenerateID(CONF_ID): cv.declare_id(Device),
|
||||||
cv.Required(CONF_NAME): cv.string,
|
cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)),
|
||||||
cv.Optional(CONF_AREA_ID): cv.use_id(Area),
|
cv.Optional(CONF_AREA_ID): cv.use_id(Area),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -207,7 +207,9 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Required(CONF_NAME): cv.valid_name,
|
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_AREA): validate_area_config,
|
||||||
cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)),
|
cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)),
|
||||||
cv.Required(CONF_BUILD_PATH): cv.string,
|
cv.Required(CONF_BUILD_PATH): cv.string,
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class EntityBase {
|
|||||||
return this->device_->get_device_id();
|
return this->device_->get_device_id();
|
||||||
}
|
}
|
||||||
void set_device(Device *device) { this->device_ = device; }
|
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
|
#endif
|
||||||
|
|
||||||
// Check if this entity has state
|
// Check if this entity has state
|
||||||
|
|||||||
@@ -502,3 +502,60 @@ def test_only_with_user_value_overrides_default() -> None:
|
|||||||
|
|
||||||
result = schema({"mqtt_id": "custom_id"})
|
result = schema({"mqtt_id": "custom_id"})
|
||||||
assert result.get("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__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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_entity_name__none_without_friendly_name() -> None:
|
||||||
|
# When name is "None" and friendly_name is not set, it should fail
|
||||||
|
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.friendly_name = "My Device"
|
||||||
|
result = config_validation._validate_entity_name("None")
|
||||||
|
assert result is None
|
||||||
|
CORE.friendly_name = None # Reset
|
||||||
|
|||||||
Reference in New Issue
Block a user