mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 10:52:56 +08:00
[web_server] Add CORS origin checking with allowed_origins (#17530)
This commit is contained in:
+22
-17
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user