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:
+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
|
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
|
||||||
|
|||||||
@@ -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]))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user