mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[web_server] Add CORS origin checking with allowed_origins (#17530)
This commit is contained in:
@@ -456,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Read a request header value portably across the Arduino and ESP-IDF web servers.
|
||||
// Returns an empty string when the header is absent (only allocates when a value is present).
|
||||
static std::string get_request_header(AsyncWebServerRequest *request, const char *name) {
|
||||
#ifdef USE_ESP32
|
||||
// ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend.
|
||||
optional<std::string> value = request->get_header(name);
|
||||
return value.has_value() ? std::move(*value) : std::string();
|
||||
#else
|
||||
// ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend.
|
||||
const AsyncWebHeader *header = request->getHeader(name);
|
||||
return header != nullptr ? std::string(header->value().c_str()) : std::string();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) {
|
||||
// No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow.
|
||||
if (origin.empty())
|
||||
return true;
|
||||
|
||||
// Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to.
|
||||
// This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time.
|
||||
const size_t scheme_sep = origin.find("://");
|
||||
if (scheme_sep != std::string::npos) {
|
||||
const std::string host = get_request_header(request, "Host");
|
||||
if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
|
||||
// Otherwise the origin must be explicitly allowed via configuration.
|
||||
for (const char *allowed_origin : this->allowed_origins_) {
|
||||
// A single "*" entry allows any origin.
|
||||
if (allowed_origin[0] == '*' && allowed_origin[1] == '\0')
|
||||
return true;
|
||||
if (origin == allowed_origin)
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
|
||||
void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) {
|
||||
const std::string origin = get_request_header(request, "Origin");
|
||||
if (!this->is_request_origin_allowed_(request, origin)) {
|
||||
request->send(403);
|
||||
return;
|
||||
}
|
||||
|
||||
AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F(""));
|
||||
// Echo the specific origin back so the response is valid even when auth (credentials) is enabled.
|
||||
response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str());
|
||||
response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true"));
|
||||
response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str());
|
||||
char mac_s[18];
|
||||
@@ -2448,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
|
||||
// Private Network Access preflight carries a cross-origin Origin by design; its handler does the
|
||||
// origin check itself, so let it run before the general enforcement below.
|
||||
if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) {
|
||||
this->handle_pna_cors_request(request);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Reject cross-origin browser requests unless the origin is explicitly allowed.
|
||||
if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) {
|
||||
request->send(403);
|
||||
return;
|
||||
}
|
||||
|
||||
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
|
||||
if (url == ESPHOME_F("/events")) {
|
||||
this->events_.add_new_client(this, request);
|
||||
@@ -2469,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
|
||||
if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) {
|
||||
this->handle_pna_cors_request(request);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Parse URL for component routing
|
||||
// Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action)
|
||||
UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST);
|
||||
|
||||
Reference in New Issue
Block a user