feat!: the token on every route, Secure Config Only goes (step 4b)

Under Secured every HTTP route but the public ones needs the token
(docs/adr/0006-two-security-modes-eleven-http-routes.md):
- isPublicRoute, isWebAsset and originMatchesHost in src/HttpAccess.h,
  with native tests.
- One middleware checks the token. It runs once the body is in, so
  /restore, /updateFirmware and /updateApplication check it on their
  first chunk and drop a refused upload.
- Static serving answers the web UI's files only: shades.cfg and
  shades.tmp are no longer served.
- The wildcard CORS headers go; WEB_CORS turns them back on for UI work
  from file://.
- The socket refuses a handshake whose Origin doesn't name its Host.
- "Secure Config Only" goes: a device set to it is Secured, and
  /loginContext drops permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-27 23:03:59 +01:00
parent 13ec38823b
commit ecb0260f61
16 changed files with 392 additions and 54 deletions

View file

@ -136,6 +136,9 @@ The device's description of the protocol it speaks: what each message type, fiel
Whether the device asks for login: *None* (everything open) or *Secured* (a PIN or password). Whether the device asks for login: *None* (everything open) or *Secured* (a PIN or password).
_Avoid_: Secure Config Only (removed) _Avoid_: Secure Config Only (removed)
**Public route**
What anyone may reach under Secured without a token: the web UI's files, `/loginContext`, `/login`, `/upnp.xml` and preflight requests. Static serving answers the web UI's files alone, so `shades.cfg` isn't one.
**Token** **Token**
What logging in returns. It stands for the PIN or password, on one client address, and every request under Secured carries it. What logging in returns. It stands for the PIN or password, on one client address, and every request under Secured carries it.
_Avoid_: session, API key (the name of the field it travels in) _Avoid_: session, API key (the name of the field it travels in)

View file

@ -99,6 +99,8 @@ Since 4.0.0 everything is on port 80. Ports 8081 (HTTP API) and 8080 (events soc
A script that called port 8081 only changes the port, if it used one of the three commands. Reads such as `/controller`, `/shades` and `/discovery` are gone: use the socket, or MQTT. The Home Assistant integration needs version 3.0.0 or newer. A script that called port 8081 only changes the port, if it used one of the three commands. Reads such as `/controller`, `/shades` and `/discovery` are gone: use the socket, or MQTT. The Home Assistant integration needs version 3.0.0 or newer.
When the device is Secured (a PIN or password), every route above except the web UI and the login routes needs the token, in an `apikey` header; the socket takes it as `?apikey=<token>`. Without it the reply is 401. "Secure Config Only" is gone: a device set to it is Secured from 4.0.0, so scripts that sent commands without logging in now need the token.
## Sources for this Project ## Sources for this Project
I spent some time reading about a myriad of topics but in the end the primary source for this project comes from https://pushstack.wordpress.com/somfy-rts-protocol/. The work done on pushstack regarding the protocol timing made this feasible without burning a bunch of time measuring pulses. I spent some time reading about a myriad of topics but in the end the primary source for this project comes from https://pushstack.wordpress.com/somfy-rts-protocol/. The work done on pushstack regarding the protocol timing made this feasible without burning a bunch of time measuring pulses.

View file

@ -7,6 +7,7 @@ Why the protocol looks like this: [ADR 0005](adr/0005-one-websocket-protocol.md)
## Connection ## Connection
- `ws://host/ws` on port 80. Under Secured the upgrade needs `?apikey=<token>` from `/login`; without a valid one it is refused with 401 ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)). - `ws://host/ws` on port 80. Under Secured the upgrade needs `?apikey=<token>` from `/login`; without a valid one it is refused with 401 ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)).
- An upgrade whose `Origin` header doesn't name the `Host` it was sent to is refused with 401 too, in either mode, so another site's page can't open the socket. Clients that aren't browsers send no `Origin`.
- Optional `?schema=<hash>`: the client already has the schema with that hash and doesn't need it again. - Optional `?schema=<hash>`: the client already has the schema with that hash and doesn't need it again.
- At most 5 clients. A sixth is closed as soon as it connects. - At most 5 clients. A sixth is closed as soon as it connects.
- Text messages of at most 4 KB. A message may be sent as several frames, and a frame larger than one TCP segment (about 1.4 KB) reaches the device in pieces anyway; the device puts it back together. It does that for one message at a time across all clients: while it is, another client's long message is answered with busy, and the client sends it again. A message that hasn't arrived in full within 10 s is given up, and its client is told with a bad request. These refusals have no `id`, since the message couldn't be read. A binary encoding, if one comes, would be asked for at the upgrade (for example `?enc=bin`) and use binary frames. - Text messages of at most 4 KB. A message may be sent as several frames, and a frame larger than one TCP segment (about 1.4 KB) reaches the device in pieces anyway; the device puts it back together. It does that for one message at a time across all clients: while it is, another client's long message is answered with busy, and the client sends it again. A message that hasn't arrived in full within 10 s is given up, and its client is told with a bad request. These refusals have no `id`, since the message couldn't be read. A binary encoding, if one comes, would be asked for at the upgrade (for example `?enc=bin`) and use binary frames.

View file

