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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user