fix: close the token bypass below a route, review fixes for 4b

The routes matched any path below them, and a web asset name there
passed the token check: GET /backup/a.js returned the backup under
Secured. Every route is now an exact match, and a web asset sits in
the root only.

- e2e: those paths answer 401 or 404; the restore test now fails when
  the chunks skip the token check (checked with the check removed).
- WEB_CORS lets the socket take Origin: null instead of skipping the
  Origin check.
- ADR 0006, GLOSSARY and README: the Origin check in either mode, /ws
  as a public route, the Host header behind a reverse proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-28 19:39:51 +01:00
parent ecb0260f61
commit 0ee52d6d9f
10 changed files with 105 additions and 51 deletions

View file

@ -7,10 +7,11 @@
// (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.
// The files the web UI loads, which all sit in the root. Static serving
// answers these alone, so the rolling codes in shades.cfg leave the device
// only through /backup. A path below a route, such as /backup/a.js, isn't one.
inline bool isWebAsset(const char *path) {
if(!path || path[0] != '/' || strstr(path, "..")) return false;
if(!path || path[0] != '/' || strchr(path + 1, '/') || 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);

View file

@ -52,8 +52,10 @@ static bool uploadAllowed(AsyncWebServerRequest *request, size_t index) {
if(index == 0) request->setAttribute("uploadAllowed", webServer.isAuthenticated(request));
return request->getAttribute("uploadAllowed", false);
}
// Every route is an exact match. The library's default also takes any path
// below the route, and /backup/a.js then passed the token check as a web asset.
void registerHttpRoutes(AsyncWebServer &server) {
server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) {
server.on(AsyncURIMatcher::exact("/loginContext"), HTTP_GET, [](AsyncWebServerRequest *request) {
AsyncJsonResponse *response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>();
root["type"] = static_cast<uint8_t>(settings.Security.type);
@ -65,31 +67,31 @@ void registerHttpRoutes(AsyncWebServer &server) {
request->send(response);
});
// Commands for scripts, as a JSON body or query parameters.
server.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand",
server.addHandler(new AsyncCallbackJsonWebHandler(AsyncURIMatcher::exact("/shadeCommand"),
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleShadeCommand(request, json); }));
server.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleShadeCommand(request, v); });
server.on(AsyncURIMatcher::exact("/shadeCommand"), HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleShadeCommand(request, v); });
server.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand",
server.addHandler(new AsyncCallbackJsonWebHandler(AsyncURIMatcher::exact("/groupCommand"),
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleGroupCommand(request, json); }));
server.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleGroupCommand(request, v); });
server.on(AsyncURIMatcher::exact("/groupCommand"), HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleGroupCommand(request, v); });
server.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand",
server.addHandler(new AsyncCallbackJsonWebHandler(AsyncURIMatcher::exact("/tiltCommand"),
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleTiltCommand(request, json); }));
server.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); });
server.on(AsyncURIMatcher::exact("/tiltCommand"), HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); });
server.on("/upnp.xml", HTTP_GET, [](AsyncWebServerRequest *request) {
server.on(AsyncURIMatcher::exact("/upnp.xml"), HTTP_GET, [](AsyncWebServerRequest *request) {
AsyncResponseStream *response = request->beginResponseStream("text/xml");
SSDP.schema(*response);
request->send(response);
});
server.addHandler(new AsyncCallbackJsonWebHandler("/login",
server.addHandler(new AsyncCallbackJsonWebHandler(AsyncURIMatcher::exact("/login"),
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleLogin(request, json); }));
server.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleLogin(request, v); });
server.on(AsyncURIMatcher::exact("/login"), HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleLogin(request, v); });
server.on("/backup", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); });
server.on(AsyncURIMatcher::exact("/backup"), HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); });
server.on("/restore", HTTP_POST,
server.on(AsyncURIMatcher::exact("/restore"), HTTP_POST,
[](AsyncWebServerRequest *request) {
if(webServer.uploadSuccess) {
restore_options_t opts;
@ -148,7 +150,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
});
// updateFirmware - file upload
server.on("/updateFirmware", HTTP_POST,
server.on(AsyncURIMatcher::exact("/updateFirmware"), HTTP_POST,
[](AsyncWebServerRequest *request) {
if(Update.hasError())
request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}");
@ -190,7 +192,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
});
// updateApplication - file upload
server.on("/updateApplication", HTTP_POST,
server.on(AsyncURIMatcher::exact("/updateApplication"), HTTP_POST,
[](AsyncWebServerRequest *request) {
if(Update.hasError())
request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}");

View file

@ -263,12 +263,14 @@ static void queueDeviceOp(AsyncWebSocketClient *client, const proto_message_t &m
}
// A browser can't set headers on a socket, so the token comes in the query.
static bool authorized(AsyncWebServerRequest *request) {
#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;
const char *from = origin ? origin->value().c_str() : nullptr;
#ifdef WEB_CORS
// The UI opened from a file:// page sends Origin: null.
if(from && strcmp(from, "null") == 0) from = nullptr;
#endif
if(!originMatchesHost(from, host ? host->value().c_str() : nullptr)) return false;
const AsyncWebParameter *key = request->getParam("apikey");
return webServer.tokenAccepted(request, key ? key->value().c_str() : nullptr);
}