@ -1,6 +1,6 @@
# Plan: authentication on every entry point, one HTTP server # Plan: authentication on every entry point, one HTTP server
**Status:** steps 1 to 3, PR B and PR C merged; step 4 in progress: 4a removes the old API, 4b adds the auth rules. **Status:** steps 1 to 3, PR B, PR C and 4a merged; 4b, the auth rules, is the last step.
**Written:** 2026-10-18, on top of PR #64 (stored secrets are write-only). **Revised:** 2026-10-22 for socket protocol 2: two security modes, eleven HTTP routes, the socket on port 80 ([ADR 0005](adr/0005-one-websocket-protocol.md), [ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)). Terms are defined in [GLOSSARY.md](../GLOSSARY.md). **Written:** 2026-10-18, on top of PR #64 (stored secrets are write-only). **Revised:** 2026-10-22 for socket protocol 2: two security modes, eleven HTTP routes, the socket on port 80 ([ADR 0005](adr/0005-one-websocket-protocol.md), [ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)). Terms are defined in [GLOSSARY.md](../GLOSSARY.md).
## Why ## Why
@ -29,20 +29,21 @@
### HTTP routes ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)) ### HTTP routes ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md))
- **Public:** static web assets, `/loginContext`, `/login`, `/upnp.xml` (SSDP), OPTIONS preflight requests. - **Public:** static web assets, `/loginContext`, `/login`, `/upnp.xml` (SSDP), OPTIONS preflight requests.
- **Token under Secured:** `/backup`, `/restore`, `/updateFirmware`, `/updateApplication`, `/shadeCommand`, `/groupCommand`, `/tiltCommand`. - **Token under Secured:** `/backup`, `/restore`, `/updateFirmware`, `/updateApplication`, `/shadeCommand`, `/groupCommand`, `/tiltCommand`.
- Which routes are public is one pure function with native tests. - Which routes are public is one pure function with native tests (`isPublicRoute` in [src/HttpAccess.h](../src/HttpAccess.h)). It counts `/ws` as public because the socket checks its own token in the handshake.
- One middleware checks the token for every other route. It runs once the body is in, so the uploads check the token on their first chunk too and drop a refused one.
- Static serving covers the web assets only, so the rolling codes in `shades.cfg` are reachable only through the authenticated `/backup`. - Static serving covers the web assets only, so the rolling codes in `shades.cfg` are reachable only through the authenticated `/backup`.
- The web UI sends `apikey` on its backup and upload requests too (today those XHRs leave it out). - The web UI sends `apikey` on its backup and upload requests too.
### Websocket ### Websocket
- **Handshake:** the socket is `ws://host/ws` on port 80. The token goes in `?apikey=<token>`, because browsers can't set headers on a websocket. `handleHandshake` checks it with the same comparison the HTTP routes use. `request->url()` is the path only, so the token doesn't reach the slow-request log. - **Handshake:** the socket is `ws://host/ws` on port 80. The token goes in `?apikey=<token>`, because browsers can't set headers on a websocket. `handleHandshake` checks it with the same comparison the HTTP routes use. `request->url()` is the path only, so the token doesn't reach the slow-request log.
- **Rules:** under None anyone can connect. Under Secured the handshake needs a valid token, and a connection that has one may send every op and subscribe to every feed. - **Rules:** under None anyone can connect. Under Secured the handshake needs a valid token, and a connection that has one may send every op and subscribe to every feed.
- **Logging in with a socket already open:** the UI reconnects the socket with the token. There is no in-band upgrade message. - **Logging in with a socket already open:** the UI reconnects the socket with the token. There is no in-band upgrade message.
- **Changing the security settings:** a `saveSettings` of the security section that leaves the device Secured, or changes its mode, closes every socket, since their tokens may no longer be good, and a socket opened under None had none. The client that saved is closed once its reply, which carries its new token, has gone, so no socket stays open on a check it never passed. The others log in when refused. - **Changing the security settings:** a `saveSettings` of the security section that leaves the device Secured, or changes its mode, closes every socket, since their tokens may no longer be good, and a socket opened under None had none. The client that saved is closed once its reply, which carries its new token, has gone, so no socket stays open on a check it never passed. The others log in when refused.
- **Origin:** a handshake whose `Origin` header doesn't match `Host` is refused. - **Origin:** a handshake whose `Origin` header doesn't match `Host` is refused, in either mode (`originMatchesHost` in [src/HttpAccess.h](../src/HttpAccess.h)). One without `Origin` comes from a client that isn't a browser and passes.
### CORS ### CORS
- The wildcard `Access-Control-Allow-*` default headers go. The UI is served from the same origin, and HA and scripts aren't browsers, so CORS doesn't affect them. - The wildcard `Access-Control-Allow-*` default headers go. The UI is served from the same origin, and HA and scripts aren't browsers, so CORS doesn't affect them.
- A build flag turns the headers back on for UI development from `file://`, which `baseUrl` in `index.js` supports. - A build flag (`WEB_CORS` in `platformio.ini`) turns the headers back on for UI development from `file://`, which `baseUrl` in `index.js` supports. It also skips the Origin check, since such a page sends `Origin: null`.
### UI ### UI
- The token is kept in `sessionStorage`: it survives a reload, is dropped when the tab closes, and is cleared when the socket upgrade is refused. - The token is kept in `sessionStorage`: it survives a reload, is dropped when the tab closes, and is cleared when the socket upgrade is refused.

View file

