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

@ -408,7 +408,6 @@ bool SecuritySettings::fromJSON(JsonObject &obj) {
this->parseValueString(obj, "username", this->username, sizeof(this->username));
applySecret(obj["password"].as<const char *>(), this->password, sizeof(this->password));
applySecret(obj["pin"].as<const char *>(), this->pin, sizeof(this->pin));
if(!obj["permissions"].isNull()) this->permissions = obj["permissions"];
return true;
}
bool SecuritySettings::save() {
@ -419,10 +418,11 @@ bool SecuritySettings::save() {
pref.putString("username", this->username);
pref.putString("password", this->password);
pref.putString("pin", this->pin);
pref.putChar("permissions", this->permissions);
pref.end();
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() {
Preferences pref;
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("password")) pref.getString("password", this->password, sizeof(this->password));
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();
return true;
}
void SecuritySettings::print() {
ESP_LOGD(TAG, "SECURITY Type:%u Username:[%s] Password:[%s] Pin:[%s] Permissions:%u",
static_cast<uint8_t>(this->type), this->username, maskSecret(this->password), maskSecret(this->pin), this->permissions);
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));
}
WifiSettings::WifiSettings() {}

View file

@ -127,16 +127,12 @@ enum class security_types : byte {
PinEntry = 0x01,
Password = 0x02
};
enum class security_permissions : byte {
ConfigOnly = 0x01
};
class SecuritySettings: BaseSettings {
public:
security_types type = security_types::None;
char username[33] = "";
char password[33] = "";
char pin[5] = "";
uint8_t permissions = 0;
bool begin();
bool save();
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::hasPassword, hasSecret(settings.Security.password));
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;
case settings_section_t::radio: {
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 "WebRoutes.h"
#include "MqttConnection.h"
#include "HttpAccess.h"
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
@ -44,12 +45,18 @@ static void onUploadDisconnect() {
rebootDelay.reboot = true;
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) {
server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) {
AsyncJsonResponse *response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>();
root["type"] = static_cast<uint8_t>(settings.Security.type);
root["permissions"] = settings.Security.permissions;
root["serverId"] = settings.serverId;
root["version"] = settings.fwVersion.name;
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.\"}"));
},
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
if(!uploadAllowed(request, index)) return;
resetTaskWdt();
if(index == 0) {
webServer.uploadSuccess = false;
@ -150,6 +158,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
rebootDelay.rebootTime = millis() + 500;
},
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
if(!uploadAllowed(request, index)) return;
if(index == 0) {
webServer.uploadSuccess = false;
ESP_LOGI(TAG, "Update: %s", filename.c_str());
@ -191,6 +200,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
rebootDelay.rebootTime = millis() + 500;
},
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
if(!uploadAllowed(request, index)) return;
if(index == 0) {
webServer.uploadSuccess = false;
ESP_LOGI(TAG, "Update: %s", filename.c_str());
@ -223,12 +233,10 @@ void registerHttpRoutes(AsyncWebServer &server) {
resetTaskWdt();
});
server.onNotFound([](AsyncWebServerRequest *r) {
if(r->method() == HTTP_OPTIONS) { r->send(200); return; }
webServer.handleNotFound(r);
});
server.onNotFound([](AsyncWebServerRequest *r) { webServer.handleNotFound(r); });
// 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 "Timing.h"
#include "LoginThrottle.h"
#include "HttpAccess.h"
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
@ -92,27 +93,16 @@ void Web::startup() {
void Web::loop() {
delay(1);
}
bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) {
ESP_LOGD(TAG, "Checking async authentication");
bool Web::tokenAccepted(AsyncWebServerRequest *request, const char *given) {
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;
else if(request->hasHeader("apikey")) {
ESP_LOGD(TAG, "Checking API Key...");
char token[API_TOKEN_SIZE];
memset(token, 0x00, sizeof(token));
this->createAPIToken(request->client()->remoteIP(), token);
if(!tokenMatches(token, request->getHeader("apikey")->value().c_str())) {
request->send(401, _encoding_text, "Unauthorized API Key");
return false;
}
// Key is valid
}
else {
ESP_LOGE(TAG, "Not authenticated...");
request->send(401, _encoding_text, "Unauthorized API Key");
return false;
}
return true;
if(!given || !request->client()) return false;
char token[API_TOKEN_SIZE];
this->createAPIToken(request->client()->remoteIP(), token);
return tokenMatches(token, given);
}
bool Web::isAuthenticated(AsyncWebServerRequest *request) {
const AsyncWebHeader *key = request->getHeader("apikey");
return this->tokenAccepted(request, key ? key->value().c_str() : nullptr);
}
bool Web::createAPIPinToken(const IPAddress ipAddress, const char *pin, char *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) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return;
shade_request_t req;
req.type = shade_request_type_t::shade_command;
// Try query params
@ -279,7 +268,6 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json)
}
void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return;
shade_request_t req;
req.type = shade_request_type_t::group_command;
if(asyncHasParam(request, "groupId")) {
@ -315,7 +303,6 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json)
}
void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
if(!this->isAuthenticated(request)) return;
shade_request_t req;
req.type = shade_request_type_t::tilt_command;
if(asyncHasParam(request, "shadeId")) {
@ -348,7 +335,6 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
}
}
void Web::handleBackup(AsyncWebServerRequest *request) {
if(!this->isAuthenticated(request)) return;
bool attach = false;
if(asyncHasParam(request, "attach")) attach = toBoolean(asyncParam(request, "attach").c_str(), false);
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);
AsyncWebServerResponse *response = request->beginResponse(200, _encoding_text, somfy.backupData);
response->addHeader("Content-Disposition", filename);
#ifdef WEB_CORS
response->addHeader("Access-Control-Expose-Headers", "Content-Disposition");
#endif
request->send(response);
}
else {
@ -404,13 +392,29 @@ static void timeRequest(AsyncWebServerRequest *request, ArMiddlewareNext next) {
next();
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() {
ESP_LOGI(TAG, "Creating Web MicroServices...");
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-Methods", "PUT,POST,GET,OPTIONS");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*");
#endif
// Before the routes, so serveStatic never answers /ws.
protoSocket.begin(asyncServer);
registerHttpRoutes(asyncServer);

View file

@ -16,7 +16,10 @@ class Web {
bool createAPIToken(const char *payload, 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 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
void handleLogin(AsyncWebServerRequest *request, JsonVariant &json);

View file

@ -22,6 +22,7 @@
#include "ESPNetwork.h"
#include "Utils.h"
#include "../entryPoints/Web.h"
#include "HttpAccess.h"
#if HAS_ETHERNET
#include <ETH.h>
#endif
@ -260,13 +261,16 @@ static void queueDeviceOp(AsyncWebSocketClient *client, const proto_message_t &m
if(item.save) freeSettingsSave(item.save);
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) {
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");
if(!key || !request->client()) return false;
char token[API_TOKEN_SIZE];
webServer.createAPIToken(request->client()->remoteIP(), token);
return tokenMatches(token, key->value().c_str());
return webServer.tokenAccepted(request, key ? key->value().c_str() : nullptr);
}
static void onConnect(AsyncWebSocketClient *client, AsyncWebServerRequest *request) {
const uint32_t id = client->id();