Replace Web Basic Auth With Cookie Sessions

Add bounded login challenges, CSRF/origin enforcement, logout, and
session-bound WebSocket admission. Isolate private HTTPD access behind a
version-guarded adapter and add focused host coverage. Also let empty
admin
SSH input reach the normal console handler.
This commit is contained in:
2026-09-05 23:55:05 +02:00
parent 4435a7fddd
commit 5a609fa40b
36 changed files with 1940 additions and 360 deletions
+12
View File
@@ -36,6 +36,8 @@ idf_component_register(
"web_server.c"
"web_session_store.c"
"web_auth_parse.c"
"web_httpd_adapter.c"
"web_cookie_auth.c"
"web_login_ui.c"
"web_console.c"
"wifi_config.c"
@@ -70,6 +72,16 @@ idf_component_register(
wolfssl__wolfssl
)
# Only web_httpd_adapter.c uses this private, version-checked boundary.
target_include_directories(${COMPONENT_LIB} PRIVATE
"$ENV{IDF_PATH}/components/esp_http_server/src"
"$ENV{IDF_PATH}/components/esp_http_server/src/port/esp32")
# HTTPD debug logs include header values; URI warnings include ticket queries.
# Compile those out, independently of runtime log-level changes.
idf_component_get_property(httpd_lib esp_http_server COMPONENT_LIB)
target_compile_definitions(${httpd_lib} PRIVATE LOG_LOCAL_LEVEL=ESP_LOG_ERROR)
# Public wolfSSH headers include wolfCrypt configuration from user_settings.h.
target_compile_definitions(${COMPONENT_LIB} PRIVATE
WOLFSSL_USER_SETTINGS
+3 -2
View File
@@ -439,8 +439,9 @@ static bool remote_command_allowed(const admin_request_t *request)
char *argv[ADMIN_SSH_CONSOLE_MAX_ARGUMENTS] = {0};
/* Use exactly the same quote/escape parser as esp_console_run(). */
size_t argc = esp_console_split_argv(copy, argv, ADMIN_SSH_CONSOLE_MAX_ARGUMENTS);
bool allowed = argc > 0U;
if (allowed && strcmp(argv[0], "user") == 0 && argc >= 2U &&
/* Empty input is handled quietly by esp_console_run(), not UART0 policy. */
bool allowed = true;
if (argc >= 2U && strcmp(argv[0], "user") == 0 &&
(strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) {
allowed = false;
}
+13 -3
View File
@@ -61,9 +61,11 @@ static bool cookie_name_char(unsigned char c)
return alnum_ascii(c) || (c && strchr("!#$%&'*+-.^_`|~", c));
}
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U])
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present)
{
if (!present) return false;
*present = false;
if (!token) return false;
memset(token, 0, WEB_AUTH_TOKEN_LENGTH + 1U);
if (!header || !name || !*name || !length || length > WEB_AUTH_COOKIE_HEADER_MAX)
@@ -92,7 +94,15 @@ bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
if (pos < length && ++pos == length) return false;
}
if (found) memcpy(token, header + selected, WEB_AUTH_TOKEN_LENGTH);
return found;
*present = found;
return true;
}
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U])
{
bool present = false;
return web_auth_parse_optional_cookie(header, length, name, token, &present) && present;
}
typedef struct { const uint8_t *data; size_t length; size_t pos; } json_cursor_t;
+4
View File
@@ -36,6 +36,10 @@ bool web_auth_parse_origin(const char *host, size_t host_length,
* name is a trusted, nonempty C string. Output is sensitive: wipe after use. */
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U]);
/* As above, but a missing selected cookie is valid with present=false. This
* lets HTTP policy distinguish absence from malformed/ambiguous cookies. */
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present);
/* Exactly username/password string fields, either order. JSON escapes and valid
* UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected.
* Database credential policy remains authoritative. Caller must wipe BOTH the
+13 -1
View File
@@ -14,6 +14,7 @@
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_server.h"
#include "web_cookie_auth.h"
static void print_usage(void)
{
@@ -52,13 +53,24 @@ static int show_status(void)
(unsigned int)snapshot.port,
esp_err_to_name(snapshot.last_error));
if (users_error == ESP_OK) {
printf("Authentication: HTTP Basic over TLS via user database, users=%u admins=%u\n",
printf("Authentication: HTTPS cookie sessions via user database, users=%u admins=%u\n",
(unsigned int)users.user_count, (unsigned int)users.admin_count);
} else {
printf("Authentication database unavailable: %s; use 'user recover --force'.\n",
esp_err_to_name(users_error));
}
printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n");
printf("Authentication routes: GET /login, GET /api/login-challenge, POST /api/login, GET /api/session, POST /api/logout\n");
web_cookie_auth_snapshot_t auth;
web_cookie_auth_get_snapshot(&auth);
web_session_store_snapshot_t sessions;
if (web_session_store_get_snapshot(&sessions) == ESP_OK)
printf("Cookie authentication: ready=%s sessions=%" PRIu32 "/4 challenges=%" PRIu32 "/4\n",
auth.ready ? "yes" : "no", sessions.active, auth.active_challenges);
printf("Login attempts=%" PRIu32 " invalid-credentials=%" PRIu32 " throttled=%" PRIu32
" auth-capacity-rejections=%" PRIu32 " CSRF/origin-rejections=%" PRIu32 " logouts=%" PRIu32 "\n",
auth.login_attempts, auth.login_failures, auth.throttled, auth.capacity_rejections,
auth.security_rejections, auth.logouts);
web_serial_transport_snapshot_t transport;
esp_err_t transport_error = web_serial_transport_get_snapshot(&transport);
+417
View File
@@ -0,0 +1,417 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_cookie_auth.h"
#include <stdio.h>
#include <string.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mbedtls/sha256.h"
#include "secure_random.h"
#include "web_auth_parse.h"
#include "web_httpd_adapter.h"
#include "web_login_ui.h"
#include "web_serial_transport.h"
#define SESSION_COOKIE "__Host-sak-session"
#define PRELOGIN_COOKIE "__Host-sak-prelogin"
#define COOKIE_FLAGS "; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age="
#define CHALLENGE_US 120000000LL
#define WINDOW_US 60000000LL
typedef struct {
int64_t expiry;
uint8_t token_digest[32];
uint8_t origin_digest[32];
char csrf[65];
} challenge_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static challenge_t s_challenges[4];
static bool s_ready;
static uint64_t s_epoch;
static int64_t s_window;
static unsigned s_attempts;
static web_cookie_auth_snapshot_t s_counts;
static bool equal(const void *a, const void *b, size_t size)
{
const uint8_t *x = a, *y = b;
unsigned difference = 0;
for (size_t i = 0; i < size; ++i) difference |= x[i] ^ y[i];
return difference == 0;
}
static bool header(httpd_req_t *r, const char *name, char *out, size_t size)
{
size_t length = httpd_req_get_hdr_value_len(r, name);
out[0] = 0;
return length < size && httpd_req_get_hdr_value_str(r, name, out, size) == ESP_OK;
}
static bool origin(httpd_req_t *r, bool required, char canonical[129])
{
char host[129] = {0}, supplied[137] = {0}, site[16] = {0};
if (!header(r, "Host", host, sizeof(host))) return false;
if (header(r, "Sec-Fetch-Site", site, sizeof(site))) {
if (strcmp(site, "same-origin") && strcmp(site, "none")) return false;
} else if (httpd_req_get_hdr_value_len(r, "Sec-Fetch-Site")) return false;
if (!header(r, "Origin", supplied, sizeof(supplied))) {
if (required || httpd_req_get_hdr_value_len(r, "Origin")) return false;
int length = snprintf(supplied, sizeof(supplied), "https://%s", host);
if (length < 0 || (size_t)length >= sizeof(supplied)) return false;
}
return web_auth_parse_origin(host, strlen(host), supplied, strlen(supplied), canonical);
}
static bool cookie(httpd_req_t *r, const char *name, char token[65])
{
char cookies[1025] = {0};
bool valid = header(r, "Cookie", cookies, sizeof(cookies)) &&
web_auth_parse_cookie(cookies, strlen(cookies), name, token);
secure_wipe(cookies, sizeof(cookies));
return valid;
}
static bool cookies_valid(httpd_req_t *r)
{
char cookies[1025] = {0}, token[65] = {0};
bool present;
esp_err_t error = httpd_req_get_hdr_value_str(r, "Cookie", cookies, sizeof(cookies));
bool valid = error == ESP_ERR_NOT_FOUND ||
(error == ESP_OK && httpd_req_get_hdr_value_len(r, "Cookie") < sizeof(cookies) &&
web_auth_parse_optional_cookie(cookies, strlen(cookies), SESSION_COOKIE, token, &present) &&
web_auth_parse_optional_cookie(cookies, strlen(cookies), PRELOGIN_COOKIE, token, &present));
secure_wipe(cookies, sizeof(cookies));
secure_wipe(token, sizeof(token));
return valid;
}
static esp_err_t response(httpd_req_t *r, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(r, status);
if (error == ESP_OK) error = httpd_resp_set_type(r, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Frame-Options", "DENY");
if (error == ESP_OK) error = httpd_resp_sendstr(r, body);
/* Never let HTTPD drain an attacker-controlled rejected request body. */
return web_httpd_unread_body(r) ? ESP_FAIL : error;
}
static esp_err_t failure(httpd_req_t *r, const char *status, const char *code)
{
bool security = !strcmp(status, "403 Forbidden");
bool credentials = !strcmp(code, "invalid_credentials");
bool throttled = !strcmp(code, "throttled");
bool full = !strcmp(code, "capacity");
taskENTER_CRITICAL(&s_lock);
s_counts.security_rejections += security;
s_counts.login_failures += credentials;
s_counts.throttled += throttled;
s_counts.capacity_rejections += full;
taskEXIT_CRITICAL(&s_lock);
char body[80];
snprintf(body, sizeof(body), "{\"error\":\"%s\"}", code);
return response(r, status, body);
}
static esp_err_t capacity(httpd_req_t *r)
{
if (httpd_resp_set_hdr(r, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
return failure(r, "503 Service Unavailable", "capacity");
}
esp_err_t web_cookie_auth_start(void)
{
esp_err_t error = web_session_store_init();
taskENTER_CRITICAL(&s_lock);
if (error == ESP_OK && s_epoch != UINT64_MAX) {
++s_epoch;
secure_wipe(s_challenges, sizeof(s_challenges));
s_window = 0;
s_attempts = 0;
s_ready = true;
} else {
s_ready = false;
error = ESP_ERR_INVALID_STATE;
}
taskEXIT_CRITICAL(&s_lock);
return error;
}
void web_cookie_auth_stop(void)
{
taskENTER_CRITICAL(&s_lock);
s_ready = false;
if (s_epoch != UINT64_MAX) ++s_epoch;
secure_wipe(s_challenges, sizeof(s_challenges));
s_window = 0;
s_attempts = 0;
taskEXIT_CRITICAL(&s_lock);
web_session_store_stop();
}
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot)
{
if (!snapshot) return;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
*snapshot = s_counts;
snapshot->ready = s_ready;
snapshot->active_challenges = 0;
for (unsigned i = 0; i < 4; ++i) {
if (s_challenges[i].expiry <= now) secure_wipe(&s_challenges[i], sizeof(s_challenges[i]));
else ++snapshot->active_challenges;
}
taskEXIT_CRITICAL(&s_lock);
}
void web_cookie_auth_clear_counters(void)
{
taskENTER_CRITICAL(&s_lock);
memset(&s_counts, 0, sizeof(s_counts));
taskEXIT_CRITICAL(&s_lock);
}
esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
web_session_view_t *view, bool *allowed)
{
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
*allowed = false;
memset(view, 0, sizeof(*view));
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) ||
r->content_len || r->method != (mutation ? HTTP_POST : HTTP_GET))
return failure(r, "400 Bad Request", "invalid_request");
if (!origin(r, mutation || upgrade, canonical))
return failure(r, "403 Forbidden", "origin");
taskENTER_CRITICAL(&s_lock);
bool ready = s_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready) return failure(r, "503 Service Unavailable", "unavailable");
esp_err_t error = ESP_ERR_NOT_FOUND;
if (cookie(r, SESSION_COOKIE, token))
error = web_session_store_lookup(token, strlen(token), canonical, strlen(canonical), view);
secure_wipe(token, sizeof(token));
if (error != ESP_OK) {
if (error != ESP_ERR_NOT_FOUND)
return failure(r, "503 Service Unavailable", "unavailable");
if (!strcmp(r->uri, "/")) {
if (httpd_resp_set_hdr(r, "Location", "/login") != ESP_OK) return ESP_FAIL;
return response(r, "303 See Other", "");
}
return failure(r, "401 Unauthorized", "authentication_required");
}
if (mutation && (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) ||
strlen(csrf) != 64U || !equal(csrf, view->csrf, 64U))) {
secure_wipe(csrf, sizeof(csrf));
secure_wipe(view, sizeof(*view));
return failure(r, "403 Forbidden", "csrf");
}
secure_wipe(csrf, sizeof(csrf));
*allowed = true;
return ESP_OK;
}
static bool secret(char out[65])
{
uint8_t bytes[32];
bool ok = secure_random_fill(bytes, sizeof(bytes)) == ESP_OK;
if (ok) {
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < sizeof(bytes); ++i) {
out[2*i] = hex[bytes[i] >> 4];
out[2*i+1] = hex[bytes[i] & 15];
}
out[64] = 0;
}
secure_wipe(bytes, sizeof(bytes));
return ok;
}
esp_err_t web_cookie_auth_handler(httpd_req_t *r)
{
bool login = !strcmp(r->uri, "/api/login");
bool bootstrap = !strcmp(r->uri, "/api/login-challenge");
bool logout = !strcmp(r->uri, "/api/logout");
bool document = !strcmp(r->uri, "/login");
web_session_view_t view = {0};
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
char set_cookie[180] = {0}, body[513] = {0};
web_auth_credentials_t credentials = {0};
challenge_t candidate = {0};
uint8_t digest[32] = {0}, origin_digest[32] = {0};
esp_err_t result = ESP_FAIL;
const char *status = "400 Bad Request", *code = "invalid_request";
bool consumed = false, allowed = false;
bool challenge_published = false;
uint64_t epoch;
int selected = -1;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
bool ready = s_ready;
epoch = s_epoch;
taskEXIT_CRITICAL(&s_lock);
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || strchr(r->uri, '?') ||
r->method != ((login || logout) ? HTTP_POST : HTTP_GET) ||
(!login && r->content_len)) goto deny;
if (document) { result = web_login_ui_send_response(r); goto cleanup; }
if (!login && !bootstrap) {
result = web_cookie_auth_require(r, logout, false, &view, &allowed);
if (!allowed) goto cleanup;
if (logout) {
web_serial_transport_revoke_web_session(view.id);
taskENTER_CRITICAL(&s_lock);
++s_counts.logouts;
taskEXIT_CRITICAL(&s_lock);
result = httpd_resp_set_hdr(r, "Set-Cookie", SESSION_COOKIE "=" COOKIE_FLAGS "0");
if (result != ESP_OK) goto cleanup;
result = response(r, "204 No Content", "");
} else {
/* Database usernames are restricted ASCII; encode nevertheless. */
char username[97] = {0};
size_t used = 0;
for (size_t i = 0; i < view.principal.username_length && i < 16; ++i)
used += (size_t)snprintf(username + used, sizeof(username) - used,
"\\u%04x", (unsigned char)view.principal.username[i]);
int64_t remaining = (view.expires_at_us - esp_timer_get_time()) / 1000000LL;
snprintf(body, sizeof(body), "{\"username\":\"%s\",\"role\":\"%s\",\"csrf\":\"%s\",\"expires_in\":%lld}",
username, view.principal.role == USER_ROLE_ADMIN ? "admin" : "user", view.csrf,
(long long)(remaining > 0 ? remaining : 0));
result = response(r, "200 OK", body);
}
goto cleanup;
}
if (!origin(r, login, canonical)) { status = "403 Forbidden"; code = "origin"; goto deny; }
if (mbedtls_sha256((const uint8_t *)canonical, strlen(canonical), origin_digest, 0)) goto deny;
if (bootstrap) {
char flag[2];
if (!header(r, "X-Login-Bootstrap", flag, sizeof(flag)) || strcmp(flag, "1")) {
status = "403 Forbidden"; code = "csrf"; goto deny;
}
} else {
char type[40];
if (!header(r, "Content-Type", type, sizeof(type)) ||
(strcmp(type, "application/json") && strcmp(type, "application/json; charset=utf-8"))) {
status = "415 Unsupported Media Type"; code = "content_type"; goto deny;
}
if (!r->content_len || r->content_len > 512U) {
status = "413 Payload Too Large"; code = "body_size"; goto deny;
}
if (cookie(r, SESSION_COOKIE, token) &&
web_session_store_lookup(token, 64, canonical, strlen(canonical), &view) == ESP_OK) {
status = "409 Conflict"; code = "already_authenticated"; goto deny;
}
if (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) || strlen(csrf) != 64) {
status = "403 Forbidden"; code = "csrf"; goto deny;
}
}
bool has_cookie = cookie(r, PRELOGIN_COOKIE, token);
if (has_cookie && mbedtls_sha256((const uint8_t *)token, 64, digest, 0)) goto deny;
taskENTER_CRITICAL(&s_lock);
for (int i = 0; i < 4; ++i) {
challenge_t *entry = &s_challenges[i];
if (entry->expiry <= now) secure_wipe(entry, sizeof(*entry));
if (s_ready && epoch == s_epoch && entry->expiry && has_cookie &&
equal(entry->token_digest, digest, 32) && equal(entry->origin_digest, origin_digest, 32)) {
if (bootstrap || equal(entry->csrf, csrf, 64)) {
candidate = *entry;
selected = i;
if (login) { secure_wipe(entry, sizeof(*entry)); consumed = true; }
}
}
}
taskEXIT_CRITICAL(&s_lock);
if (bootstrap) {
bool fresh = selected < 0;
if (fresh) {
if (!secret(token) || !secret(candidate.csrf) ||
mbedtls_sha256((const uint8_t *)token, 64, candidate.token_digest, 0)) {
status = "503 Service Unavailable"; code = "unavailable"; goto deny;
}
memcpy(candidate.origin_digest, origin_digest, 32);
candidate.expiry = now + CHALLENGE_US;
taskENTER_CRITICAL(&s_lock);
if (s_ready && epoch == s_epoch) for (int i = 0; i < 4; ++i) {
if (!s_challenges[i].expiry) {
s_challenges[i] = candidate; selected = i; challenge_published = true; break;
}
}
taskEXIT_CRITICAL(&s_lock);
if (selected < 0) { result = capacity(r); goto cleanup; }
snprintf(set_cookie, sizeof(set_cookie), PRELOGIN_COOKIE "=%s" COOKIE_FLAGS "120", token);
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) goto cleanup;
}
snprintf(body, sizeof(body), "{\"csrf\":\"%s\",\"expires_in\":%lld}", candidate.csrf,
(long long)((candidate.expiry - now) / 1000000LL));
result = response(r, "200 OK", body);
goto cleanup;
}
if (!consumed) { status = "403 Forbidden"; code = "challenge_expired"; goto deny; }
if (httpd_resp_set_hdr(r, "Set-Cookie", PRELOGIN_COOKIE "=" COOKIE_FLAGS "0") != ESP_OK) goto cleanup;
size_t received = 0;
int64_t deadline = now + 3000000LL;
while (received < r->content_len && esp_timer_get_time() < deadline) {
int count = httpd_req_recv(r, body + received, r->content_len - received);
if (count <= 0) goto deny;
received += (size_t)count;
}
if (received != r->content_len || !web_auth_parse_login(body, received, &credentials)) goto deny;
now = esp_timer_get_time();
unsigned attempts;
int64_t retry;
taskENTER_CRITICAL(&s_lock);
ready = s_ready && epoch == s_epoch;
if (now - s_window >= WINDOW_US) { s_window = now; s_attempts = 0; }
attempts = s_attempts;
if (ready && attempts < 5) { ++s_attempts; ++s_counts.login_attempts; }
retry = (s_window + WINDOW_US - now + 999999LL) / 1000000LL;
taskEXIT_CRITICAL(&s_lock);
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
if (attempts >= 5) {
char seconds[16];
snprintf(seconds, sizeof(seconds), "%lld", (long long)retry);
if (httpd_resp_set_hdr(r, "Retry-After", seconds) != ESP_OK) goto cleanup;
result = failure(r, "429 Too Many Requests", "throttled");
goto cleanup;
}
bool authenticated = false;
user_principal_t principal = {0};
esp_err_t error = user_database_authenticate_password(credentials.username, credentials.username_length,
credentials.password, credentials.password_length, &principal, &authenticated);
secure_wipe(body, sizeof(body));
secure_wipe(&credentials, sizeof(credentials));
if (error == ESP_OK && authenticated)
error = web_session_store_issue(&principal, canonical, strlen(canonical), token, &view);
secure_wipe(&principal, sizeof(principal));
if (error == ESP_ERR_NO_MEM) { result = capacity(r); goto cleanup; }
if (error != ESP_OK) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
if (!authenticated) { status = "401 Unauthorized"; code = "invalid_credentials"; goto deny; }
snprintf(set_cookie, sizeof(set_cookie), SESSION_COOKIE "=%s" COOKIE_FLAGS "3600", token);
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) {
web_session_store_invalidate(view.id); goto cleanup;
}
result = response(r, "200 OK", "{\"authenticated\":true}");
if (result != ESP_OK) web_session_store_invalidate(view.id);
goto cleanup;
deny:
result = failure(r, status, code);
cleanup:
if (challenge_published && result != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (epoch == s_epoch && selected >= 0 &&
equal(s_challenges[selected].token_digest, candidate.token_digest, 32))
secure_wipe(&s_challenges[selected], sizeof(s_challenges[selected]));
taskEXIT_CRITICAL(&s_lock);
}
web_httpd_wipe_request(r, web_httpd_unread_body(r));
secure_wipe(&view, sizeof(view));
secure_wipe(token, sizeof(token));
secure_wipe(csrf, sizeof(csrf));
secure_wipe(set_cookie, sizeof(set_cookie));
secure_wipe(body, sizeof(body));
secure_wipe(&credentials, sizeof(credentials));
secure_wipe(&candidate, sizeof(candidate));
secure_wipe(digest, sizeof(digest));
return result;
}
+19
View File
@@ -0,0 +1,19 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_http_server.h"
#include "web_session_store.h"
esp_err_t web_cookie_auth_start(void);
void web_cookie_auth_stop(void);
typedef struct {
uint32_t login_attempts, login_failures, throttled, capacity_rejections;
uint32_t security_rejections, logouts, active_challenges;
bool ready;
} web_cookie_auth_snapshot_t;
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot);
void web_cookie_auth_clear_counters(void);
/* Sends an error on denial, with allowed=false. View is caller-wiped. */
esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
bool upgrade, web_session_view_t *view,
bool *allowed);
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
+106
View File
@@ -0,0 +1,106 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Deliberately isolated dependency on the installed IDF HTTPD layout. */
#include "web_httpd_adapter.h"
#include <string.h>
#include <strings.h>
#include "esp_idf_version.h"
#include "esp_httpd_priv.h"
#include "secure_random.h"
#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 0)
#error "Reaudit HTTPD header storage and pre-handler upgrade behavior for this IDF"
#endif
bool web_httpd_headers_valid(httpd_req_t *request)
{
if (!request || !request->aux) return false;
const struct httpd_req_aux *aux = request->aux;
const char *start = aux->scratch;
if (!start || aux->scratch_cur_size > 1024U) return false;
const char *end = start + aux->scratch_cur_size;
const char *line = start;
for (unsigned i = 0; i < aux->req_hdrs_count; ++i) {
if (line >= end) return false;
while (line < end && !*line) ++line;
const char *stop = memchr(line, 0, (size_t)(end - line));
if (!stop) return false;
const char *colon = memchr(line, ':', (size_t)(stop - line));
if (!colon || colon == line) return false;
size_t length = (size_t)(colon - line);
for (const char *p = line; p < colon; ++p) {
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
(*p >= '0' && *p <= '9') || strchr("!#$%&'*+-.^_`|~", *p))) return false;
}
for (const char *p = colon + 1; p < stop; ++p) {
if ((unsigned char)*p < 32U || (unsigned char)*p == 127U) return false;
}
/* Reject transfer coding and Expect rather than draining an unbounded
* body after an authentication failure. No application route uses them. */
if ((length == 17U && !strncasecmp(line, "Transfer-Encoding", length)) ||
(length == 6U && !strncasecmp(line, "Expect", length))) return false;
const char *previous = start;
for (unsigned j = 0; j < i; ++j) {
while (previous < line && !*previous) ++previous;
const char *previous_end = memchr(previous, 0, (size_t)(line - previous));
if (!previous_end) return false;
const char *previous_colon = memchr(previous, ':', (size_t)(previous_end - previous));
if (!previous_colon) return false;
if ((size_t)(previous_colon - previous) == length &&
!strncasecmp(previous, line, length)) return false;
previous = previous_end + 1;
}
line = stop + 1;
}
return true;
}
bool web_httpd_upgrade_requested(httpd_req_t *request)
{
const struct httpd_req_aux *aux = request->aux;
if (!aux || !aux->sd || !aux->ws_handshake_detect || aux->sd->ws_handshake_done)
return false;
char version[3], key[25];
if (httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Version") != 2U ||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Version", version, sizeof(version)) != ESP_OK ||
strcmp(version, "13") || httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Key") != 24U ||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Key", key, sizeof(key)) != ESP_OK ||
key[22] != '=' || key[23] != '=' || !strchr("AQgw", key[21])) return false;
for (unsigned i = 0; i < 21; ++i)
if (!strchr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", key[i])) return false;
return true;
}
esp_err_t web_httpd_upgrade(httpd_req_t *request,
esp_err_t (*handler)(httpd_req_t *))
{
if (!web_httpd_upgrade_requested(request)) return ESP_ERR_INVALID_STATE;
esp_err_t error = httpd_ws_respond_server_handshake(request, NULL);
if (error == ESP_OK) {
struct httpd_req_aux *aux = request->aux;
aux->sd->ws_handshake_done = true;
aux->sd->ws_handler = handler;
aux->sd->ws_control_frames = false;
aux->sd->ws_user_ctx = NULL;
}
return error;
}
void web_httpd_wipe_request(httpd_req_t *request, bool closing)
{
struct httpd_req_aux *aux = request->aux;
if (!aux) return;
if (aux->scratch) secure_wipe(aux->scratch, aux->scratch_cur_size);
aux->req_hdrs_count = 0;
if (aux->sd) {
size_t keep = closing ? 0 : aux->sd->pending_len;
/* httpd_unrecv()/httpd_recv_pending() right-align unread bytes. */
if (keep <= sizeof(aux->sd->pending_data))
secure_wipe(aux->sd->pending_data, sizeof(aux->sd->pending_data) - keep);
}
}
bool web_httpd_unread_body(httpd_req_t *request)
{
const struct httpd_req_aux *aux = request->aux;
return aux && aux->remaining_len != 0;
}
+14
View File
@@ -0,0 +1,14 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_http_server.h"
/* HTTPD-owner only, before body reads or any response. Reject duplicate lines,
* including Cookie, rather than trusting first-match public getters. */
bool web_httpd_headers_valid(httpd_req_t *request);
bool web_httpd_upgrade_requested(httpd_req_t *request);
bool web_httpd_unread_body(httpd_req_t *request);
/* After the final response/lookup: preserve only unread pipelined data on a
* keepalive connection. Closing requests may discard pending data entirely. */
void web_httpd_wipe_request(httpd_req_t *request, bool closing);
esp_err_t web_httpd_upgrade(httpd_req_t *request,
esp_err_t (*handler)(httpd_req_t *));
+3 -2
View File
@@ -110,7 +110,8 @@ static const char s_login_html[] =
" const abort = new AbortController(); controller = abort;\n"
" const timeout = setTimeout(() => abort.abort(), 15000);\n"
" let csrf = '';\n"
" const options = {credentials:'same-origin', mode:'same-origin', cache:'no-store', redirect:'error', signal:abort.signal};\n"
/* CORS mode preserves Origin under no-referrer; CSP still limits connections to self. */
" const options = {credentials:'same-origin', mode:'cors', cache:'no-store', redirect:'error', signal:abort.signal};\n"
" try {\n"
" const challenge = await fetch('/api/login-challenge', {...options, headers:{'X-Login-Bootstrap':'1'}});\n"
" if (current !== generation) return;\n"
@@ -152,7 +153,7 @@ esp_err_t web_login_ui_send_response(httpd_req_t *request)
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Frame-Options", "DENY");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Content-Security-Policy",
"default-src 'none'; script-src 'sha256-x70ID2kbifGBVYfh/pePTt5v/AVHkT7JVAV0LjT1wCo='; "
"default-src 'none'; script-src 'sha256-eZO4pMDQx6SIaa5AFlMnuf0CD5JdGSWyi8lNVmCNPBQ='; "
"style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'none'");
if (error == ESP_OK) error = httpd_resp_send(request, s_login_html, sizeof(s_login_html) - 1U);
+2 -3
View File
@@ -4,7 +4,6 @@
#include "esp_err.h"
#include "esp_http_server.h"
/* Standalone public login document for the future atomic 8D.3 cutover.
* Rendering only: no authentication, URI registration, or session allocation.
* Do not expose this page until its protected API and application routes exist. */
/* Standalone public login document. Rendering only: authentication, route
* registration and bounded challenge allocation belong to web_cookie_auth. */
esp_err_t web_login_ui_send_response(httpd_req_t *request);
+38 -16
View File
@@ -14,6 +14,8 @@
#include "sdkconfig.h"
#include "secure_random.h"
#include "serial_service.h"
#include "web_auth_parse.h"
#include "web_httpd_adapter.h"
#if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT
#error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT"
@@ -104,8 +106,7 @@ static uint64_t s_ticket_epoch;
static esp_err_t identity_is_current(const user_principal_t *principal,
web_session_id_t id, bool *current)
{
return id == 0U ? user_database_principal_is_current(principal, current)
: web_session_store_check_principal(id, principal, current);
return web_session_store_check_principal(id, principal, current);
}
static TickType_t milliseconds_to_ticks(uint32_t milliseconds)
@@ -270,9 +271,6 @@ static esp_err_t validate_origin(httpd_req_t *request)
esp_err_t result = httpd_req_get_hdr_value_str(
request, "Origin", origin, sizeof(origin));
if (result == ESP_ERR_NOT_FOUND) {
return ESP_OK;
}
if (result != ESP_OK) {
return ESP_ERR_INVALID_ARG;
}
@@ -284,9 +282,7 @@ static esp_err_t validate_origin(httpd_req_t *request)
return ESP_ERR_INVALID_ARG;
}
int written = snprintf(expected, sizeof(expected), "https://%s", host);
bool matches = written > 0 && (size_t)written < sizeof(expected) &&
strcmp(origin, expected) == 0;
bool matches = web_auth_parse_origin(host, strlen(host), origin, strlen(origin), expected);
secure_wipe(origin, sizeof(origin));
secure_wipe(host, sizeof(host));
secure_wipe(expected, sizeof(expected));
@@ -652,6 +648,12 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
}
bool activated = false;
/* Ticket and principal admission must succeed before HTTP 101. */
result = web_httpd_upgrade(request, web_serial_transport_ws_handler);
if (result != ESP_OK) {
close_unpublished_broker_session(slot, slot_generation, client_id);
goto cleanup;
}
int64_t next_currentness_check_us =
monotonic_time_us() + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
taskENTER_CRITICAL(&s_lock);
@@ -1576,6 +1578,26 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
return ESP_ERR_INVALID_STATE;
}
/* Reclaim stale identities without database calls under the transport lock.
* A late result must not clear a ticket published into the same array slot. */
for (size_t i = 0; i < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++i) {
taskENTER_CRITICAL(&s_lock);
web_serial_ticket_t candidate = s_tickets[i];
taskEXIT_CRITICAL(&s_lock);
bool live = false;
if (candidate.active &&
(identity_is_current(&candidate.principal, candidate.web_session_id, &live) != ESP_OK || !live)) {
taskENTER_CRITICAL(&s_lock);
web_serial_ticket_t *entry = &s_tickets[i];
if (entry->active && entry->web_session_id == candidate.web_session_id &&
entry->expires_at_us == candidate.expires_at_us &&
constant_time_equal(entry->digest, candidate.digest, sizeof(entry->digest)))
clear_ticket_locked(entry);
taskEXIT_CRITICAL(&s_lock);
}
secure_wipe(&candidate, sizeof(candidate));
}
uint8_t random_bytes[WEB_SERIAL_RANDOM_BYTES] = {0};
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
result = secure_random_fill(random_bytes, sizeof(random_bytes));
@@ -1602,7 +1624,6 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
epoch != UINT64_MAX) {
purge_tickets_locked(now_us);
size_t selected = WEB_SERIAL_TRANSPORT_MAX_TICKETS;
int64_t oldest_expiry = INT64_MAX;
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS;
++index) {
web_serial_ticket_t *entry = &s_tickets[index];
@@ -1610,10 +1631,6 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
selected = index;
break;
}
if (entry->expires_at_us < oldest_expiry) {
oldest_expiry = entry->expires_at_us;
selected = index;
}
}
if (selected < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
web_serial_ticket_t *entry = &s_tickets[selected];
@@ -1688,6 +1705,9 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
}
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
}
if (result == ESP_OK) {
result = httpd_resp_send(request, response, written);
}
@@ -1715,12 +1735,14 @@ esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
httpd_ws_client_info_t info =
httpd_ws_get_fd_info(request->handle, socket_fd);
if (info == HTTPD_WS_CLIENT_HTTP) {
bool opening = request->sess_ctx == NULL && web_session_id != 0U &&
request->method == HTTP_GET && web_httpd_upgrade_requested(request);
if (info == HTTPD_WS_CLIENT_HTTP && !opening) {
(void)send_plain_bad_request(request);
add_counter(&s_counters.protocol_errors, 1U);
return ESP_FAIL;
}
if (info != HTTPD_WS_CLIENT_WEBSOCKET) {
if (info != HTTPD_WS_CLIENT_WEBSOCKET && !opening) {
return ESP_FAIL;
}
@@ -1736,7 +1758,7 @@ esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
esp_err_t result;
if (request->sess_ctx == NULL) {
/* IDF has already sent 101; authentication failures must only close. */
/* The registered HTTP route defers 101 until admission. */
result = connect_websocket(request, socket_fd, web_session_id);
} else {
result = process_websocket_frame(request);
+8 -8
View File
@@ -100,7 +100,7 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server);
/*
* Mint a one-time bearer ticket bound to a current authenticated principal and
* originating web-session ID (zero only for the shipped Basic path). The
* nonzero originating web-session ID. The
* principal is copied; the output is exactly 32 Base64URL characters plus a
* terminator and expires after 30 monotonic seconds. Never log or persist it.
*/
@@ -111,7 +111,7 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
/*
* Convenience POST response helper for /api/ws-ticket. Authentication is
* intentionally outside this module: pass the principal returned by successful
* authentication, and its session ID (zero for Basic). Cookie callers must also
* authentication, and its session ID. Callers must also
* enforce CSRF/Origin policy. Register as HTTP_POST, not as a public handler.
*/
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
@@ -119,13 +119,13 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
web_session_id_t web_session_id);
/*
* Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and
* handle_ws_control_frames=false. The initial upgraded GET authenticates the
* ticket; later invocations process one complete data frame.
* Frame callback installed by the HTTPD adapter after authorized admission.
* Do not register directly: the initial HTTP GET must pass cookie/Origin policy
* and call the session handler below before any 101 response.
*/
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request);
/* Trusted future cookie-authorized upgrade caller; must validate cookie/Origin
* first. Zero identifies only the shipped Basic path, never a cookie fallback. */
/* Trusted cookie-authorized upgrade caller; validate cookie/Origin first.
* Zero is invalid for initial admission; no Basic fallback exists. */
esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
web_session_id_t web_session_id);
/* Invalidates the store first, then marks only matching tickets/slots for owner
@@ -140,7 +140,7 @@ esp_err_t web_serial_transport_clear_counters(void);
/* Invalidate cookie records and tickets/sockets for one username (also after
* deletion), or all accounts. Store invalidation occurs even if serial init
* failed; these do not touch the HTTPD-owned Basic cache, which rechecks DB. */
* failed. Authoritative store/principal checks supplement notifications. */
esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
size_t username_length);
esp_err_t web_serial_transport_revoke_sessions(void);
+73 -240
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* TLS-only HTTP server with bounded Basic authentication and status output. */
/* TLS-only HTTP server with bounded cookie authentication and status output. */
#include "web_server.h"
@@ -15,8 +15,6 @@
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "mbedtls/base64.h"
#include "mbedtls/md.h"
#include "secure_random.h"
#include "serial_config.h"
#include "serial_service.h"
@@ -26,28 +24,13 @@
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "web_ui.h"
#include "wifi_manager.h"
#define WEB_SERVER_PORT 443U
#define WEB_SERVER_MAX_BASIC_DECODED \
(USER_DATABASE_USERNAME_CAPACITY + 1U + USER_DATABASE_PASSWORD_CAPACITY)
#define WEB_SERVER_MAX_BASIC_ENCODED \
(((WEB_SERVER_MAX_BASIC_DECODED + 2U) / 3U) * 4U)
#define WEB_SERVER_MAX_AUTHORIZATION \
((sizeof("Basic ") - 1U) + WEB_SERVER_MAX_BASIC_ENCODED + 1U)
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
#define WEB_SERVER_AUTH_CACHE_ENTRIES 4U
#define WEB_SERVER_AUTH_CACHE_KEY_LENGTH 32U
#define WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH 32U
#define WEB_SERVER_AUTH_CACHE_TTL_US 300000000LL
typedef struct {
bool active;
int64_t expires_at_us;
uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH];
user_principal_t principal;
} web_server_auth_cache_entry_t;
static SemaphoreHandle_t s_server_mutex;
static httpd_handle_t s_server;
@@ -59,10 +42,6 @@ static bool s_serial_transport_attached;
static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
static esp_err_t s_serial_transport_error = ESP_ERR_INVALID_STATE;
static web_server_counters_t s_counters;
static bool s_auth_cache_ready;
static uint8_t s_auth_cache_key[WEB_SERVER_AUTH_CACHE_KEY_LENGTH];
static web_server_auth_cache_entry_t
s_auth_cache[WEB_SERVER_AUTH_CACHE_ENTRIES];
static esp_err_t ensure_mutex(void)
{
@@ -112,188 +91,19 @@ static esp_err_t send_plain_error(httpd_req_t *request,
return error;
}
static esp_err_t send_authentication_required(httpd_req_t *request)
{
esp_err_t error = httpd_resp_set_hdr(
request, "WWW-Authenticate",
"Basic realm=\"ESP32-SAK\", charset=\"UTF-8\"");
if (error != ESP_OK) {
increment_counter(&s_counters.response_errors);
return error;
}
return send_plain_error(request, "401 Unauthorized", "Authentication required.\n");
}
static bool constant_time_equal(const uint8_t *left, const uint8_t *right,
size_t length)
{
uint8_t difference = 0U;
for (size_t index = 0U; index < length; ++index) {
difference |= left[index] ^ right[index];
}
return difference == 0U;
}
static esp_err_t calculate_auth_cache_digest(
const char *authorization, size_t authorization_length,
uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH])
{
if (!s_auth_cache_ready) {
return ESP_ERR_INVALID_STATE;
}
const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (info == NULL ||
mbedtls_md_hmac(info, s_auth_cache_key, sizeof(s_auth_cache_key),
(const uint8_t *)authorization, authorization_length,
digest) != 0) {
return ESP_FAIL;
}
return ESP_OK;
}
static bool authenticate_from_cache(
const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH],
user_principal_t *principal)
{
int64_t now = esp_timer_get_time();
for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) {
web_server_auth_cache_entry_t *entry = &s_auth_cache[index];
if (!entry->active || entry->expires_at_us <= now ||
!constant_time_equal(entry->digest, digest, sizeof(entry->digest))) {
if (entry->active && entry->expires_at_us <= now) {
secure_wipe(entry, sizeof(*entry));
}
continue;
}
bool current = false;
if (user_database_principal_is_current(&entry->principal, &current) == ESP_OK &&
current) {
*principal = entry->principal;
entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US;
return true;
}
secure_wipe(entry, sizeof(*entry));
return false;
}
return false;
}
static void store_authenticated_request(
const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH],
const user_principal_t *principal)
{
int64_t now = esp_timer_get_time();
size_t selected = 0U;
int64_t earliest_expiry = INT64_MAX;
for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) {
web_server_auth_cache_entry_t *entry = &s_auth_cache[index];
if (entry->active &&
constant_time_equal(entry->digest, digest, sizeof(entry->digest))) {
selected = index;
break;
}
if (!entry->active || entry->expires_at_us <= now) {
selected = index;
earliest_expiry = INT64_MIN;
} else if (earliest_expiry != INT64_MIN &&
entry->expires_at_us < earliest_expiry) {
selected = index;
earliest_expiry = entry->expires_at_us;
}
}
web_server_auth_cache_entry_t *entry = &s_auth_cache[selected];
secure_wipe(entry, sizeof(*entry));
entry->active = true;
entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US;
memcpy(entry->digest, digest, sizeof(entry->digest));
entry->principal = *principal;
}
static esp_err_t authenticate_request(httpd_req_t *request,
user_principal_t *principal,
bool *authenticated)
{
char authorization[WEB_SERVER_MAX_AUTHORIZATION] = {0};
uint8_t decoded[WEB_SERVER_MAX_BASIC_DECODED] = {0};
size_t decoded_length = 0U;
uint8_t cache_digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH] = {0};
bool cache_digest_valid = false;
esp_err_t result = ESP_OK;
memset(principal, 0, sizeof(*principal));
*authenticated = false;
increment_counter(&s_counters.requests);
size_t header_length = httpd_req_get_hdr_value_len(request, "Authorization");
if (header_length == 0U || header_length >= sizeof(authorization)) {
goto cleanup;
}
if (httpd_req_get_hdr_value_str(request, "Authorization",
authorization, sizeof(authorization)) != ESP_OK ||
header_length < 7U || strncasecmp(authorization, "Basic ", 6U) != 0) {
goto cleanup;
}
result = calculate_auth_cache_digest(authorization, header_length, cache_digest);
if (result != ESP_OK) {
goto cleanup;
}
cache_digest_valid = true;
if (authenticate_from_cache(cache_digest, principal)) {
*authenticated = true;
goto cleanup;
}
int decode_result = mbedtls_base64_decode(
decoded, sizeof(decoded), &decoded_length,
(const unsigned char *)authorization + 6U, header_length - 6U);
if (decode_result != 0 || decoded_length == 0U) {
goto cleanup;
}
uint8_t *separator = memchr(decoded, ':', decoded_length);
if (separator == NULL) {
goto cleanup;
}
size_t username_length = (size_t)(separator - decoded);
size_t password_length = decoded_length - username_length - 1U;
result = user_database_authenticate_password(
decoded, username_length, separator + 1U, password_length,
principal, authenticated);
if (result == ESP_OK && *authenticated && cache_digest_valid) {
store_authenticated_request(cache_digest, principal);
}
cleanup:
secure_wipe(authorization, sizeof(authorization));
secure_wipe(decoded, sizeof(decoded));
secure_wipe(cache_digest, sizeof(cache_digest));
if (result != ESP_OK) {
memset(principal, 0, sizeof(*principal));
return result;
}
if (*authenticated) {
increment_counter(&s_counters.authenticated_requests);
} else {
memset(principal, 0, sizeof(*principal));
increment_counter(&s_counters.authentication_failures);
}
return ESP_OK;
}
static esp_err_t authorize_or_respond(httpd_req_t *request,
user_principal_t *principal,
bool *authorized)
{
*authorized = false;
esp_err_t error = authenticate_request(request, principal, authorized);
if (error != ESP_OK) {
*authorized = false;
return send_plain_error(request, "503 Service Unavailable",
"Authentication service unavailable.\n");
}
return *authorized ? ESP_OK : send_authentication_required(request);
web_session_view_t view = {0};
increment_counter(&s_counters.requests);
esp_err_t error = web_cookie_auth_require(request, false, false, &view, authorized);
web_httpd_wipe_request(request, web_httpd_unread_body(request));
*principal = view.principal;
secure_wipe(&view, sizeof(view));
increment_counter(*authorized ? &s_counters.authenticated_requests :
&s_counters.authentication_failures);
return error;
}
static esp_err_t send_authenticated_ui(httpd_req_t *request,
@@ -331,23 +141,21 @@ static esp_err_t asset_handler(httpd_req_t *request)
static esp_err_t ticket_handler(httpd_req_t *request)
{
user_principal_t principal = {0};
web_session_view_t view = {0};
bool authorized = false;
esp_err_t error = authorize_or_respond(request, &principal, &authorized);
increment_counter(&s_counters.requests);
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &authorized);
increment_counter(authorized ? &s_counters.authenticated_requests : &s_counters.authentication_failures);
if (error != ESP_OK || !authorized) {
secure_wipe(&principal, sizeof(principal));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
increment_counter(&s_counters.ticket_requests);
if (request->content_len != 0U) {
secure_wipe(&principal, sizeof(principal));
return send_plain_error(request, "400 Bad Request",
"Ticket requests must have an empty body.\n");
}
error = web_serial_transport_handle_authenticated_ticket_request(
request, &principal, 0U);
secure_wipe(&principal, sizeof(principal));
request, &view.principal, view.id);
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
if (error == ESP_OK) {
return ESP_OK;
}
@@ -355,9 +163,15 @@ static esp_err_t ticket_handler(httpd_req_t *request)
return send_plain_error(request, "400 Bad Request",
"Invalid web-terminal ticket request.\n");
}
if (error == ESP_ERR_NO_MEM &&
httpd_resp_set_hdr(request, "Retry-After", "5") == ESP_OK) {
return send_plain_error(request, "503 Service Unavailable", "{\"error\":\"capacity\"}");
}
increment_counter(&s_counters.response_errors);
return send_plain_error(request, "503 Service Unavailable",
"Web terminal transport unavailable.\n");
if (error == ESP_ERR_INVALID_STATE)
return send_plain_error(request, "503 Service Unavailable",
"Web terminal transport unavailable.\n");
return error; /* A failed/partial send must close, not send a second response. */
}
static const char *safe_string(const char *value)
@@ -533,12 +347,24 @@ static const httpd_uri_t s_ticket_uri = {
.user_ctx = NULL,
};
static esp_err_t websocket_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed);
if (allowed) error = web_serial_transport_session_ws_handler(request, view.id);
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
static const httpd_uri_t s_websocket_uri = {
.uri = WEB_SERIAL_TRANSPORT_WS_URI,
.method = HTTP_GET,
.handler = web_serial_transport_ws_handler,
.handler = websocket_handler,
.user_ctx = NULL,
.is_websocket = true,
/* Authorize and admit before the adapter sends 101, not IDF's pre-handler path. */
.is_websocket = false,
.handle_ws_control_frames = false,
};
@@ -589,6 +415,22 @@ static const httpd_uri_t *const s_uri_handlers[] = {
&s_logo_uri,
};
static const httpd_uri_t s_auth_uris[] = {
{.uri = "/login", .method = HTTP_GET, .handler = web_cookie_auth_handler},
{.uri = "/api/login-challenge", .method = HTTP_GET, .handler = web_cookie_auth_handler},
{.uri = "/api/login", .method = HTTP_POST, .handler = web_cookie_auth_handler},
{.uri = "/api/session", .method = HTTP_GET, .handler = web_cookie_auth_handler},
{.uri = "/api/logout", .method = HTTP_POST, .handler = web_cookie_auth_handler},
};
static esp_err_t route_error_handler(httpd_req_t *request, httpd_err_code_t code)
{
(void)send_plain_error(request,
code == HTTPD_405_METHOD_NOT_ALLOWED ? "405 Method Not Allowed" : "404 Not Found",
"Unsupported route or method.\n");
return ESP_FAIL; /* Do not drain a rejected request body on keepalive. */
}
esp_err_t web_server_init(void)
{
esp_err_t error = ensure_mutex();
@@ -596,19 +438,6 @@ esp_err_t web_server_init(void)
return error;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (!s_auth_cache_ready) {
error = secure_random_fill(s_auth_cache_key, sizeof(s_auth_cache_key));
if (error == ESP_OK) {
secure_wipe(s_auth_cache, sizeof(s_auth_cache));
s_auth_cache_ready = true;
}
}
xSemaphoreGive(s_server_mutex);
if (error != ESP_OK) {
return error;
}
bool initialize_serial_transport = false;
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (!s_serial_transport_init_attempted) {
@@ -654,12 +483,8 @@ esp_err_t web_server_start(void)
serial_transport_ready = s_serial_transport_initialized;
xSemaphoreGive(s_server_mutex);
/* Initialize only after admission: a rejected start must not undo stop.
* Dormant Phase 8D primitives do not gate the existing Basic-auth service. */
esp_err_t session_error = web_session_store_init();
if (session_error != ESP_OK) {
ESP_LOGW("web_server", "Session store unavailable: %s", esp_err_to_name(session_error));
}
/* Initialize only after lifecycle admission; failure gates all HTTPS auth. */
error = web_cookie_auth_start();
uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0};
uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0};
@@ -667,7 +492,7 @@ esp_err_t web_server_start(void)
size_t private_key_length = 0U;
httpd_handle_t server = NULL;
error = web_security_copy_tls_material(
if (error == ESP_OK) error = web_security_copy_tls_material(
certificate, sizeof(certificate), &certificate_length,
private_key, sizeof(private_key), &private_key_length);
if (error == ESP_OK) {
@@ -675,7 +500,8 @@ esp_err_t web_server_start(void)
/* Two browser terminals retain room for parallel assets and status fetches. */
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]);
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]);
config.httpd.lru_purge_enable = true;
config.httpd.recv_wait_timeout = 1;
config.httpd.send_wait_timeout = 1;
@@ -698,13 +524,19 @@ esp_err_t web_server_start(void)
}
bool serial_transport_attached = false;
for (size_t i = 0; error == ESP_OK && i < sizeof(s_auth_uris) / sizeof(s_auth_uris[0]); ++i)
error = httpd_register_uri_handler(server, &s_auth_uris[i]);
if (error == ESP_OK)
error = httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, route_error_handler);
if (error == ESP_OK)
error = httpd_register_err_handler(server, HTTPD_405_METHOD_NOT_ALLOWED, route_error_handler);
esp_err_t attach_error = s_serial_transport_error;
if (error == ESP_OK && serial_transport_ready) {
attach_error = web_serial_transport_attach_server(server);
serial_transport_attached = attach_error == ESP_OK;
}
if (error != ESP_OK) {
web_session_store_stop();
web_cookie_auth_stop();
}
if (error != ESP_OK && server != NULL) {
esp_err_t cleanup_error = httpd_ssl_stop(server);
@@ -750,7 +582,7 @@ esp_err_t web_server_stop(void)
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
web_session_store_stop();
web_cookie_auth_stop();
if (serial_transport_attached) {
esp_err_t detach_error = web_serial_transport_detach_server(server);
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
@@ -813,5 +645,6 @@ esp_err_t web_server_clear_counters(void)
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
memset(&s_counters, 0, sizeof(s_counters));
xSemaphoreGive(s_server_mutex);
web_cookie_auth_clear_counters();
return ESP_OK;
}
+231 -47
View File
@@ -9,7 +9,7 @@
#include "web_assets_data.h"
#define WEB_UI_DOCUMENT_CACHE_CONTROL "private, max-age=300"
#define WEB_UI_DOCUMENT_CACHE_CONTROL "no-store"
#define WEB_UI_ASSET_CACHE_CONTROL "private, max-age=604800"
static const char s_index_html[] =
@@ -87,6 +87,34 @@ static const char s_index_html[] =
".button:last-child{grid-column:1/-1}.page{grid-template-rows:auto auto minmax(0,1fr)}}\n"
"</style>\n"
"<link rel=\"icon\" href=\"data:,\">\n"
"<script>(() => {\n"
"'use strict';\n"
"let failed = false, generation = 0, controller = null, timer = null;\n"
"window.addEventListener('error', (event) => {\n"
" if (!event.target || !['SCRIPT', 'LINK', 'IMG'].includes(event.target.tagName) || failed) return;\n"
" failed = true;\n"
" const current = generation;\n"
" controller = new AbortController();\n"
" timer = window.setTimeout(() => controller.abort(), 15000);\n"
" fetch('/api/session', {credentials: 'same-origin', mode: 'same-origin', cache: 'no-store', redirect: 'error', signal: controller.signal})\n"
" .then((response) => {\n"
" if (current !== generation) return;\n"
" if (response.status === 401) {\n"
" ++generation; controller.abort(); window.clearTimeout(timer);\n"
" if (window.sakSessionExpired) window.sakSessionExpired();\n"
" else if (!window.sakLoginNavigating) { window.sakLoginNavigating = true; window.location.replace('/login'); }\n"
" }\n"
" }).catch(() => {}).finally(() => {\n"
" if (current === generation) { controller.abort(); window.clearTimeout(timer); }\n"
" });\n"
"}, true);\n"
"window.addEventListener('pagehide', () => {\n"
" ++generation;\n"
" if (controller) controller.abort();\n"
" window.clearTimeout(timer);\n"
"});\n"
"})();\n"
"</script>\n"
"<link rel=\"stylesheet\" href=\"/assets/xterm.css\">\n"
"<script defer src=\"/assets/xterm.js\"></script>\n"
"<script defer src=\"/assets/addon-fit.js\"></script>\n"
@@ -120,11 +148,12 @@ static const char s_index_html[] =
"<button id=\"request-control\" class=\"button primary\" type=\"button\" disabled>Request control</button>\n"
"<button id=\"release-control\" class=\"button danger\" type=\"button\" disabled>Release control</button>\n"
"<button id=\"connection-toggle\" class=\"button danger\" type=\"button\">Disconnect</button>\n"
"<button id=\"sign-out\" class=\"button\" type=\"button\">Sign out</button>\n"
"</div>\n"
"<p id=\"session-info\" class=\"connection-detail\" aria-live=\"polite\">Session lasts one hour from sign-in (absolute expiry).</p>\n"
"<p id=\"input-state\" class=\"input-state\" data-enabled=\"false\" aria-live=\"polite\">"
"Observer mode — terminal input is disabled.</p>\n"
"<p id=\"connection-detail\" class=\"connection-detail\" aria-live=\"polite\">"
"Requesting a one-time connection ticket…</p>\n"
"<p id=\"connection-detail\" class=\"connection-detail\" aria-live=\"polite\">Loading application… If loading fails, <a href=\"/login\">open login</a> or reload this page.</p>\n"
"</div>\n"
"</section>\n"
"<section class=\"panel terminal-panel\" aria-label=\"Serial terminal\">\n"
@@ -152,6 +181,8 @@ static const char s_app_js[] =
"const requestControl = element('request-control');\n"
"const releaseControl = element('release-control');\n"
"const connectionToggle = element('connection-toggle');\n"
"const signOut = element('sign-out');\n"
"const sessionInfo = element('session-info');\n"
"const terminalHost = element('terminal');\n"
"const terminal = new Terminal({\n"
" allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n"
@@ -182,6 +213,138 @@ static const char s_app_js[] =
"let lastFitHeight = 0;\n"
"let statusInFlight = false;\n"
"let statusTimer = null;\n"
"let csrf = '';\n"
"let sessionDeadline = 0;\n"
"let expiryTimer = null;\n"
"let navigating = false;\n"
"let loggingOut = false;\n"
"let suspended = false;\n"
"let workGeneration = 0;\n"
"let sessionGeneration = 0;\n"
"const requests = new Set();\n"
"const live = (generation) => generation === workGeneration && !unloading && !navigating;\n"
"const cancelWork = () => {\n"
" ++workGeneration;\n"
" ++connectionGeneration;\n"
" clearReconnectTimer();\n"
" for (const controller of requests) controller.abort();\n"
" requests.clear();\n"
" if (ticketAbort) ticketAbort.abort();\n"
" ticketAbort = null;\n"
" if (statusTimer !== null) window.clearInterval(statusTimer);\n"
" statusTimer = null;\n"
" statusInFlight = false;\n"
" window.clearTimeout(expiryTimer);\n"
" expiryTimer = null;\n"
" if (socket) { const previous = socket; socket = null; previous.close(); }\n"
" clientId = null;\n"
" clientIdField.textContent = '—';\n"
" setRole('observer');\n"
"};\n"
"const login = () => {\n"
" if (navigating) return;\n"
" navigating = true;\n"
" csrf = '';\n"
" cancelWork();\n"
" reconnectEnabled = false;\n"
" if (fitFrame) window.cancelAnimationFrame(fitFrame);\n"
" updateControls();\n"
" if (!window.sakLoginNavigating) { window.sakLoginNavigating = true; window.location.replace('/login'); }\n"
"};\n"
"window.sakSessionExpired = login;\n"
"if (window.sakLoginNavigating) { navigating = true; reconnectEnabled = false; }\n"
"// Keep authentication bodies bounded even if the peer sends chunked data.\n"
"async function readJson(response, limit) {\n"
" const reader = response.body.getReader();\n"
" const bytes = new Uint8Array(limit);\n"
" let length = 0;\n"
" try {\n"
" for (;;) {\n"
" const {done, value} = await reader.read();\n"
" if (done) break;\n"
" if (length + value.length > limit) throw new Error('Invalid device response.');\n"
" bytes.set(value, length); length += value.length;\n"
" }\n"
" return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes.subarray(0, length)));\n"
" } finally { await reader.cancel().catch(() => {}); }\n"
"}\n"
"async function api(path, generation, {method = 'GET', signal, limit = 512, current = () => true} = {}) {\n"
" const controller = new AbortController();\n"
" const abort = () => controller.abort();\n"
" if (signal) { signal.addEventListener('abort', abort, {once: true}); if (signal.aborted) abort(); }\n"
" requests.add(controller);\n"
" const timeout = window.setTimeout(abort, 15000);\n"
" try {\n"
/* Non-CORS POST with no-referrer serializes Origin as null in browsers. */
" const response = await fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n"
" cache: 'no-store', redirect: 'error', signal: controller.signal,\n"
" ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf}, body: ''} : {})});\n"
" if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n"
" if (response.status === 401) { login(); throw new Error('Session ended.'); }\n"
" if (!response.ok) {\n"
" const error = new Error(response.status === 403 ? 'Session security check failed. Reload the session and retry explicitly.' :\n"
" response.status === 429 || response.status === 503 ? 'Device capacity or backoff limit. Try again later.' : 'Device request failed.');\n"
" error.status = response.status;\n"
" const retry = response.headers.get('Retry-After');\n"
" error.retry = /^[0-9]{1,4}$/.test(retry || '') ? Math.min(3600, Math.max(1, Number(retry))) : 5;\n"
" if (response.status === 429 || response.status === 503) error.message += ` Wait ${error.retry} second(s).`;\n"
" throw error;\n"
" }\n"
" const payload = response.status === 204 ? null : await readJson(response, limit);\n"
" if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n"
" return {status: response.status, payload};\n"
" } finally {\n"
" controller.abort(); requests.delete(controller); window.clearTimeout(timeout);\n"
" if (signal) signal.removeEventListener('abort', abort);\n"
" }\n"
"}\n"
"async function loadSession(generation, signal) {\n"
" const sequence = ++sessionGeneration;\n"
" const current = () => sequence === sessionGeneration;\n"
" const {payload} = await api('/api/session', generation, {signal, current});\n"
" if (!live(generation) || !current() || (signal && signal.aborted)) return false;\n"
" if (!payload || typeof payload.username !== 'string' || !payload.username.length || encoder.encode(payload.username).length > 16 ||\n"
" !['user', 'admin'].includes(payload.role) || !/^[0-9a-f]{64}$/.test(payload.csrf) ||\n"
" !Number.isInteger(payload.expires_in) || payload.expires_in < 0 || payload.expires_in > 3600) {\n"
" throw new Error('Invalid session response. Reload to retry.');\n"
" }\n"
" csrf = payload.csrf; payload.csrf = '';\n"
" const deadline = Date.now() + payload.expires_in * 1000;\n"
" sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n"
" sessionInfo.textContent = `${payload.username} · Session expires at ${new Date(sessionDeadline).toLocaleTimeString()} (one hour absolute; traffic does not extend it).`;\n"
" window.clearTimeout(expiryTimer);\n"
" if (!loggingOut) expiryTimer = window.setTimeout(() => { if (live(generation)) login(); }, Math.max(0, sessionDeadline - Date.now()));\n"
" return true;\n"
"}\n"
"function startPolling() {\n"
" if (statusTimer === null) { pollStatus(); statusTimer = window.setInterval(pollStatus, 5000); }\n"
"}\n"
"async function logout() {\n"
" if (loggingOut || unloading || navigating) return;\n"
" loggingOut = true; suspended = true; reconnectEnabled = false;\n"
" cancelWork(); updateControls();\n"
" const generation = workGeneration;\n"
" setConnection('Signing out', 'warn', 'Serial disconnected. Waiting for logout confirmation…');\n"
" try {\n"
" if (!await loadSession(generation)) return;\n"
" const result = await api('/api/logout', generation, {method: 'POST'});\n"
" if (live(generation) && result.status === 204) { login(); return; }\n"
" throw new Error('Logout was not confirmed.');\n"
" } catch (error) {\n"
" if (!live(generation)) return;\n"
" // Only a lost/network response needs confirmation; never repeat the mutation.\n"
" if (!error.status) {\n"
" try { await loadSession(generation); } catch (_) {}\n"
" }\n"
" if (!live(generation)) return;\n"
" csrf = '';\n"
" setConnection('Sign out not confirmed', 'bad', error.status ? error.message :\n"
" 'Network failure: sign out is not confirmed. Retry Sign out, or select Connect to check the session and resume.');\n"
" } finally {\n"
" if (live(generation)) { loggingOut = false; updateControls(); }\n"
" }\n"
"}\n"
"signOut.addEventListener('click', logout);\n"
"const setBadge = (target, text, tone) => {\n"
" target.textContent = text;\n"
" target.dataset.tone = tone;\n"
@@ -195,7 +358,8 @@ static const char s_app_js[] =
" requestControl.disabled = !socketOpen() || writer;\n"
" releaseControl.disabled = !socketOpen() || !writer;\n"
" const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n"
" connectionToggle.disabled = unloading;\n"
" connectionToggle.disabled = unloading || navigating || loggingOut;\n"
" signOut.disabled = unloading || navigating || loggingOut;\n"
" connectionToggle.textContent = connectionActive ? 'Disconnect' : 'Connect';\n"
" connectionToggle.classList.toggle('danger', connectionActive);\n"
" inputState.dataset.enabled = writer ? 'true' : 'false';\n"
@@ -220,7 +384,7 @@ static const char s_app_js[] =
" }\n"
"};\n"
"const scheduleReconnect = () => {\n"
" if (unloading || !reconnectEnabled || reconnectTimer !== null) return;\n"
" if (unloading || navigating || suspended || !reconnectEnabled || reconnectTimer !== null) return;\n"
" const delay = reconnectDelay;\n"
" reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n"
" setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n"
@@ -257,24 +421,18 @@ static const char s_app_js[] =
" terminal.write(new Uint8Array(event.data));\n"
" }\n"
"};\n"
"async function requestTicket(signal) {\n"
" const response = await fetch('/api/ws-ticket', {\n"
" method: 'POST', credentials: 'same-origin', cache: 'no-store', signal\n"
" });\n"
" if (!response.ok) throw new Error('ticket request failed');\n"
" const payload = await response.json();\n"
" if (payload === null || typeof payload !== 'object' ||\n"
" typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
" throw new Error('invalid ticket response');\n"
"async function requestTicket(signal, generation) {\n"
" const {payload} = await api('/api/ws-ticket', generation, {method: 'POST', signal});\n"
" if (!payload || typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
" throw new Error('Invalid ticket response.');\n"
" }\n"
" const ticket = payload.ticket;\n"
" payload.ticket = '';\n"
" return ticket;\n"
" const ticket = payload.ticket; payload.ticket = ''; return ticket;\n"
"}\n"
"async function connect() {\n"
" if (unloading || !reconnectEnabled) return;\n"
" if (unloading || navigating || loggingOut || suspended || !reconnectEnabled) return;\n"
" clearReconnectTimer();\n"
" const generation = ++connectionGeneration;\n"
" const work = workGeneration;\n"
" if (ticketAbort !== null) ticketAbort.abort();\n"
" ticketAbort = new AbortController();\n"
" if (socket !== null) {\n"
@@ -287,7 +445,10 @@ static const char s_app_js[] =
" setRole('observer');\n"
" setConnection('Connecting', 'warn', 'Requesting a one-time connection ticket…');\n"
" try {\n"
" const ticket = await requestTicket(ticketAbort.signal);\n"
" const signal = ticketAbort.signal;\n"
" if (!await loadSession(work, signal) || generation !== connectionGeneration) return;\n"
" startPolling();\n"
" const ticket = await requestTicket(signal, work);\n"
" if (unloading || generation !== connectionGeneration) return;\n"
" ticketAbort = null;\n"
" const url = new URL('/ws/serial', window.location.origin);\n"
@@ -298,19 +459,19 @@ static const char s_app_js[] =
" nextSocket.binaryType = 'arraybuffer';\n"
" socket = nextSocket;\n"
" nextSocket.addEventListener('open', () => {\n"
" if (socket !== nextSocket) return;\n"
" if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n"
" setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n"
" });\n"
" nextSocket.addEventListener('message', (event) => {\n"
" if (socket === nextSocket) handleSocketMessage(event);\n"
" if (live(work) && generation === connectionGeneration && socket === nextSocket) handleSocketMessage(event);\n"
" });\n"
" nextSocket.addEventListener('error', () => {\n"
" if (socket === nextSocket) {\n"
" if (live(work) && generation === connectionGeneration && socket === nextSocket) {\n"
" setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n"
" }\n"
" });\n"
" nextSocket.addEventListener('close', () => {\n"
" if (socket !== nextSocket) return;\n"
" if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n"
" socket = null;\n"
" clientId = null;\n"
" clientIdField.textContent = '—';\n"
@@ -320,7 +481,15 @@ static const char s_app_js[] =
" } catch (error) {\n"
" if (generation !== connectionGeneration || unloading || error.name === 'AbortError') return;\n"
" ticketAbort = null;\n"
" if (!live(work)) return;\n"
" if (error.status === 403 || (error.status && error.status !== 429 && error.status !== 503)) {\n"
" reconnectEnabled = false;\n"
" setConnection('Request failed', 'bad', error.message + ' Select Connect to reload the session.');\n"
" return;\n"
" }\n"
" if (error.status === 429 || error.status === 503) reconnectDelay = error.retry * 1000;\n"
" scheduleReconnect();\n"
" if (error.status) connectionDetail.textContent = error.message + ` Retrying in ${error.retry} second(s).`;\n"
" }\n"
"}\n"
"terminal.onData((data) => {\n"
@@ -337,6 +506,8 @@ static const char s_app_js[] =
" if (role === 'writer' && socketOpen()) socket.send('release-writer');\n"
"});\n"
"connectionToggle.addEventListener('click', () => {\n"
" if (unloading || navigating || loggingOut) return;\n"
" suspended = false;\n"
" const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n"
" if (!connectionActive) {\n"
" reconnectEnabled = true;\n"
@@ -371,7 +542,7 @@ static const char s_app_js[] =
" } catch (_) {}\n"
"};\n"
"const scheduleFit = () => {\n"
" if (fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n"
" if (!unloading && !navigating && fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n"
"};\n"
"const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n"
"if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n"
@@ -415,38 +586,51 @@ static const char s_app_js[] =
" }\n"
"};\n"
"async function pollStatus() {\n"
" if (unloading || statusInFlight) return;\n"
" if (unloading || navigating || suspended || !csrf || statusInFlight) return;\n"
" const generation = workGeneration;\n"
" statusInFlight = true;\n"
" try {\n"
" const response = await fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'});\n"
" if (!response.ok) throw new Error('status request failed');\n"
" updateStatus(await response.json());\n"
" } catch (_) {\n"
" wifiSummary.textContent = 'Unavailable';\n"
" serialSummary.textContent = 'Unavailable';\n"
" const {payload} = await api('/api/status', generation, {limit: 3072});\n"
" if (live(generation)) updateStatus(payload);\n"
" } catch (error) {\n"
" if (!live(generation)) return;\n"
" wifiSummary.textContent = 'Unavailable'; serialSummary.textContent = 'Unavailable';\n"
" brokerClientsField.textContent = '—';\n"
" } finally {\n"
" statusInFlight = false;\n"
" }\n"
" if (error.status === 403) {\n"
" cancelWork(); reconnectEnabled = false; csrf = '';\n"
" setConnection('Request failed', 'bad', error.message + ' Select Connect to reload the session.');\n"
" } else if (error.status) connectionDetail.textContent = error.message;\n"
" } finally { if (live(generation)) statusInFlight = false; }\n"
"}\n"
"const shutdown = () => {\n"
" if (unloading) return;\n"
" unloading = true;\n"
" ++connectionGeneration;\n"
" clearReconnectTimer();\n"
" if (statusTimer !== null) window.clearInterval(statusTimer);\n"
" cancelWork();\n"
" unloading = true; csrf = '';\n"
" if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n"
" fitFrame = 0;\n"
" if (resizeObserver !== null) resizeObserver.disconnect();\n"
" window.removeEventListener('resize', scheduleFit);\n"
" if (ticketAbort !== null) ticketAbort.abort();\n"
" if (socket !== null) socket.close();\n"
" socket = null;\n"
" updateControls();\n"
"};\n"
"window.addEventListener('pagehide', shutdown, {once: true});\n"
"window.addEventListener('pagehide', shutdown);\n"
"window.addEventListener('pageshow', (event) => {\n"
" if (!event.persisted || navigating) return;\n"
" unloading = false; loggingOut = false;\n"
" if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n"
" scheduleFit(); updateControls();\n"
" if (reconnectEnabled && !suspended) connect();\n"
" else {\n"
" const generation = workGeneration;\n"
" const pending = loadSession(generation);\n"
" const sequence = sessionGeneration;\n"
" pending.then((valid) => {\n"
" if (valid && live(generation) && sequence === sessionGeneration && !suspended) startPolling();\n"
" }).catch(() => {\n"
" if (live(generation) && sequence === sessionGeneration) setConnection('Session check failed', 'bad', 'Reload or select Connect to retry.');\n"
" });\n"
" }\n"
"});\n"
"updateControls();\n"
"scheduleFit();\n"
"pollStatus();\n"
"statusTimer = window.setInterval(pollStatus, 5000);\n"
"connect();\n"
"})();\n";
@@ -530,8 +714,8 @@ static esp_err_t set_response_headers(httpd_req_t *request,
if (result == ESP_OK && response->content_security_policy) {
result = httpd_resp_set_hdr(
request, "Content-Security-Policy",
"default-src 'none'; script-src 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; "
"script-src-elem 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; "
"default-src 'none'; script-src 'self' 'sha256-o6St1XqFiWgZZKDDKYP8Y1ROJxvOnf96z55w4i/dC20='; "
"script-src-elem 'self' 'sha256-o6St1XqFiWgZZKDDKYP8Y1ROJxvOnf96z55w4i/dC20='; "
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; "
"base-uri 'none'; form-action 'none'; "
"frame-ancestors 'none'");