@ -42,6 +42,10 @@ build_flags =
; Route log_printfv through src/LogLock.cpp so lines from both cores don't ; Route log_printfv through src/LogLock.cpp so lines from both cores don't
; interleave. The core already wraps log_printf for ESP Insights. ; interleave. The core already wraps log_printf for ESP Insights.
-Wl,--wrap=log_printfv -Wl,--wrap=log_printfv
; For work on the web UI from a file:// page (baseUrl in index.js): sends
; the CORS headers and lets the socket skip its Origin check. Never in a
; release (docs/SECURITY_PLAN.md, CORS).
; -DWEB_CORS
monitor_speed = 115200 monitor_speed = 115200
monitor_filters = monitor_filters =
time time

View file

@ -408,7 +408,6 @@ bool SecuritySettings::fromJSON(JsonObject &obj) {
this->parseValueString(obj, "username", this->username, sizeof(this->username)); this->parseValueString(obj, "username", this->username, sizeof(this->username));
applySecret(obj["password"].as<const char *>(), this->password, sizeof(this->password)); applySecret(obj["password"].as<const char *>(), this->password, sizeof(this->password));
applySecret(obj["pin"].as<const char *>(), this->pin, sizeof(this->pin)); applySecret(obj["pin"].as<const char *>(), this->pin, sizeof(this->pin));
if(!obj["permissions"].isNull()) this->permissions = obj["permissions"];
return true; return true;
} }
bool SecuritySettings::save() { bool SecuritySettings::save() {
@ -419,10 +418,11 @@ bool SecuritySettings::save() {
pref.putString("username", this->username); pref.putString("username", this->username);
pref.putString("password", this->password); pref.putString("password", this->password);
pref.putString("pin", this->pin); pref.putString("pin", this->pin);
pref.putChar("permissions", this->permissions);
pref.end(); pref.end();
return true; return true;
} }
// A device that had "Secure Config Only" keeps its PIN or password and so is
// Secured. Its old permissions key goes with the next save's clear().
bool SecuritySettings::load() { bool SecuritySettings::load() {
Preferences pref; Preferences pref;
pref.begin("SEC"); pref.begin("SEC");
@ -430,13 +430,12 @@ bool SecuritySettings::load() {
if(pref.isKey("username")) pref.getString("username", this->username, sizeof(this->username)); if(pref.isKey("username")) pref.getString("username", this->username, sizeof(this->username));
if(pref.isKey("password")) pref.getString("password", this->password, sizeof(this->password)); if(pref.isKey("password")) pref.getString("password", this->password, sizeof(this->password));
if(pref.isKey("pin")) pref.getString("pin", this->pin, sizeof(this->pin)); if(pref.isKey("pin")) pref.getString("pin", this->pin, sizeof(this->pin));
if(pref.isKey("permissions")) this->permissions = pref.getChar("permissions", this->permissions);
pref.end(); pref.end();
return true; return true;
} }
void SecuritySettings::print() { void SecuritySettings::print() {
ESP_LOGD(TAG, "SECURITY Type:%u Username:[%s] Password:[%s] Pin:[%s] Permissions:%u", ESP_LOGD(TAG, "SECURITY Type:%u Username:[%s] Password:[%s] Pin:[%s]",
static_cast<uint8_t>(this->type), this->username, maskSecret(this->password), maskSecret(this->pin), this->permissions); static_cast<uint8_t>(this->type), this->username, maskSecret(this->password), maskSecret(this->pin));
} }
WifiSettings::WifiSettings() {} WifiSettings::WifiSettings() {}

View file

@ -127,16 +127,12 @@ enum class security_types : byte {
PinEntry = 0x01, PinEntry = 0x01,
Password = 0x02 Password = 0x02
}; };
enum class security_permissions : byte {
ConfigOnly = 0x01
};
class SecuritySettings: BaseSettings { class SecuritySettings: BaseSettings {
public: public:
security_types type = security_types::None; security_types type = security_types::None;
char username[33] = ""; char username[33] = "";
char password[33] = ""; char password[33] = "";
char pin[5] = ""; char pin[5] = "";
uint8_t permissions = 0;
bool begin(); bool begin();
bool save(); bool save();
bool load(); bool load();

View file

@ -91,8 +91,6 @@ static void writeSettings(ProtoWriter &w, settings_section_t section) {
w.add(proto_field_t::username, settings.Security.username); w.add(proto_field_t::username, settings.Security.username);
w.add(proto_field_t::hasPassword, hasSecret(settings.Security.password)); w.add(proto_field_t::hasPassword, hasSecret(settings.Security.password));
w.add(proto_field_t::hasPin, hasSecret(settings.Security.pin)); w.add(proto_field_t::hasPin, hasSecret(settings.Security.pin));
// Not "Secure Config Only"'s permissions: the socket has two modes, None
// and Secured (docs/adr/0006-two-security-modes-eleven-http-routes.md).
break; break;
case settings_section_t::radio: { case settings_section_t::radio: {
const transceiver_config_t &cfg = somfy.transceiver.config; const transceiver_config_t &cfg = somfy.transceiver.config;

49
src/HttpAccess.h Normal file
View file

@ -0,0 +1,49 @@
#ifndef httpaccess_h
#define httpaccess_h
#include <string.h>
#include <strings.h>
// Who may reach what on port 80 under Secured
// (docs/adr/0006-two-security-modes-eleven-http-routes.md). Paths are the
// request's path, without the query.
// The files the web UI loads. Static serving answers these alone, so the
// rolling codes in shades.cfg leave the device only through /backup.
inline bool isWebAsset(const char *path) {
if(!path || path[0] != '/' || strstr(path, "..")) return false;
if(strcmp(path, "/") == 0 || strcmp(path, "/appversion") == 0) return true;
static const char *const types[] = {".html", ".js", ".css", ".png", ".svg", ".ico"};
const size_t len = strlen(path);
for(const char *type : types) {
const size_t n = strlen(type);
if(len > n + 1 && strcmp(path + len - n, type) == 0) return true;
}
return false;
}
// True for what anyone may reach without a token: the web UI's files, what
// logging in needs, SSDP's description and preflight requests. /ws is here
// because the socket checks its own token in the handshake, from the query.
inline bool isPublicRoute(const char *path, bool preflight) {
if(preflight) return true;
if(!path) return false;
static const char *const routes[] = {"/loginContext", "/login", "/upnp.xml", "/ws"};
for(const char *route : routes)
if(strcmp(path, route) == 0) return true;
return isWebAsset(path);
}
// True when a socket handshake comes from a page the device served. A
// browser always sends Origin with a handshake, so another site's page open
// on the LAN can't use a token the browser holds. Clients that aren't
// browsers send none.
inline bool originMatchesHost(const char *origin, const char *host) {
if(!origin) return true;
static const char *const schemes[] = {"http://", "https://"};
for(const char *scheme : schemes) {
const size_t n = strlen(scheme);
if(strncasecmp(origin, scheme, n) == 0) return host && host[0] && strcasecmp(origin + n, host) == 0;
}
return false;
}
#endif

View file

@ -11,6 +11,7 @@
#include "Web.h" #include "Web.h"
#include "WebRoutes.h" #include "WebRoutes.h"
#include "MqttConnection.h" #include "MqttConnection.h"
#include "HttpAccess.h"
#include <AsyncTCP.h> #include <AsyncTCP.h>
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <AsyncJson.h> #include <AsyncJson.h>
@ -44,12 +45,18 @@ static void onUploadDisconnect() {
rebootDelay.reboot = true; rebootDelay.reboot = true;
rebootDelay.rebootTime = millis() + 500; rebootDelay.rebootTime = millis() + 500;
} }
// The token check (requireToken in Web.cpp) runs once the body is in, after
// every chunk was handled, so an upload checks the token on its first chunk
// and drops every chunk of a refused one.
static bool uploadAllowed(AsyncWebServerRequest *request, size_t index) {
if(index == 0) request->setAttribute("uploadAllowed", webServer.isAuthenticated(request));
return request->getAttribute("uploadAllowed", false);
}
void registerHttpRoutes(AsyncWebServer &server) { void registerHttpRoutes(AsyncWebServer &server) {
server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) { server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) {
AsyncJsonResponse *response = new AsyncJsonResponse(); AsyncJsonResponse *response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>(); JsonObject root = response->getRoot().to<JsonObject>();
root["type"] = static_cast<uint8_t>(settings.Security.type); root["type"] = static_cast<uint8_t>(settings.Security.type);
root["permissions"] = settings.Security.permissions;
root["serverId"] = settings.serverId; root["serverId"] = settings.serverId;
root["version"] = settings.fwVersion.name; root["version"] = settings.fwVersion.name;
root["model"] = "ESPSomfyRTS"; root["model"] = "ESPSomfyRTS";
@ -122,6 +129,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
else request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"The upload didn't complete, so nothing was restored.\"}")); else request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"The upload didn't complete, so nothing was restored.\"}"));
}, },
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
if(!uploadAllowed(request, index)) return;
resetTaskWdt(); resetTaskWdt();
if(index == 0) { if(index == 0) {
webServer.uploadSuccess = false; webServer.uploadSuccess = false;
@ -150,6 +158,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
rebootDelay.rebootTime = millis() + 500; rebootDelay.rebootTime = millis() + 500;
}, },
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
if(!uploadAllowed(request, index)) return;
if(index == 0) { if(index == 0) {
webServer.uploadSuccess = false; webServer.uploadSuccess = false;
ESP_LOGI(TAG, "Update: %s", filename.c_str()); ESP_LOGI(TAG, "Update: %s", filename.c_str());
@ -191,6 +200,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
rebootDelay.rebootTime = millis() + 500; rebootDelay.rebootTime = millis() + 500;
}, },
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
if(!uploadAllowed(request, index)) return;
if(index == 0) { if(index == 0) {
webServer.uploadSuccess = false; webServer.uploadSuccess = false;
ESP_LOGI(TAG, "Update: %s", filename.c_str()); ESP_LOGI(TAG, "Update: %s", filename.c_str());
@ -223,12 +233,10 @@ void registerHttpRoutes(AsyncWebServer &server) {
resetTaskWdt(); resetTaskWdt();
}); });
server.onNotFound([](AsyncWebServerRequest *r) { server.onNotFound([](AsyncWebServerRequest *r) { webServer.handleNotFound(r); });
if(r->method() == HTTP_OPTIONS) { r->send(200); return; }
webServer.handleNotFound(r);
});
// serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them // serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html")
.setFilter([](AsyncWebServerRequest *r) { return isWebAsset(r->url().c_str()); });
} }

