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
+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;
}