- Add admin account list, create, role, delete, and password workflows - Execute identity-checked mutations through the existing dispatcher - Bound queued credential lifetime and wipe transient secrets - Add explicit password generation with saved-value acknowledgement - Handle self-revocation and uncertain outcomes without automatic retries - Register optional account routes without disrupting terminal transports - Expand host regressions and document contracts and pending target checks Validated host suites and pio run; hardware validation remains pending.
297 lines
16 KiB
C
297 lines
16 KiB
C
/* Production store dependency doubles and its existing public API suite. */
|
|
#define main store_tests
|
|
#ifdef HOST_ACCOUNTS
|
|
#define user_database_username_valid store_username_valid
|
|
#endif
|
|
#include "../web_session_store/test.c"
|
|
#undef main
|
|
#ifdef HOST_ACCOUNTS
|
|
#undef user_database_username_valid
|
|
#endif
|
|
#include "web_cookie_auth.h"
|
|
#include "web_httpd_adapter.h"
|
|
#include "esp_httpd_priv.h"
|
|
#ifdef HOST_ADMIN
|
|
#include "web_admin_transport.h"
|
|
#endif
|
|
|
|
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 size_t receive_fragment = 7;
|
|
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 > receive_fragment) size = receive_fragment;
|
|
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);
|
|
#ifdef HOST_ADMIN
|
|
web_admin_transport_revoke(id, NULL, 0);
|
|
#endif
|
|
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;
|
|
}
|
|
|
|
#ifdef HOST_ADMIN
|
|
#include "admin_test.c"
|
|
#endif
|
|
#ifdef HOST_SETTINGS
|
|
#include "settings_test.c"
|
|
#endif
|
|
#ifdef HOST_SERIAL_SETTINGS
|
|
#include "serial_settings_test.c"
|
|
#endif
|
|
#ifdef HOST_ACCOUNTS
|
|
#include "account_settings_test.c"
|
|
#endif
|
|
|
|
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");
|
|
#ifdef HOST_ADMIN
|
|
admin_tests();
|
|
#endif
|
|
#ifdef HOST_SETTINGS
|
|
settings_tests();
|
|
#endif
|
|
#ifdef HOST_SERIAL_SETTINGS
|
|
serial_settings_tests();
|
|
#endif
|
|
#ifdef HOST_ACCOUNTS
|
|
account_settings_tests();
|
|
#endif
|
|
return 0;
|
|
}
|