View file

@ -16,6 +16,7 @@
#include "outputs/ProtoSocket.h" #include "outputs/ProtoSocket.h"
#include "Timing.h" #include "Timing.h"
#include "LoginThrottle.h" #include "LoginThrottle.h"
#include "HttpAccess.h"
#include <AsyncTCP.h> #include <AsyncTCP.h>
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <AsyncJson.h> #include <AsyncJson.h>
@ -92,27 +93,16 @@ void Web::startup() {
void Web::loop() { void Web::loop() {
delay(1); delay(1);
} }
bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) { bool Web::tokenAccepted(AsyncWebServerRequest *request, const char *given) {
ESP_LOGD(TAG, "Checking async authentication");
if(settings.Security.type == security_types::None) return true; if(settings.Security.type == security_types::None) return true;
else if(!cfg && (settings.Security.permissions & static_cast<uint8_t>(security_permissions::ConfigOnly)) == 0x01) return true; if(!given || !request->client()) return false;
else if(request->hasHeader("apikey")) { char token[API_TOKEN_SIZE];
ESP_LOGD(TAG, "Checking API Key..."); this->createAPIToken(request->client()->remoteIP(), token);
char token[API_TOKEN_SIZE]; return tokenMatches(token, given);
memset(token, 0x00, sizeof(token)); }
this->createAPIToken(request->client()->remoteIP(), token); bool Web::isAuthenticated(AsyncWebServerRequest *request) {
if(!tokenMatches(token, request->getHeader("apikey")->value().c_str())) { const AsyncWebHeader *key = request->getHeader("apikey");
request->send(401, _encoding_text, "Unauthorized API Key"); return this->tokenAccepted(request, key ? key->value().c_str() : nullptr);
return false;
}
// Key is valid
}
else {
ESP_LOGE(TAG, "Not authenticated...");
request->send(401, _encoding_text, "Unauthorized API Key");
return false;
}
return true;
} }
bool Web::createAPIPinToken(const IPAddress ipAddress, const char *pin, char *token) { bool Web::createAPIPinToken(const IPAddress ipAddress, const char *pin, char *token) {
return this->createAPIToken((String(pin) + ":" + ipAddress.toString()).c_str(), token); return this->createAPIToken((String(pin) + ":" + ipAddress.toString()).c_str(), token);
@ -240,7 +230,6 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
} }
void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json) { void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return;
shade_request_t req; shade_request_t req;
req.type = shade_request_type_t::shade_command; req.type = shade_request_type_t::shade_command;
// Try query params // Try query params
@ -279,7 +268,6 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json)
} }
void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) { void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return;
shade_request_t req; shade_request_t req;
req.type = shade_request_type_t::group_command; req.type = shade_request_type_t::group_command;
if(asyncHasParam(request, "groupId")) { if(asyncHasParam(request, "groupId")) {
@ -315,7 +303,6 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json)
} }
void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) { void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return;
shade_request_t req; shade_request_t req;
req.type = shade_request_type_t::tilt_command; req.type = shade_request_type_t::tilt_command;
if(asyncHasParam(request, "shadeId")) { if(asyncHasParam(request, "shadeId")) {
@ -348,7 +335,6 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
} }
} }
void Web::handleBackup(AsyncWebServerRequest *request) { void Web::handleBackup(AsyncWebServerRequest *request) {
if(!this->isAuthenticated(request)) return;
bool attach = false; bool attach = false;
if(asyncHasParam(request, "attach")) attach = toBoolean(asyncParam(request, "attach").c_str(), false); if(asyncHasParam(request, "attach")) attach = toBoolean(asyncParam(request, "attach").c_str(), false);
ESP_LOGI(TAG, "Async saving current shade information"); ESP_LOGI(TAG, "Async saving current shade information");
@ -368,7 +354,9 @@ void Web::handleBackup(AsyncWebServerRequest *request) {
snprintf(filename, sizeof(filename), "attachment; filename=\"ESPSomfyRTS %s.backup\"", iso); snprintf(filename, sizeof(filename), "attachment; filename=\"ESPSomfyRTS %s.backup\"", iso);
AsyncWebServerResponse *response = request->beginResponse(200, _encoding_text, somfy.backupData); AsyncWebServerResponse *response = request->beginResponse(200, _encoding_text, somfy.backupData);
response->addHeader("Content-Disposition", filename); response->addHeader("Content-Disposition", filename);
#ifdef WEB_CORS
response->addHeader("Access-Control-Expose-Headers", "Content-Disposition"); response->addHeader("Access-Control-Expose-Headers", "Content-Disposition");
#endif
request->send(response); request->send(response);
} }
else { else {
@ -404,13 +392,29 @@ static void timeRequest(AsyncWebServerRequest *request, ArMiddlewareNext next) {
next(); next();
logRequestTime(request, ip, "handler", timer.us()); logRequestTime(request, ip, "handler", timer.us());
} }
// Under Secured every route but the public ones needs the token. This runs
// once the body is in, after an upload's chunks, so the uploads check the
// token themselves as well (uploadAllowed in HttpRoutes.cpp).
static void requireToken(AsyncWebServerRequest *request, ArMiddlewareNext next) {
if(isPublicRoute(request->url().c_str(), request->method() == HTTP_OPTIONS) || webServer.isAuthenticated(request)) {
next();
return;
}
request->send(401, _encoding_text, "Unauthorized API Key");
}
void Web::begin() { void Web::begin() {
ESP_LOGI(TAG, "Creating Web MicroServices..."); ESP_LOGI(TAG, "Creating Web MicroServices...");
asyncServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next); }); asyncServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next); });
asyncServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { requireToken(r, next); });
#ifdef WEB_CORS
// For work on the UI from a file:// page (baseUrl in index.js). A release
// leaves them out: the UI comes from the device, and HA and scripts aren't
// browsers.
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*");
#endif
// Before the routes, so serveStatic never answers /ws. // Before the routes, so serveStatic never answers /ws.
protoSocket.begin(asyncServer); protoSocket.begin(asyncServer);
registerHttpRoutes(asyncServer); registerHttpRoutes(asyncServer);

