mirror of
https://github.com/esphome/esphome.git
synced 2026-05-24 09:56:46 +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 {
|
||||
|
||||
/// 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
|
||||
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 *method; ///< Pointer to method within URL, for example "turn_on"
|
||||
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
|
||||
StringRef domain; ///< Domain within URL, for example "sensor"
|
||||
StringRef id; ///< Entity name/id within URL, for example "Temperature"
|
||||
StringRef method; ///< Method within URL, for example "turn_on"
|
||||
#ifdef USE_DEVICES
|
||||
StringRef device_name; ///< Device name within URL, empty for main device
|
||||
#endif
|
||||
bool valid{false}; ///< 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 domain_equals(const char *str) const { return this->domain == str; }
|
||||
bool method_equals(const char *str) const { return this->method == str; }
|
||||
|
||||
bool id_equals_entity(EntityBase *entity) const {
|
||||
// Get object_id with zero heap allocation
|
||||
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;
|
||||
}
|
||||
|
||||
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; }
|
||||
/// Match entity by name first, then fall back to object_id with deprecation warning
|
||||
/// Returns EntityMatchResult with match status and whether action segment is empty
|
||||
EntityMatchResult match_entity(EntityBase *entity) const;
|
||||
};
|
||||
|
||||
#ifdef USE_WEBSERVER_SORTING
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
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,
|
||||
const std::function<void(AsyncResponseStream &stream, EntityBase *obj)> &action_func = nullptr) {
|
||||
stream->print("<tr class=\"");
|
||||
@@ -16,8 +39,27 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &
|
||||
stream->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("\"><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(action.c_str());
|
||||
if (action_func) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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<std::string> request_get_header(httpd_req_t *req, const char *name);
|
||||
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 {
|
||||
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
|
||||
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(); }
|
||||
|
||||
@@ -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):
|
||||
value = string(value)
|
||||
try:
|
||||
@@ -1991,9 +2011,28 @@ 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.
|
||||
Use with cv.Length() to also enforce maximum length.
|
||||
"""
|
||||
value = string(value)
|
||||
return _validate_no_slash(value)
|
||||
|
||||
|
||||
ENTITY_BASE_SCHEMA = Schema(
|
||||
{
|
||||
Optional(CONF_NAME): _validate_entity_name,
|
||||
|
||||
@@ -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.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,
|
||||
cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)),
|
||||
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,
|
||||
|
||||
@@ -100,6 +100,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
|
||||
|
||||
@@ -502,3 +502,60 @@ 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__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