[web_server] Add CORS origin checking with allowed_origins (#17530)

This commit is contained in:
Jesse Hills
2026-07-13 15:38:48 +12:00
committed by GitHub
parent 4a82b10783
commit a8dfd00cc6
8 changed files with 250 additions and 25 deletions
+22 -17
View File
@@ -92,18 +92,24 @@ is choosing an open control surface, in the same way that running native OTA
without a password leaves OTA open. The API is documented and is meant to be without a password leaves OTA open. The API is documented and is meant to be
called by other devices, scripts, and pages. called by other devices, scripts, and pages.
The device performs no CSRF token, `Origin`, or `Referer` validation and returns As defense-in-depth, the web server checks the `Origin` header on browser requests
a permissive CORS policy. Cross-origin requests are handled the same as any other to its entity control and state endpoints: a request whose `Origin` does not match
network request, including requests a browser is induced to make by a page the the address the device is served on is rejected, and the `allowed_origins` option
operator visits (the "confused deputy", or CSRF, pattern). The following are widens that list. This blocks the common "confused deputy" (CSRF) case where a page
therefore **not** vulnerabilities in this repository: the operator visits drives the device through their browser. It is **not** an
authentication boundary: it only constrains browsers. Any client that omits the
`Origin` header — `curl`, scripts, or other non-browser callers on the same
network — reaches every endpoint exactly as before. The check also does not cover
the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
validation. The following are therefore **not** vulnerabilities in this repository:
- Cross-origin or CSRF requests to the control endpoints (for example, a page the - Requests without an `Origin` header (for example `curl`) reaching the control
operator opens toggling a switch), whether or not `web_server` `auth:` is set. endpoints, whether or not `web_server` `auth:` is set.
- Cross-origin reads of device state permitted by the CORS policy. - Requests from an origin the operator added to `allowed_origins`.
- Cross-origin firmware upload through the web OTA endpoint (`/update`) when web - Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
OTA is enabled without `web_server` `auth:`. This is the same exposure as web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
running OTA without a password. covered by the `Origin` check; this is the same exposure as running OTA without a
password.
The supported defenses are `web_server` `auth:`, protecting OTA (a web password or The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
a native OTA password), and keeping devices on a trusted, segmented network. See a native OTA password), and keeping devices on a trusted, segmented network. See
@@ -113,9 +119,7 @@ What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
and any memory-safety or protocol bug in the server reachable without credentials. and any memory-safety or protocol bug in the server reachable without credentials.
This section documents the current design and scope; it is not a judgment that the This section documents the current design and scope; it is not a judgment that the
design is optimal or that it will not change. Optional hardening (for example an design is optimal or that it will not change.
origin allowlist or opt-in CSRF checks) is welcome as a normal enhancement PR,
framed as defense-in-depth rather than a security fix.
## Explicitly out of scope ## Explicitly out of scope
@@ -124,9 +128,10 @@ framed as defense-in-depth rather than a security fix.
- Operator-supplied hostile YAML (covered above — config authoring is trusted). - Operator-supplied hostile YAML (covered above — config authoring is trusted).
- Attacks that require an already-authenticated device peer (someone who already - Attacks that require an already-authenticated device peer (someone who already
holds the API key / OTA / web credentials). holds the API key / OTA / web credentials).
- Cross-site (CSRF), cross-origin, or CORS behavior of the device web server and - Access to the device web server or its web OTA endpoint by non-browser clients
its web OTA endpoint. The web server is an open HTTP API by design (see above); (those that send no `Origin` header). The web server is an open HTTP API by
gate it with `web_server` `auth:` and network isolation. design (see above); browser cross-origin requests are blocked by default, but the
real controls are `web_server` `auth:` and network isolation.
- Anything in the dashboard / device-builder — report that in its own repository - Anything in the dashboard / device-builder — report that in its own repository
(linked at the top). (linked at the top).
- Deployments where the operator removed protections or exposed credentials. See - Deployments where the operator removed protections or exposed credentials. See
+45 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import gzip import gzip
import logging import logging
import re
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import web_server_base from esphome.components import web_server_base
@@ -46,6 +47,7 @@ AUTO_LOAD = ["json", "web_server_base"]
CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUP_ID = "sorting_group_id"
CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_GROUPS = "sorting_groups"
CONF_SORTING_WEIGHT = "sorting_weight" CONF_SORTING_WEIGHT = "sorting_weight"
CONF_ALLOWED_ORIGINS = "allowed_origins"
web_server_ns = cg.esphome_ns.namespace("web_server") web_server_ns = cg.esphome_ns.namespace("web_server")
@@ -104,6 +106,41 @@ def validate_ota(config: ConfigType) -> ConfigType:
return config return config
# An Origin header is always "scheme://host[:port]" with no path or trailing slash.
_ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$")
def validate_origin(value: str) -> str:
# "*" is the wildcard that allows any origin.
if value == "*":
return value
value = cv.string_strict(value)
if not _ORIGIN_RE.match(value):
raise cv.Invalid(
f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no "
f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin."
)
# Browsers send the scheme and host lowercased in the Origin header, so normalize to match.
return value.lower()
def validate_private_network_access(config: ConfigType) -> ConfigType:
# PNA preflights are always cross-origin, so they can only be authorized against the
# allowed_origins list. Enabling PNA without any origins would deny every PNA request.
if (
config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]
and config.get(CONF_ALLOWED_ORIGINS) is None
):
raise cv.Invalid(
f"'{CONF_ALLOWED_ORIGINS}' must be set when "
f"'{CONF_ENABLE_PRIVATE_NETWORK_ACCESS}' is enabled. List each origin that is "
f"allowed to reach the device (e.g. 'https://example.com'). '*' allows any origin "
f"but is not recommended.",
path=[CONF_ENABLE_PRIVATE_NETWORK_ACCESS],
)
return config
def validate_sorting_groups(config: ConfigType) -> ConfigType: def validate_sorting_groups(config: ConfigType) -> ConfigType:
if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3: if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3:
raise cv.Invalid( raise cv.Invalid(
@@ -201,7 +238,10 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_CSS_INCLUDE): cv.file_, cv.Optional(CONF_CSS_INCLUDE): cv.file_,
cv.Optional(CONF_JS_URL): cv.string, cv.Optional(CONF_JS_URL): cv.string,
cv.Optional(CONF_JS_INCLUDE): cv.file_, cv.Optional(CONF_JS_INCLUDE): cv.file_,
cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=True): cv.boolean, cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=False): cv.boolean,
cv.Optional(CONF_ALLOWED_ORIGINS): cv.All(
cv.ensure_list(validate_origin), cv.Length(min=1)
),
cv.Optional(CONF_AUTH): cv.Schema( cv.Optional(CONF_AUTH): cv.Schema(
{ {
cv.Required(CONF_USERNAME): cv.All( cv.Required(CONF_USERNAME): cv.All(
@@ -238,6 +278,7 @@ CONFIG_SCHEMA = cv.All(
validate_local, validate_local,
validate_sorting_groups, validate_sorting_groups,
validate_ota, validate_ota,
validate_private_network_access,
_consume_web_server_sockets, _consume_web_server_sockets,
) )
@@ -334,6 +375,9 @@ async def to_code(config):
request_log_listener() # Request a log listener slot for web server log streaming request_log_listener() # Request a log listener slot for web server log streaming
if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")
if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None:
cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS")
cg.add(var.set_allowed_origins(allowed_origins))
if CONF_AUTH in config: if CONF_AUTH in config:
cg.add_define("USE_WEBSERVER_AUTH") cg.add_define("USE_WEBSERVER_AUTH")
cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME]))
+64 -7
View File
@@ -456,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) {
} }
#endif #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 #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { 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("")); 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("Access-Control-Allow-Private-Network"), ESPHOME_F("true"));
response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str());
char mac_s[18]; char mac_s[18];
@@ -2448,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
return; 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 !defined(USE_ESP32) && defined(USE_ARDUINO)
if (url == ESPHOME_F("/events")) { if (url == ESPHOME_F("/events")) {
this->events_.add_new_client(this, request); this->events_.add_new_client(this, request);
@@ -2469,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
} }
#endif #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 // Parse URL for component routing
// Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action) // 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); UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST);
@@ -242,6 +242,22 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
*/ */
void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; } void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; }
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
/** Set the origins that browsers are allowed to make cross-origin requests from.
*
* Requests without an `Origin` header (e.g. non-browser clients like curl or the native API)
* are always allowed. Requests whose `Origin` matches the address the device is served on
* (same-origin) are always allowed. Any other browser origin must appear in this list, or the
* request is rejected. A single "*" entry allows any origin. Each other entry must exactly match
* the requesting page's `Origin` header (e.g. "https://example.com").
*
* This list is also used to authorize Private Network Access requests when that feature is enabled.
*
* @param origins The list of allowed origins.
*/
void set_allowed_origins(std::initializer_list<const char *> origins) { this->allowed_origins_ = origins; }
#endif
// ========== INTERNAL METHODS ========== // ========== INTERNAL METHODS ==========
// (In most use cases you won't need these) // (In most use cases you won't need these)
/// Setup the internal web server and register handlers. /// Setup the internal web server and register handlers.
@@ -593,6 +609,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
const char *js_include_{nullptr}; const char *js_include_{nullptr};
#endif #endif
bool expose_log_{true}; bool expose_log_{true};
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
// Extra origins allowed to make cross-origin browser requests ("*" means any origin).
// Only compiled when allowed_origins is configured; same-origin is always allowed regardless.
FixedVector<const char *> allowed_origins_;
#endif
/// Check whether the given request Origin is permitted. Same-origin (matching the Host the
/// request was sent to) and requests without an Origin header are always allowed; any other
/// origin must be listed in allowed_origins. The caller passes the already-read Origin header.
bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin);
private: private:
#ifdef USE_SENSOR #ifdef USE_SENSOR
+1
View File
@@ -302,6 +302,7 @@
#define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_WEBSERVER_GZIP #define USE_WEBSERVER_GZIP
#define USE_WEBSERVER_SORTING #define USE_WEBSERVER_SORTING
#define USE_WEBSERVER_ALLOWED_ORIGINS
#define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1
#define USE_CAPTIVE_PORTAL_GZIP #define USE_CAPTIVE_PORTAL_GZIP
#define USE_WIFI_11KV_SUPPORT #define USE_WIFI_11KV_SUPPORT
@@ -0,0 +1,86 @@
"""Tests for web_server Private Network Access / allowed_origins validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.web_server import (
CONF_ALLOWED_ORIGINS,
validate_origin,
validate_private_network_access,
)
from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS
from esphome.types import ConfigType
def test_pna_enabled_without_origins_fails() -> None:
"""Enabling PNA without allowed_origins must fail validation."""
config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True}
with pytest.raises(cv.Invalid) as exc_info:
validate_private_network_access(config)
error_msg = str(exc_info.value)
assert CONF_ALLOWED_ORIGINS in error_msg
assert "must be set" in error_msg
def test_pna_enabled_with_origins_passes() -> None:
"""Enabling PNA with at least one allowed origin passes validation."""
config: ConfigType = {
CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True,
CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"],
}
assert validate_private_network_access(config) == config
def test_origins_without_pna_passes() -> None:
"""allowed_origins can be set without enabling PNA (they are independent)."""
config: ConfigType = {
CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False,
CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"],
}
assert validate_private_network_access(config) == config
def test_pna_disabled_without_origins_passes() -> None:
"""PNA disabled and no origins specified passes validation."""
config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False}
assert validate_private_network_access(config) == config
def test_validate_origin_wildcard() -> None:
"""The '*' wildcard is accepted as-is."""
assert validate_origin("*") == "*"
@pytest.mark.parametrize(
"value",
[
"https://example.com",
"http://example.com:8080",
"https://192.168.1.5",
],
)
def test_validate_origin_valid(value: str) -> None:
"""Well-formed origins pass through unchanged."""
assert validate_origin(value) == value
def test_validate_origin_lowercased() -> None:
"""Scheme and host are normalized to lowercase to match the browser Origin header."""
assert validate_origin("HTTPS://App.Example.com") == "https://app.example.com"
@pytest.mark.parametrize(
"value",
[
"https://example.com/", # trailing slash
"https://example.com/path", # path segment
"example.com", # missing scheme
"", # empty
],
)
def test_validate_origin_invalid(value: str) -> None:
"""Malformed origins are rejected at config time instead of silently 403ing."""
with pytest.raises(cv.Invalid, match="not a valid origin"):
validate_origin(value)
@@ -5,3 +5,6 @@ web_server:
port: 8080 port: 8080
version: 2 version: 2
compression: br compression: br
enable_private_network_access: true
allowed_origins:
- https://app.esphome.io
@@ -4,6 +4,9 @@ packages:
web_server: web_server:
port: 8080 port: 8080
version: 3 version: 3
# allowed_origins can be set independently of Private Network Access
allowed_origins:
- https://app.esphome.io
sorting_groups: sorting_groups:
- id: sorting_group_1 - id: sorting_group_1
name: "Group 1 Diplayed Last" name: "Group 1 Diplayed Last"