View file

@ -16,7 +16,10 @@ class Web {
bool createAPIToken(const char *payload, char *token); bool createAPIToken(const char *payload, char *token);
bool createAPIPinToken(const IPAddress ipAddress, const char *pin, char *token); bool createAPIPinToken(const IPAddress ipAddress, const char *pin, char *token);
bool createAPIPasswordToken(const IPAddress ipAddress, const char *username, const char *password, char *token); bool createAPIPasswordToken(const IPAddress ipAddress, const char *username, const char *password, char *token);
bool isAuthenticated(AsyncWebServerRequest *request, bool cfg = false); // True under None, or when given is the token for the request's client.
bool tokenAccepted(AsyncWebServerRequest *request, const char *given);
// tokenAccepted with the apikey header, which is where HTTP sends it.
bool isAuthenticated(AsyncWebServerRequest *request);
// HTTP handlers // HTTP handlers
void handleLogin(AsyncWebServerRequest *request, JsonVariant &json); void handleLogin(AsyncWebServerRequest *request, JsonVariant &json);

View file

@ -22,6 +22,7 @@
#include "ESPNetwork.h" #include "ESPNetwork.h"
#include "Utils.h" #include "Utils.h"
#include "../entryPoints/Web.h" #include "../entryPoints/Web.h"
#include "HttpAccess.h"
#if HAS_ETHERNET #if HAS_ETHERNET
#include <ETH.h> #include <ETH.h>
#endif #endif
@ -260,13 +261,16 @@ static void queueDeviceOp(AsyncWebSocketClient *client, const proto_message_t &m
if(item.save) freeSettingsSave(item.save); if(item.save) freeSettingsSave(item.save);
replyError(client, msg.id, proto_error_t::busy, "Command queue full"); replyError(client, msg.id, proto_error_t::busy, "Command queue full");
} }
// A browser can't set headers on a socket, so the token comes in the query.
static bool authorized(AsyncWebServerRequest *request) { static bool authorized(AsyncWebServerRequest *request) {
if(settings.Security.type == security_types::None) return true; #ifndef WEB_CORS
// A UI opened from a file:// page sends Origin: null, so WEB_CORS skips it.
const AsyncWebHeader *origin = request->getHeader("Origin");
const AsyncWebHeader *host = request->getHeader("Host");
if(!originMatchesHost(origin ? origin->value().c_str() : nullptr, host ? host->value().c_str() : nullptr)) return false;
#endif
const AsyncWebParameter *key = request->getParam("apikey"); const AsyncWebParameter *key = request->getParam("apikey");
if(!key || !request->client()) return false; return webServer.tokenAccepted(request, key ? key->value().c_str() : nullptr);
char token[API_TOKEN_SIZE];
webServer.createAPIToken(request->client()->remoteIP(), token);
return tokenMatches(token, key->value().c_str());
} }
static void onConnect(AsyncWebSocketClient *client, AsyncWebServerRequest *request) { static void onConnect(AsyncWebSocketClient *client, AsyncWebServerRequest *request) {
const uint32_t id = client->id(); const uint32_t id = client->id();

View file

@ -79,14 +79,15 @@ class Schema:
class ProtoClient: class ProtoClient:
def __init__(self, host, port=80, path="/ws", params=None, timeout=10): def __init__(self, host, port=80, path="/ws", params=None, timeout=10, headers=None):
self.host = host self.host = host
query = ("?" + urllib.parse.urlencode(params)) if params else "" query = ("?" + urllib.parse.urlencode(params)) if params else ""
self.sock = socket.create_connection((host, port), timeout=timeout) self.sock = socket.create_connection((host, port), timeout=timeout)
key = base64.b64encode(os.urandom(16)).decode() key = base64.b64encode(os.urandom(16)).decode()
extra = "".join(f"{k}: {v}\r\n" for k, v in (headers or {}).items())
self.sock.sendall(( self.sock.sendall((
f"GET {path}{query} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n" f"GET {path}{query} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode()) f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n{extra}\r\n").encode())
head = b"" head = b""
while b"\r\n\r\n" not in head: while b"\r\n\r\n" not in head:
chunk = self.sock.recv(1024) chunk = self.sock.recv(1024)

127
test/e2e/test_routes.py Normal file
View file

@ -0,0 +1,127 @@
"""The HTTP routes and the socket's Origin check under the device's security
mode (docs/adr/0006-two-security-modes-eleven-http-routes.md).
Like test_auth, it tests the mode it finds: pass --password or --pin for a
Secured device.
"""
import time
import urllib.error
import urllib.request
import pytest
from proto_client import ProtoClient, heap
# Every route that needs the token under Secured, sent without a body or
# parameters, so a route that let one through would still change nothing.
TOKEN_ROUTES = [
("GET", "/backup"),
("GET", "/shadeCommand"),
("GET", "/groupCommand"),
("GET", "/tiltCommand"),
("PUT", "/shadeCommand"),
("PUT", "/groupCommand"),
("PUT", "/tiltCommand"),
]
PUBLIC_ROUTES = ["/", "/index.html", "/index.js", "/loginContext", "/upnp.xml"]
SHADE_FILES = ["/shades.cfg", "/shades.tmp"]
def http(host, path, method="GET", body=None, headers=None):
"""The status and headers of a request, refusals included."""
req = urllib.request.Request(f"http://{host}{path}", data=body, method=method, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=20) as r:
r.read()
return r.status, r.headers
except urllib.error.HTTPError as e:
e.read()
return e.code, e.headers
def upload(host, path, headers=None):
"""Posts a small file that isn't a backup or an image."""
boundary = "e2e-upload"
body = (
f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"e2e.bin\"\r\n"
"Content-Type: application/octet-stream\r\n\r\n").encode() + b"\0" * 256 + f"\r\n--{boundary}--\r\n".encode()
return http(host, path, "POST", body, {"Content-Type": f"multipart/form-data; boundary={boundary}", **(headers or {})})
@pytest.mark.parametrize("method,path", TOKEN_ROUTES)
def test_secured_refuses_a_route_without_a_token(host, secured, method, path):
if not secured:
pytest.skip("the device's security is None")
body = b"{}" if method == "PUT" else None
status, _ = http(host, path, method, body, {"Content-Type": "application/json"} if body else None)
assert status == 401
@pytest.mark.parametrize("method,path", TOKEN_ROUTES)
def test_secured_refuses_a_route_with_a_wrong_token(host, secured, method, path):
if not secured:
pytest.skip("the device's security is None")
body = b"{}" if method == "PUT" else None
headers = {"apikey": "0" * 64}
if body:
headers["Content-Type"] = "application/json"
status, _ = http(host, path, method, body, headers)
assert status == 401
def test_secured_accepts_the_token_on_a_route(host, secured, token):
if not secured:
pytest.skip("the device's security is None")
status, _ = http(host, "/backup", headers={"apikey": token})
assert status == 200
@pytest.mark.parametrize("path", ["/restore", "/updateFirmware"])
def test_secured_drops_an_upload_without_a_token(host, secured, token, path):
"""The token check runs once the body is in, so the uploads check it on their first chunk.
An /updateFirmware that got through would stop the radio and reboot the
device once the connection closed. /updateApplication isn't sent: one
that got through would write over the file system.
"""
if not secured:
pytest.skip("the device's security is None")
before = heap(host, token)
status, _ = upload(host, path)
assert status == 401
after = heap(host, token)
assert after["uptime"] >= before["uptime"], "the device rebooted"
@pytest.mark.parametrize("path", PUBLIC_ROUTES)
def test_public_routes_need_no_token(host, path):
status, _ = http(host, path)
assert status == 200
@pytest.mark.parametrize("path", SHADE_FILES)
def test_the_shade_files_are_not_served(host, path):
"""Their rolling codes leave the device only through /backup."""
status, _ = http(host, path)
assert status in (401, 404)
def test_replies_carry_no_cors_headers(host):
_, headers = http(host, "/loginContext", headers={"Origin": "http://example.com"})
assert headers.get("Access-Control-Allow-Origin") is None
def test_a_socket_from_another_site_is_refused(host, token):
params = {"apikey": token} if token else None
c = ProtoClient(host, params=params, headers={"Origin": "http://example.com"})
assert c.status == 401
def test_a_socket_from_the_page_the_device_served_is_accepted(host, token):
# ProtoClient's Host names the port, and so does an Origin that matches it.
params = {"apikey": token} if token else None
c = ProtoClient(host, params=params, headers={"Origin": f"http://{host}:80"})
assert c.status == 101
c.close()
# The device frees the slot when it sees the disconnect.
time.sleep(0.5)

View file

@ -0,0 +1,138 @@
#include <unity.h>
#include "HttpAccess.h"
void setUp() {}
void tearDown() {}
/*********************************************************************
* Web assets
********************************************************************/
static void test_the_web_ui_files_are_web_assets() {
TEST_ASSERT_TRUE(isWebAsset("/"));
TEST_ASSERT_TRUE(isWebAsset("/index.html"));
TEST_ASSERT_TRUE(isWebAsset("/index.js"));
TEST_ASSERT_TRUE(isWebAsset("/app.css"));
TEST_ASSERT_TRUE(isWebAsset("/favicon.png"));
TEST_ASSERT_TRUE(isWebAsset("/apple-icon.png"));
TEST_ASSERT_TRUE(isWebAsset("/icon.svg"));
TEST_ASSERT_TRUE(isWebAsset("/appversion"));
}
static void test_the_shade_files_are_not_web_assets() {
TEST_ASSERT_FALSE(isWebAsset("/shades.cfg"));
TEST_ASSERT_FALSE(isWebAsset("/shades.tmp"));
}
static void test_a_compressed_file_is_not_asked_for_by_name() {
// serveStatic finds index.js.gz for /index.js.
TEST_ASSERT_FALSE(isWebAsset("/index.js.gz"));
}
static void test_a_type_alone_is_not_a_web_asset() {
TEST_ASSERT_FALSE(isWebAsset("/.js"));
TEST_ASSERT_FALSE(isWebAsset(".js"));
TEST_ASSERT_FALSE(isWebAsset("/js"));
}
static void test_a_path_leaving_the_root_is_not_a_web_asset() {
TEST_ASSERT_FALSE(isWebAsset("/../shades.cfg.js"));
TEST_ASSERT_FALSE(isWebAsset("/x/../index.js"));
}
static void test_a_missing_or_relative_path_is_not_a_web_asset() {
TEST_ASSERT_FALSE(isWebAsset(nullptr));
TEST_ASSERT_FALSE(isWebAsset(""));
TEST_ASSERT_FALSE(isWebAsset("index.html"));
}
/*********************************************************************
* Public routes
********************************************************************/
static void test_logging_in_and_ssdp_are_public() {
TEST_ASSERT_TRUE(isPublicRoute("/loginContext", false));
TEST_ASSERT_TRUE(isPublicRoute("/login", false));
TEST_ASSERT_TRUE(isPublicRoute("/upnp.xml", false));
}
static void test_the_socket_checks_its_own_token() {
TEST_ASSERT_TRUE(isPublicRoute("/ws", false));
}
static void test_the_web_ui_is_public() {
TEST_ASSERT_TRUE(isPublicRoute("/", false));
TEST_ASSERT_TRUE(isPublicRoute("/index.js", false));
}
static void test_the_other_routes_need_the_token() {
static const char *const routes[] = {"/backup", "/restore", "/updateFirmware", "/updateApplication",
"/shadeCommand", "/groupCommand", "/tiltCommand"};
for(const char *route : routes) TEST_ASSERT_FALSE_MESSAGE(isPublicRoute(route, false), route);
}
static void test_an_unknown_path_needs_the_token() {
TEST_ASSERT_FALSE(isPublicRoute("/shades.cfg", false));
TEST_ASSERT_FALSE(isPublicRoute("/controller", false));
TEST_ASSERT_FALSE(isPublicRoute(nullptr, false));
}
static void test_a_route_differing_in_case_or_length_needs_the_token() {
TEST_ASSERT_FALSE(isPublicRoute("/Login", false));
TEST_ASSERT_FALSE(isPublicRoute("/login/", false));
TEST_ASSERT_FALSE(isPublicRoute("/logins", false));
TEST_ASSERT_FALSE(isPublicRoute("/ws/", false));
}
static void test_a_preflight_is_public() {
TEST_ASSERT_TRUE(isPublicRoute("/backup", true));
}
/*********************************************************************
* Origin and Host
********************************************************************/
static void test_the_page_the_device_served_matches() {
TEST_ASSERT_TRUE(originMatchesHost("http://192.168.1.20", "192.168.1.20"));
TEST_ASSERT_TRUE(originMatchesHost("http://espsomfyrts.local", "espsomfyrts.local"));
TEST_ASSERT_TRUE(originMatchesHost("http://192.168.1.20:8000", "192.168.1.20:8000"));
}
static void test_https_behind_a_proxy_matches() {
TEST_ASSERT_TRUE(originMatchesHost("https://somfy.example.com", "somfy.example.com"));
}
static void test_host_names_match_whatever_their_case() {
TEST_ASSERT_TRUE(originMatchesHost("http://ESPSomfyRTS.local", "espsomfyrts.local"));
TEST_ASSERT_TRUE(originMatchesHost("HTTP://192.168.1.20", "192.168.1.20"));
}
static void test_another_site_does_not_match() {
TEST_ASSERT_FALSE(originMatchesHost("http://evil.example", "192.168.1.20"));
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.20.evil.example", "192.168.1.20"));
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.2", "192.168.1.20"));
}
static void test_another_port_does_not_match() {
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.20:8000", "192.168.1.20"));
}
static void test_a_page_opened_from_a_file_does_not_match() {
TEST_ASSERT_FALSE(originMatchesHost("null", "192.168.1.20"));
TEST_ASSERT_FALSE(originMatchesHost("file://", "192.168.1.20"));
}
static void test_an_origin_without_a_host_does_not_match() {
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.20", ""));
TEST_ASSERT_FALSE(originMatchesHost("http://", ""));
TEST_ASSERT_FALSE(originMatchesHost("http://192.168.1.20", nullptr));
}
static void test_a_client_that_is_not_a_browser_sends_no_origin() {
TEST_ASSERT_TRUE(originMatchesHost(nullptr, "192.168.1.20"));
}
int main(int, char **) {
UNITY_BEGIN();
RUN_TEST(test_the_web_ui_files_are_web_assets);
RUN_TEST(test_the_shade_files_are_not_web_assets);
RUN_TEST(test_a_compressed_file_is_not_asked_for_by_name);
RUN_TEST(test_a_type_alone_is_not_a_web_asset);
RUN_TEST(test_a_path_leaving_the_root_is_not_a_web_asset);
RUN_TEST(test_a_missing_or_relative_path_is_not_a_web_asset);
RUN_TEST(test_logging_in_and_ssdp_are_public);
RUN_TEST(test_the_socket_checks_its_own_token);
RUN_TEST(test_the_web_ui_is_public);
RUN_TEST(test_the_other_routes_need_the_token);
RUN_TEST(test_an_unknown_path_needs_the_token);
RUN_TEST(test_a_route_differing_in_case_or_length_needs_the_token);
RUN_TEST(test_a_preflight_is_public);
RUN_TEST(test_the_page_the_device_served_matches);
RUN_TEST(test_https_behind_a_proxy_matches);
RUN_TEST(test_host_names_match_whatever_their_case);
RUN_TEST(test_another_site_does_not_match);
RUN_TEST(test_another_port_does_not_match);
RUN_TEST(test_a_page_opened_from_a_file_does_not_match);
RUN_TEST(test_an_origin_without_a_host_does_not_match);
RUN_TEST(test_a_client_that_is_not_a_browser_sends_no_origin);
return UNITY_END();
}