Add Phase 9C security hardening
Generate exact-hash SDK source overrides without modifying dependencies. Harden SSH allocation and algorithm policy, tighten web authentication cleanup, and add focused host contract tests and documentation.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Included by test.c: public API checks with live-buffer IO/parser/KDF probes. */
|
||||
static const char auth_wrong_body[] = "{\"username\":\"alice\",\"password\":\"wrong\"}";
|
||||
static const char auth_bad_json[] = "{\"username\":\"alice\",\"password\":\"secret-value\",\"extra\":1}";
|
||||
|
||||
static web_cookie_auth_snapshot_t auth_counts(void) {
|
||||
web_cookie_auth_snapshot_t counts;
|
||||
web_cookie_auth_get_snapshot(&counts);
|
||||
return counts;
|
||||
}
|
||||
|
||||
static esp_err_t auth_observe(const char *status) {
|
||||
watch_login = true;
|
||||
esp_err_t result = web_cookie_auth_handler(&req);
|
||||
watch_login = false; /* captured stack pointers are no longer live */
|
||||
assert(!strcmp(response_status, status));
|
||||
return result;
|
||||
}
|
||||
|
||||
static void auth_five_verifications(void) {
|
||||
char token[65], csrf[65];
|
||||
for (unsigned i = 0; i < 5; ++i) {
|
||||
challenge(token, csrf); login_request(token, csrf, auth_wrong_body);
|
||||
assert(auth_observe("401 Unauthorized") == ESP_OK);
|
||||
}
|
||||
}
|
||||
|
||||
static void auth_restart_hook(void) {
|
||||
assert(!host_lock_depth);
|
||||
web_cookie_auth_stop();
|
||||
assert(web_cookie_auth_start() == ESP_OK);
|
||||
}
|
||||
|
||||
static void auth_deadline_hook(void) { now += 3000001; }
|
||||
|
||||
static void auth_budget_tests(void) {
|
||||
char token[65], csrf[65];
|
||||
now = 180000000; auth_reset(); web_cookie_auth_clear_counters();
|
||||
auth_five_verifications();
|
||||
assert(password_calls == 5 && parser_calls == 5 && auth_counts().login_attempts == 5);
|
||||
unsigned reads = receive_calls, parses = parser_calls;
|
||||
const int64_t offsets[] = {0, 58999999, 59999999};
|
||||
const char *retries[] = {"60", "2", "1"};
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
now = 180000000 + offsets[i];
|
||||
challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
recv_fail = true; /* an exhausted budget must never reach this failure */
|
||||
assert(auth_observe("429 Too Many Requests") == ESP_FAIL);
|
||||
recv_fail = false;
|
||||
assert(receive_calls == reads && parser_calls == parses && password_calls == 5);
|
||||
assert(body_offset == 0 && aux.remaining_len == strlen(good_body));
|
||||
assert(!strcmp(retry_value, retries[i]));
|
||||
assert(cookie_count == 1 && strstr(cookie_values[0], "__Host-sak-prelogin="));
|
||||
assert(strstr(cookie_values[0], "Max-Age=0"));
|
||||
assert(auth_counts().active_challenges == 0 && auth_counts().login_attempts == 5);
|
||||
zero(scratch, sizeof(scratch));
|
||||
login_request(token, csrf, good_body);
|
||||
assert(auth_observe("403 Forbidden") == ESP_FAIL); /* denied challenge stayed consumed */
|
||||
assert(receive_calls == reads && parser_calls == parses && password_calls == 5);
|
||||
}
|
||||
now = 240000000;
|
||||
login_request(token, csrf, good_body);
|
||||
assert(auth_observe("403 Forbidden") == ESP_FAIL); /* refill cannot resurrect it */
|
||||
challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
assert(auth_observe("200 OK") == ESP_OK);
|
||||
assert(password_calls == 6 && parser_calls == parses + 1 && receive_calls > reads);
|
||||
assert(auth_counts().login_attempts == 6 && !retry_value[0]);
|
||||
|
||||
auth_reset(); web_cookie_auth_clear_counters(); auth_five_verifications();
|
||||
web_cookie_auth_clear_counters();
|
||||
challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
reads = receive_calls; parses = parser_calls;
|
||||
assert(auth_observe("429 Too Many Requests") == ESP_FAIL);
|
||||
assert(!auth_counts().login_attempts && auth_counts().throttled == 1);
|
||||
assert(receive_calls == reads && parser_calls == parses && password_calls == 5);
|
||||
auth_restart_hook(); /* existing restart replenishment remains intentional */
|
||||
challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
assert(auth_observe("200 OK") == ESP_OK);
|
||||
assert(auth_counts().login_attempts == 1 && password_calls == 6);
|
||||
puts("PASS: exhausted budget before receive/parser/KDF, close without drain, rounded Retry-After/refill boundary, consumed challenge and restart semantics");
|
||||
}
|
||||
|
||||
static void auth_malformed_budget_tests(void) {
|
||||
char token[65], csrf[65];
|
||||
now = 360000000; auth_reset(); web_cookie_auth_clear_counters();
|
||||
for (unsigned i = 0; i < 7; ++i) {
|
||||
challenge(token, csrf); login_request(token, csrf, auth_bad_json);
|
||||
assert(auth_observe("400 Bad Request") == ESP_OK);
|
||||
assert(body_wipes && credential_wipes);
|
||||
assert(!password_calls && !auth_counts().login_attempts);
|
||||
}
|
||||
assert(parser_calls == 7 && receive_calls > 7);
|
||||
now = 365000000; auth_five_verifications();
|
||||
assert(password_calls == 5 && auth_counts().login_attempts == 5);
|
||||
now = 420000000; /* 60 s after malformed probe, only 55 s after first verification */
|
||||
challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
unsigned reads = receive_calls, parses = parser_calls;
|
||||
assert(auth_observe("429 Too Many Requests") == ESP_FAIL);
|
||||
assert(!strcmp(retry_value, "5") && receive_calls == reads && parser_calls == parses);
|
||||
now = 425000000;
|
||||
challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
assert(auth_observe("200 OK") == ESP_OK && password_calls == 6);
|
||||
puts("PASS: malformed requests do not charge verification attempts or advance the verification window");
|
||||
}
|
||||
|
||||
static void auth_epoch_tests(void) {
|
||||
char token[65], csrf[65];
|
||||
for (unsigned phase = 0; phase < 2; ++phase) {
|
||||
for (unsigned restart = 0; restart < 2; ++restart) {
|
||||
auth_reset(); web_cookie_auth_clear_counters();
|
||||
challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
void (*hook)(void) = restart ? auth_restart_hook : web_cookie_auth_stop;
|
||||
if (phase == 0) header_hook = hook; /* consumed challenge, before first probe */
|
||||
else parse_hook = hook; /* early probe passed, before final reservation */
|
||||
assert(auth_observe("503 Service Unavailable") == (phase ? ESP_OK : ESP_FAIL));
|
||||
assert(!password_calls && !auth_counts().login_attempts && !snapshot().active);
|
||||
if (phase) {
|
||||
assert(receive_calls && parser_calls == 1 && body_wipes && credential_wipes);
|
||||
} else {
|
||||
assert(!receive_calls && !parser_calls && !body_offset);
|
||||
assert(aux.remaining_len == strlen(good_body));
|
||||
}
|
||||
}
|
||||
}
|
||||
puts("PASS: readiness and epoch fencing at early probe and authoritative post-parse reservation");
|
||||
}
|
||||
|
||||
static void auth_plaintext_tests(void) {
|
||||
char token[65], csrf[65];
|
||||
for (unsigned mode = 0; mode < 4; ++mode) {
|
||||
auth_reset(); web_cookie_auth_clear_counters();
|
||||
challenge(token, csrf);
|
||||
login_request(token, csrf, mode == 1 ? auth_wrong_body : mode == 2 ? auth_bad_json : good_body);
|
||||
const char *status = mode == 1 ? "401 Unauthorized" : mode == 2 ? "400 Bad Request" :
|
||||
mode == 3 ? "503 Service Unavailable" : "200 OK";
|
||||
send_fail = true; db_fail = mode == 3;
|
||||
assert(auth_observe(status) == ESP_FAIL);
|
||||
send_fail = db_fail = false;
|
||||
assert(body_wipes && credential_wipes && !snapshot().active);
|
||||
assert(password_calls == (mode == 2 ? 0U : 1U));
|
||||
assert(auth_counts().login_attempts == password_calls);
|
||||
}
|
||||
for (unsigned mode = 0; mode < 2; ++mode) {
|
||||
auth_reset(); web_cookie_auth_clear_counters();
|
||||
challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
receive_fragment = 7;
|
||||
if (mode) recv_hook = auth_deadline_hook;
|
||||
else recv_fail_after = 7;
|
||||
send_fail = true;
|
||||
assert(auth_observe("400 Bad Request") == ESP_FAIL);
|
||||
send_fail = false; recv_fail_after = 0;
|
||||
assert(body_offset == 7 && aux.remaining_len == strlen(good_body) - 7);
|
||||
assert(receive_calls == (mode ? 1U : 2U) && body_wipes);
|
||||
assert(!parser_calls && !password_calls && !auth_counts().login_attempts);
|
||||
}
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
unsigned before = sends;
|
||||
server.config.max_resp_headers = 1; /* final Set-Cookie fails after successful KDF */
|
||||
watch_login = true;
|
||||
assert(web_cookie_auth_handler(&req) != ESP_OK);
|
||||
watch_login = false;
|
||||
server.config.max_resp_headers = 8;
|
||||
assert(sends == before && body_wipes && credential_wipes && !snapshot().active);
|
||||
puts("PASS: raw JSON wiped before KDF, credentials wiped after KDF, pre-send error wiping and send/header/partial-receive failure cleanup");
|
||||
}
|
||||
|
||||
static void auth_admission_tests(void) {
|
||||
auth_budget_tests();
|
||||
auth_malformed_budget_tests();
|
||||
auth_epoch_tests();
|
||||
auth_plaintext_tests();
|
||||
auth_reset();
|
||||
}
|
||||
@@ -253,7 +253,9 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_BROKER"] if broker else []),
|
||||
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
|
||||
*(["-DHOST_LIFECYCLE"] if lifecycle else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto", *(["-lmbedcrypto"] if ssh_settings else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources),
|
||||
"-Wl,--wrap=web_auth_parse_login,--wrap=secure_wipe,--wrap=httpd_resp_set_hdr",
|
||||
"-lcrypto", *(["-lmbedcrypto"] if ssh_settings else []),
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
if lifecycle:
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#undef user_database_username_valid
|
||||
#endif
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_auth_parse.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "esp_httpd_priv.h"
|
||||
#ifdef HOST_ADMIN
|
||||
@@ -32,11 +33,48 @@ static bool send_fail, recv_fail;
|
||||
static size_t receive_fragment = 7;
|
||||
static void (*password_hook)(void);
|
||||
static void (*send_hook)(void);
|
||||
static void (*header_hook)(void), (*parse_hook)(void), (*recv_hook)(void);
|
||||
static unsigned receive_calls, parser_calls, body_wipes, credential_wipes;
|
||||
static size_t recv_fail_after;
|
||||
static bool watch_login;
|
||||
static const char *watched_body;
|
||||
static web_auth_credentials_t *watched_credentials;
|
||||
static char retry_value[16];
|
||||
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);
|
||||
|
||||
/* Link-time wrappers retain the production parser, wipe, and IDF header setter. */
|
||||
bool __real_web_auth_parse_login(const char *, size_t, web_auth_credentials_t *);
|
||||
void __real_secure_wipe(void *, size_t);
|
||||
esp_err_t __real_httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
|
||||
bool __wrap_web_auth_parse_login(const char *body, size_t length, web_auth_credentials_t *credentials) {
|
||||
assert(!host_lock_depth); ++parser_calls;
|
||||
if (watch_login) { watched_body = body; watched_credentials = credentials; }
|
||||
bool valid = __real_web_auth_parse_login(body, length, credentials);
|
||||
if (parse_hook) { void (*hook)(void) = parse_hook; parse_hook = NULL; hook(); }
|
||||
return valid;
|
||||
}
|
||||
void __wrap_secure_wipe(void *data, size_t length) {
|
||||
__real_secure_wipe(data, length);
|
||||
if (watch_login && data == watched_body && length == WEB_AUTH_LOGIN_BODY_MAX + 1) {
|
||||
zero(data, length); ++body_wipes;
|
||||
}
|
||||
if (watch_login && data == watched_credentials && length == sizeof(*watched_credentials)) {
|
||||
zero(data, length); ++credential_wipes;
|
||||
}
|
||||
}
|
||||
esp_err_t __wrap_httpd_resp_set_hdr(httpd_req_t *r, const char *name, const char *value) {
|
||||
assert(!host_lock_depth);
|
||||
esp_err_t error = __real_httpd_resp_set_hdr(r, name, value);
|
||||
if (header_hook && !strcmp(name, "Set-Cookie") &&
|
||||
strstr(value, "__Host-sak-prelogin=") && strstr(value, "Max-Age=0")) {
|
||||
void (*hook)(void) = header_hook; header_hook = NULL; hook();
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -45,11 +83,18 @@ 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);
|
||||
(void)r; assert(!host_lock_depth);
|
||||
if (watch_login) {
|
||||
if (watched_body) { assert(body_wipes); zero(watched_body, WEB_AUTH_LOGIN_BODY_MAX + 1); }
|
||||
if (watched_credentials) { assert(credential_wipes); zero(watched_credentials, sizeof(*watched_credentials)); }
|
||||
}
|
||||
++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, "Retry-After"))
|
||||
snprintf(retry_value, sizeof(retry_value), "%s", response_headers[i].value);
|
||||
if (!strcmp(response_headers[i].field, "Set-Cookie")) {
|
||||
assert(cookie_count < 2);
|
||||
snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value);
|
||||
@@ -59,10 +104,14 @@ esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
|
||||
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;
|
||||
(void)r; assert(!host_lock_depth); ++receive_calls;
|
||||
if (watch_login && !body_offset) watched_body = out;
|
||||
if (recv_fail || (recv_fail_after && body_offset >= recv_fail_after)) 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;
|
||||
aux.remaining_len -= size;
|
||||
if (recv_hook) { void (*hook)(void) = recv_hook; recv_hook = NULL; hook(); }
|
||||
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) {
|
||||
@@ -78,6 +127,12 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *r, const char *protocol
|
||||
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 (watch_login) {
|
||||
assert(watched_body && watched_credentials && body_wipes && !credential_wipes);
|
||||
zero(watched_body, WEB_AUTH_LOGIN_BODY_MAX + 1);
|
||||
assert(u == watched_credentials->username && p == watched_credentials->password);
|
||||
assert(un == watched_credentials->username_length && pn == watched_credentials->password_length);
|
||||
}
|
||||
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;
|
||||
@@ -93,7 +148,9 @@ static void begin(const char *uri, int method, const char *body) {
|
||||
.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;
|
||||
response_status[0] = output[0] = retry_value[0] = 0; cookie_count = 0;
|
||||
watch_login = false; watched_body = NULL; watched_credentials = NULL;
|
||||
body_wipes = credential_wipes = 0;
|
||||
}
|
||||
static void add(const char *key, const char *value) {
|
||||
char *at = scratch;
|
||||
@@ -126,10 +183,15 @@ static void login_request(const char *token, const char *csrf, const char *body)
|
||||
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
||||
}
|
||||
static void auth_reset(void) {
|
||||
watch_login = false; watched_body = NULL; watched_credentials = NULL;
|
||||
header_hook = parse_hook = recv_hook = NULL; recv_fail_after = 0;
|
||||
receive_calls = parser_calls = 0;
|
||||
web_cookie_auth_stop(); reset(); assert(web_cookie_auth_start() == ESP_OK);
|
||||
password_calls = 0; password_hook = NULL;
|
||||
}
|
||||
|
||||
#include "admission_test.c"
|
||||
|
||||
#ifdef HOST_ADMIN
|
||||
#include "admin_test.c"
|
||||
#endif
|
||||
@@ -302,6 +364,7 @@ int main(void) {
|
||||
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");
|
||||
auth_admission_tests();
|
||||
#ifdef HOST_ADMIN
|
||||
admin_tests();
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user