[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
called by other devices, scripts, and pages.
The device performs no CSRF token, `Origin`, or `Referer` validation and returns
a permissive CORS policy. Cross-origin requests are handled the same as any other
network request, including requests a browser is induced to make by a page the
operator visits (the "confused deputy", or CSRF, pattern). The following are
therefore **not** vulnerabilities in this repository:
As defense-in-depth, the web server checks the `Origin` header on browser requests
to its entity control and state endpoints: a request whose `Origin` does not match
the address the device is served on is rejected, and the `allowed_origins` option
widens that list. This blocks the common "confused deputy" (CSRF) case where a page
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
operator opens toggling a switch), whether or not `web_server` `auth:` is set.
- Cross-origin reads of device state permitted by the CORS policy.
- Cross-origin firmware upload through the web OTA endpoint (`/update`) when web
OTA is enabled without `web_server` `auth:`. This is the same exposure as
running OTA without a password.
- Requests without an `Origin` header (for example `curl`) reaching the control
endpoints, whether or not `web_server` `auth:` is set.
- Requests from an origin the operator added to `allowed_origins`.
- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
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
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.
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
origin allowlist or opt-in CSRF checks) is welcome as a normal enhancement PR,
framed as defense-in-depth rather than a security fix.
design is optimal or that it will not change.
## 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).
- Attacks that require an already-authenticated device peer (someone who already
holds the API key / OTA / web credentials).
- Cross-site (CSRF), cross-origin, or CORS behavior of the device web server and
its web OTA endpoint. The web server is an open HTTP API by design (see above);
gate it with `web_server` `auth:` and network isolation.
- Access to the device web server or its web OTA endpoint by non-browser clients
(those that send no `Origin` header). The web server is an open HTTP API by
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
(linked at the top).
- 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 logging
import re
import esphome.codegen as cg
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_GROUPS = "sorting_groups"
CONF_SORTING_WEIGHT = "sorting_weight"
CONF_ALLOWED_ORIGINS = "allowed_origins"
web_server_ns = cg.esphome_ns.namespace("web_server")
@@ -104,6 +106,41 @@ def validate_ota(config: ConfigType) -> ConfigType:
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:
if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3:
raise cv.Invalid(
@@ -201,7 +238,10 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_CSS_INCLUDE): cv.file_,
cv.Optional(CONF_JS_URL): cv.string,
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.Required(CONF_USERNAME): cv.All(
@@ -238,6 +278,7 @@ CONFIG_SCHEMA = cv.All(
validate_local,
validate_sorting_groups,
validate_ota,
validate_private_network_access,
_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
if config[CONF_ENABLE_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:
cg.add_define("USE_WEBSERVER_AUTH")
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
// 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);
@@ -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; }
#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 ==========
// (In most use cases you won't need these)
/// 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};
#endif
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:
#ifdef USE_SENSOR
+1
View File
@@ -302,6 +302,7 @@
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_WEBSERVER_GZIP
#define USE_WEBSERVER_SORTING
#define USE_WEBSERVER_ALLOWED_ORIGINS
#define WEB_SERVER_DEFAULT_HEADERS_COUNT 1
#define USE_CAPTIVE_PORTAL_GZIP
#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
version: 2
compression: br
enable_private_network_access: true
allowed_origins:
- https://app.esphome.io
@@ -4,6 +4,9 @@ packages:
web_server:
port: 8080
version: 3
# allowed_origins can be set independently of Private Network Access
allowed_origins:
- https://app.esphome.io
sorting_groups:
- id: sorting_group_1
name: "Group 1 Diplayed Last"