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

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