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,257 @@
|
||||
/* Production store dependency doubles and its existing public API suite. */
|
||||
#define main store_tests
|
||||
#include "../web_session_store/test.c"
|
||||
#undef main
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "esp_httpd_priv.h"
|
||||
|
||||
static struct httpd_data server = {.config.max_resp_headers = 8};
|
||||
static struct sock_db socket_state;
|
||||
static struct resp_hdr response_headers[8];
|
||||
static char scratch[1024], output[1024], cookie_values[2][200];
|
||||
static const char *request_body;
|
||||
static size_t body_offset;
|
||||
static unsigned password_calls, cookie_count, sends, upgrades;
|
||||
static unsigned fail_header, setter_calls;
|
||||
static bool send_fail, recv_fail;
|
||||
static void (*password_hook)(void);
|
||||
static char response_status[48];
|
||||
static struct httpd_req_aux aux;
|
||||
static httpd_req_t req;
|
||||
size_t host_read_pending(httpd_req_t *r, char *out, size_t n);
|
||||
|
||||
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *status) {
|
||||
(void)r; if (fail_header && ++setter_calls == fail_header) return ESP_FAIL;
|
||||
snprintf(response_status, sizeof(response_status), "%s", status); return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *type) {
|
||||
(void)r; (void)type; return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
|
||||
(void)r; ++sends; assert(strlen(body) < sizeof(output)); strcpy(output, body);
|
||||
cookie_count = 0;
|
||||
for (unsigned i = 0; i < aux.resp_hdrs_count; ++i) {
|
||||
assert(response_headers[i].value);
|
||||
assert(strcmp(response_headers[i].field, "WWW-Authenticate"));
|
||||
if (!strcmp(response_headers[i].field, "Set-Cookie")) {
|
||||
assert(cookie_count < 2);
|
||||
snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value);
|
||||
}
|
||||
}
|
||||
return send_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
|
||||
(void)r; if (recv_fail) return -1;
|
||||
if (size > 7) size = 7; /* Fragment every login body. */
|
||||
memcpy(out, request_body + body_offset, size); body_offset += size;
|
||||
aux.remaining_len -= size; return (int)size;
|
||||
}
|
||||
esp_err_t web_login_ui_send_response(httpd_req_t *r) { return httpd_resp_sendstr(r, "login document"); }
|
||||
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id) {
|
||||
web_session_store_invalidate(id); return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *r, const char *protocol) {
|
||||
(void)r; (void)protocol; ++upgrades; return ESP_OK;
|
||||
}
|
||||
esp_err_t user_database_authenticate_password(const uint8_t *u, size_t un,
|
||||
const uint8_t *p, size_t pn, user_principal_t *principal, bool *authenticated) {
|
||||
assert(!host_lock_depth); ++password_calls;
|
||||
if (password_hook) { void (*hook)(void) = password_hook; password_hook = NULL; hook(); }
|
||||
*authenticated = un == 5 && !memcmp(u, "alice", 5) && pn == 12 && !memcmp(p, "password1234", 12);
|
||||
if (*authenticated) *principal = alice;
|
||||
return db_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
|
||||
static void begin(const char *uri, int method, const char *body) {
|
||||
memset(scratch, 0, sizeof(scratch)); memset(response_headers, 0, sizeof(response_headers));
|
||||
memset(&socket_state, 0, sizeof(socket_state));
|
||||
aux = (struct httpd_req_aux){.sd = &socket_state, .scratch = scratch,
|
||||
.scratch_cur_size = sizeof(scratch), .resp_hdrs = response_headers};
|
||||
req = (httpd_req_t){.handle = &server, .aux = &aux, .uri = uri, .method = method,
|
||||
.content_len = body ? strlen(body) : 0};
|
||||
aux.remaining_len = req.content_len;
|
||||
request_body = body; body_offset = 0;
|
||||
response_status[0] = output[0] = 0; cookie_count = 0;
|
||||
}
|
||||
static void add(const char *key, const char *value) {
|
||||
char *at = scratch;
|
||||
for (unsigned i = 0; i < aux.req_hdrs_count; ++i) at += strlen(at) + 1;
|
||||
assert((size_t)(at - scratch) + strlen(key) + strlen(value) + 3 < sizeof(scratch));
|
||||
sprintf(at, "%s: %s", key, value); ++aux.req_hdrs_count;
|
||||
}
|
||||
static void same_origin(void) { add("Host", "device.example"); add("Origin", origin); }
|
||||
static void expect(const char *status) {
|
||||
(void)web_cookie_auth_handler(&req);
|
||||
assert(!strcmp(response_status, status));
|
||||
}
|
||||
static void token_from(const char *value, char token[65]) {
|
||||
const char *start = strchr(value, '='); assert(start && strlen(start + 1) >= 64);
|
||||
memcpy(token, start + 1, 64); token[64] = 0;
|
||||
}
|
||||
static void csrf_from(char csrf[65]) {
|
||||
const char *start = strstr(output, "\"csrf\":\""); assert(start);
|
||||
memcpy(csrf, start + 8, 64); csrf[64] = 0;
|
||||
}
|
||||
static void challenge(char token[65], char csrf[65]) {
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example");
|
||||
add("X-Login-Bootstrap", "1"); expect("200 OK");
|
||||
assert(cookie_count == 1); token_from(cookie_values[0], token); csrf_from(csrf);
|
||||
}
|
||||
static const char good_body[] = "{\"username\":\"alice\",\"password\":\"password1234\"}";
|
||||
static void login_request(const char *token, const char *csrf, const char *body) {
|
||||
begin("/api/login", HTTP_POST, body); same_origin(); add("Content-Type", "application/json");
|
||||
add("X-CSRF-Token", csrf);
|
||||
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
||||
}
|
||||
static void auth_reset(void) {
|
||||
web_cookie_auth_stop(); reset(); assert(web_cookie_auth_start() == ESP_OK);
|
||||
password_calls = 0; password_hook = NULL;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
char token[65], csrf[65], session[65], cookies[200];
|
||||
challenge(token, csrf);
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example"); add("X-Login-Bootstrap", "1");
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
||||
expect("200 OK"); assert(cookie_count == 0);
|
||||
login_request(token, csrf, good_body); expect("200 OK");
|
||||
assert(password_calls == 1 && cookie_count == 2 && snapshot().active == 1);
|
||||
assert(strstr(cookie_values[0], "__Host-sak-prelogin="));
|
||||
assert(strstr(cookie_values[0], "Max-Age=0"));
|
||||
assert(strstr(cookie_values[1], "Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600"));
|
||||
token_from(cookie_values[1], session);
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin();
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", session); add("Cookie", cookies);
|
||||
expect("200 OK"); csrf_from(csrf);
|
||||
issued_t other = mint(&alice);
|
||||
begin("/api/logout", HTTP_POST, NULL); same_origin(); add("Cookie", cookies); add("X-CSRF-Token", csrf);
|
||||
expect("204 No Content"); present(&other); assert(snapshot().active == 1);
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
||||
puts("PASS: challenge reuse, fragmented login, two independent Set-Cookie fields, session and isolated logout");
|
||||
|
||||
auth_reset();
|
||||
for (unsigned i = 0; i < 5; ++i) {
|
||||
challenge(token, csrf); login_request(token, csrf, "{\"username\":\"alice\",\"password\":\"wrong\"}");
|
||||
expect("401 Unauthorized");
|
||||
login_request(token, csrf, good_body); expect("403 Forbidden");
|
||||
}
|
||||
challenge(token, csrf); login_request(token, csrf, good_body); expect("429 Too Many Requests");
|
||||
assert(password_calls == 5); now += 60000000;
|
||||
challenge(token, csrf); login_request(token, csrf, good_body); expect("200 OK");
|
||||
auth_reset(); for (unsigned i = 0; i < 4; ++i) challenge(token, csrf);
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); same_origin(); add("X-Login-Bootstrap", "1"); expect("503 Service Unavailable");
|
||||
now += 120000000; challenge(token, csrf);
|
||||
auth_reset(); for (unsigned i = 0; i < 4; ++i) (void)mint(&alice);
|
||||
challenge(token, csrf); login_request(token, csrf, good_body); expect("503 Service Unavailable"); assert(snapshot().active == 4);
|
||||
puts("PASS: consumed challenges, global five/60s throttle, expiry and no live challenge/session eviction");
|
||||
|
||||
auth_reset(); challenge(token, csrf);
|
||||
const char *keys[] = {"Host", "Origin", "Cookie", "Content-Type", "X-CSRF-Token"};
|
||||
for (unsigned i = 0; i < sizeof(keys)/sizeof(keys[0]); ++i) {
|
||||
login_request(token, csrf, good_body); add(keys[i], "ambiguous"); expect("400 Bad Request");
|
||||
}
|
||||
assert(password_calls == 0);
|
||||
login_request(token, csrf, good_body); add("Transfer-Encoding", "chunked"); expect("400 Bad Request");
|
||||
login_request(token, csrf, good_body); add("Sec-Fetch-Site", "cross-site"); expect("403 Forbidden");
|
||||
login_request(token, "invalid", good_body); expect("403 Forbidden");
|
||||
login_request(token, csrf, good_body); req.method = HTTP_GET; expect("400 Bad Request");
|
||||
login_request(token, csrf, good_body); req.content_len = 513; expect("413 Payload Too Large");
|
||||
login_request(token, csrf, "{\"username\":\"alice\",\"password\":\"x\",\"unknown\":1}"); expect("400 Bad Request");
|
||||
assert(password_calls == 0);
|
||||
begin("/", HTTP_GET, NULL); same_origin(); add("Authorization", "Basic ignored");
|
||||
web_session_view_t view; bool allowed;
|
||||
assert(web_cookie_auth_require(&req, false, false, &view, &allowed) == ESP_OK && !allowed);
|
||||
assert(!strcmp(response_status, "303 See Other"));
|
||||
begin("/assets/app.js", HTTP_GET, NULL); same_origin();
|
||||
assert(web_cookie_auth_require(&req, false, false, &view, &allowed) == ESP_OK && !allowed);
|
||||
assert(!strcmp(response_status, "401 Unauthorized"));
|
||||
puts("PASS: duplicate security headers, framing, methods, metadata, CSRF, strict JSON and no Basic bypass");
|
||||
|
||||
auth_reset(); other = mint(&alice);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
||||
begin("/api/logout", HTTP_POST, NULL); same_origin(); add("Cookie", cookies);
|
||||
expect("403 Forbidden"); present(&other);
|
||||
begin("/api/logout", HTTP_POST, NULL); add("Host", "device.example");
|
||||
add("Cookie", cookies); add("X-CSRF-Token", other.view.csrf);
|
||||
expect("403 Forbidden"); present(&other);
|
||||
begin("/api/logout", HTTP_POST, NULL); add("Host", "device.example"); add("Origin", "https://foreign.example");
|
||||
add("Cookie", cookies); add("X-CSRF-Token", other.view.csrf);
|
||||
expect("403 Forbidden"); present(&other);
|
||||
begin("/api/session", HTTP_GET, NULL); add("Host", "device.example"); add("Cookie", cookies);
|
||||
expect("200 OK");
|
||||
begin("/api/session", HTTP_GET, NULL); add("Host", "alias.local"); add("Cookie", cookies);
|
||||
expect("401 Unauthorized"); present(&other);
|
||||
challenge(token, csrf);
|
||||
begin("/api/login", HTTP_POST, good_body); same_origin(); add("Content-Type", "application/json");
|
||||
add("X-CSRF-Token", csrf);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s; __Host-sak-prelogin=%s", other.token, token);
|
||||
add("Cookie", cookies); expect("409 Conflict"); assert(password_calls == 0); present(&other);
|
||||
begin("/api/login", HTTP_POST, good_body); same_origin(); add("Content-Type", "application/json");
|
||||
add("X-CSRF-Token", csrf);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s; __Host-sak-session=%s", other.token, other.token);
|
||||
add("Cookie", cookies); expect("400 Bad Request"); assert(password_calls == 0);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
||||
now = other.view.expires_at_us;
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
||||
auth_reset(); other = mint(&alice); stale_user = alice.user_id;
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
||||
puts("PASS: mandatory mutation Origin/CSRF, origin binding, explicit account switching, duplicate named cookies, expiry/currentness");
|
||||
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
password_hook = web_cookie_auth_stop; expect("503 Service Unavailable"); assert(!snapshot().active);
|
||||
auth_reset(); rng_fail = true;
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); same_origin(); add("X-Login-Bootstrap", "1"); expect("503 Service Unavailable");
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body); send_fail = true;
|
||||
assert(web_cookie_auth_handler(&req) != ESP_OK); send_fail = false; assert(!snapshot().active);
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body); recv_fail = true;
|
||||
expect("400 Bad Request"); recv_fail = false; assert(!password_calls);
|
||||
begin("/api/session", HTTP_GET, NULL); add("Host", "first"); add("host", "second");
|
||||
char value[32]; assert(httpd_req_get_hdr_value_str(&req, "Host", value, sizeof(value)) == ESP_OK);
|
||||
assert(!strcmp(value, "first") && !web_httpd_headers_valid(&req));
|
||||
begin("/ws/serial", HTTP_GET, NULL); same_origin();
|
||||
add("Sec-WebSocket-Version", "13"); add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
||||
assert(!web_httpd_upgrade_requested(&req)); aux.ws_handshake_detect = true;
|
||||
assert(web_httpd_upgrade(&req, web_cookie_auth_handler) == ESP_OK && upgrades == 1);
|
||||
assert(socket_state.ws_handshake_done && !web_httpd_upgrade_requested(&req));
|
||||
begin("/ws/serial", HTTP_GET, NULL); same_origin(); aux.ws_handshake_detect = true;
|
||||
add("Sec-WebSocket-Version", "130"); add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
||||
assert(!web_httpd_upgrade_requested(&req));
|
||||
for (size_t keep = 0; keep <= sizeof(socket_state.pending_data); ++keep) {
|
||||
memset(socket_state.pending_data, 's', sizeof(socket_state.pending_data));
|
||||
size_t offset = sizeof(socket_state.pending_data) - keep;
|
||||
memset(socket_state.pending_data + offset, 'p', keep);
|
||||
socket_state.pending_len = keep;
|
||||
web_httpd_wipe_request(&req, false);
|
||||
zero(socket_state.pending_data, offset);
|
||||
char received[128];
|
||||
size_t first = host_read_pending(&req, received, 1);
|
||||
assert(first == (keep ? 1U : 0U));
|
||||
if (first) assert(received[0] == 'p');
|
||||
web_httpd_wipe_request(&req, false);
|
||||
zero(socket_state.pending_data, offset + first);
|
||||
size_t remaining = host_read_pending(&req, received, sizeof(received));
|
||||
assert(remaining == keep - first);
|
||||
for (size_t i = 0; i < remaining; ++i) assert(received[i] == 'p');
|
||||
}
|
||||
memset(socket_state.pending_data, 's', sizeof(socket_state.pending_data));
|
||||
socket_state.pending_len = 3; web_httpd_wipe_request(&req, true);
|
||||
zero(socket_state.pending_data, sizeof(socket_state.pending_data));
|
||||
zero(scratch, sizeof(scratch));
|
||||
puts("PASS: request wiping preserves right-aligned pending data through actual IDF reader (all lengths/partial reads)");
|
||||
puts("PASS: stop/login race, RNG/send/receive failure, actual IDF first-header semantics and explicit upgrade adapter");
|
||||
for (unsigned limit = 0; limit < 6; ++limit) {
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
server.config.max_resp_headers = limit;
|
||||
assert(web_cookie_auth_handler(&req) != ESP_OK);
|
||||
assert(snapshot().active == 0);
|
||||
server.config.max_resp_headers = 8;
|
||||
}
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
server.config.max_resp_headers = 6; expect("200 OK"); assert(cookie_count == 2);
|
||||
server.config.max_resp_headers = 8;
|
||||
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user