/* SPDX-License-Identifier: GPL-3.0-only */ #include "fakes.h" #include "web_auth_parse.h" #include "esp_app_format.h" #include "esp_app_desc.h" #include "esp_image_format.h" #include "web_firmware_update.h" static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; } static size_t strlcpy(char *out, const char *in, size_t size) { size_t length = strlen(in); if (size) { size_t n = length < size - 1 ? length : size - 1; memcpy(out, in, n); out[n] = 0; } return length; } /* IDF getters compare ptrdiff_t with size_t; retain their exact source. */ #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wsign-compare" #include "httpd_production.h" #pragma GCC diagnostic pop static int s_lock; static bool s_ready; static web_cookie_auth_snapshot_t s_counts; #include "auth_production.h" static SemaphoreHandle_t s_server_mutex; static httpd_handle_t s_server; static bool s_transitioning; static esp_err_t s_last_error; static uint32_t s_generation; #include "server_production.h" static void *test_malloc(size_t); static void test_free(void *); #define malloc test_malloc #define free test_free #include "web_firmware_update.c" #undef malloc #undef free static uint8_t image[12000], flash[12000]; static httpd_req_t request; static esp_partition_t running, target; static bool missing_target, missing_running; static bool lookup_current, final_current, lookup_unavailable, final_unavailable; static int role, stage_fail, response_fail, response_step; static bool malloc_fail, task_fail, identity_busy, locked, mutex_busy; static unsigned allocations, allocations_live, tasks_created, tasks_deleted; static unsigned begins, writes, ends, aborts, metadata_reads, commits, receives, wipes; static unsigned restarts, response_sends, releases, identity_releases; static size_t flash_size, max_chunk, cutoff, stall_at, parsed_size; static int64_t now_us, read_us; static char response_status[64], response_body[160]; static uint32_t notification, identity_token; static void (*task_entry)(void *); static int task_storage, server_storage, mutex_storage; static bool receive_race, commit_race, revoke_at_metadata; static unsigned cases; static bool ota_live; static char raw_length[32], header_scratch[2048]; static struct httpd_req_aux request_aux; static void *test_malloc(size_t size) { assert(size == BUFFER_SIZE); ++allocations; if (malloc_fail) return NULL; ++allocations_live; return malloc(size); } static void test_free(void *p) { if (p) { assert(allocations_live == 1); --allocations_live; } free(p); } void secure_wipe(void *p, size_t n) { memset(p, 0, n); } int64_t esp_timer_get_time(void) { return now_us; } int xSemaphoreTake(SemaphoreHandle_t m, unsigned ticks) { assert(m == &mutex_storage && !locked); if (mutex_busy) { assert(ticks == 0); return 0; } locked = true; return pdTRUE; } void xSemaphoreGive(SemaphoreHandle_t m) { assert(m == &mutex_storage && locked); locked = false; } int xTaskCreate(void (*entry)(void *), const char *name, unsigned stack, void *arg, unsigned priority, TaskHandle_t *out) { assert(!strcmp(name, "fw_reboot") && stack == 2048 && !arg && priority == 5); assert(s_transitioning && identity_token && !commits && !task_entry); if (task_fail) return 0; task_entry = entry; *out = &task_storage; ++tasks_created; return pdPASS; } int xTaskNotify(TaskHandle_t t, uint32_t value, int action) { assert(t == &task_storage && task_entry && !notification && action == eSetValueWithOverwrite); assert(value == 1 || value == 2); if (value == 1) assert(commits == 1 && response_sends == 1 && !response_fail && !aborts); notification = value; return pdPASS; } int xTaskNotifyWait(uint32_t clear_in, uint32_t clear_out, uint32_t *value, uint32_t wait) { assert(!clear_in && clear_out == UINT32_MAX && wait == portMAX_DELAY && notification); *value = notification; return pdTRUE; } void vTaskDelay(unsigned ticks) { assert(ticks == 500 && notification == 1); } void vTaskDelete(TaskHandle_t task) { assert(!task); ++tasks_deleted; } void esp_restart(void) { assert(!locked); ++restarts; } static void finish_owner(void) { if (task_entry) { assert(notification); task_entry(NULL); task_entry = NULL; assert(tasks_deleted == tasks_created); } } static void set_header(const char *name, const char *value) { for (size_t i = 0; i < request.header_count; ++i) { if (!strcmp(name, request.headers[i].name)) { request.headers[i].value = value; return; } } assert(request.header_count < 12); request.headers[request.header_count++] = (test_header_t){name, value}; } static void set_length(size_t length) { request.content_len = length; snprintf(raw_length, sizeof(raw_length), "%zu", length); set_header("Content-Length", raw_length); } bool web_httpd_headers_valid(httpd_req_t *r) { request_aux = (struct httpd_req_aux){.scratch = header_scratch}; for (size_t i = 0; i < r->header_count; ++i) { if (!r->headers[i].value) continue; size_t used = request_aux.scratch_cur_size; int written = snprintf(header_scratch + used, sizeof(header_scratch) - used, "%s:%s", r->headers[i].name, r->headers[i].value); assert(written >= 0 && (size_t)written + 1 <= sizeof(header_scratch) - used); request_aux.scratch_cur_size += (size_t)written + 1; ++request_aux.req_hdrs_count; } r->aux = &request_aux; return r->headers_valid && adapter_headers_valid(r); } bool web_httpd_unread_body(httpd_req_t *r) { return r->received < r->content_len; } void web_httpd_wipe_request(httpd_req_t *r, bool unread) { assert(unread == (r->received < r->content_len)); ++wipes; } static esp_err_t response_result(void) { return ++response_step == response_fail ? ESP_FAIL : ESP_OK; } esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { assert(r == &request && strlen(s) < sizeof(response_status)); strcpy(response_status, s); return response_result(); } esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { assert(r == &request && !strcmp(s, "application/json; charset=utf-8")); return response_result(); } esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *name, const char *value) { assert(r == &request && name && value); return response_result(); } esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) { assert(r == &request && strlen(body) < sizeof(response_body)); ++response_sends; strcpy(response_body, body); return response_result(); } static void competing_lifecycle(void) { assert(s_transitioning && !locked); assert(web_firmware_update_reserve(request.handle) == ESP_ERR_INVALID_STATE); assert(web_server_reboot_current(s_generation) == ESP_ERR_INVALID_STATE); assert(!restarts); uint32_t token = 0; assert(web_security_reserve_identity(0, false, &token) == ESP_ERR_INVALID_STATE && !token); } int httpd_req_recv(httpd_req_t *r, char *out, size_t want) { assert(r == &request && want && want <= BUFFER_SIZE && s_transitioning && identity_token); assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE); ++receives; now_us += read_us; if (receive_race) competing_lifecycle(); if (r->received >= stall_at) return HTTPD_SOCK_ERR_TIMEOUT; if (r->received >= cutoff) return 0; size_t count = want < max_chunk ? want : max_chunk; if (count > cutoff - r->received) count = cutoff - r->received; assert(r->received + count <= sizeof(image)); memcpy(out, image + r->received, count); r->received += count; return (int)count; } esp_err_t web_session_store_lookup(const char *token, size_t length, const char *origin, size_t origin_length, web_session_view_t *view) { assert(length == 64 && token[0] == 'a' && origin_length == strlen(origin)); assert(!strcmp(origin, "https://device")); if (lookup_unavailable) return ESP_FAIL; if (!lookup_current) return ESP_ERR_NOT_FOUND; view->id = 42; view->principal.role = role; view->principal.user_id = 9; memset(view->csrf, 'b', 64); view->csrf[64] = 0; return ESP_OK; } esp_err_t web_session_store_check_principal(web_session_id_t id, const user_principal_t *principal, bool *current) { assert(id == 42 && principal->role == USER_ROLE_ADMIN && principal->user_id == 9); assert(ends == 1 && metadata_reads == 1 && !commits); *current = final_current; return final_unavailable ? ESP_FAIL : ESP_OK; } esp_err_t web_security_reserve_identity(uint32_t expected, bool reset, uint32_t *token) { assert(!expected && !reset && s_transitioning); *token = 0; if (identity_busy || identity_token) return ESP_ERR_INVALID_STATE; *token = identity_token = 7; return ESP_OK; } void web_security_release_identity(uint32_t token) { assert(token && token == identity_token && s_transitioning); identity_token = 0; ++identity_releases; } const esp_partition_t *esp_ota_get_running_partition(void) { return missing_running ? NULL : &running; } const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *p) { assert(!p); return missing_target ? NULL : ⌖ } esp_err_t esp_ota_begin(const esp_partition_t *p, size_t size, esp_ota_handle_t *handle) { assert(p == &target && p->address != running.address && size == request.content_len); assert(size <= target.size && request.received == PREFIX_SIZE && s_transitioning && identity_token); assert(tasks_created == 1 && !locked); ++begins; assert(!ota_live && *handle == 0); if (stage_fail == 1) return ESP_FAIL; *handle = 123; ota_live = true; /* IDF publishes the handle before erasing; this models erase failure. */ return stage_fail == 6 ? ESP_FAIL : ESP_OK; } esp_err_t esp_ota_write(esp_ota_handle_t handle, const void *data, size_t size) { assert(handle == 123 && s_transitioning && !locked && size <= BUFFER_SIZE); assert(flash_size + size <= sizeof(flash)); ++writes; if (stage_fail == 2) return ESP_FAIL; memcpy(flash + flash_size, data, size); flash_size += size; return ESP_OK; } esp_err_t esp_ota_end(esp_ota_handle_t handle) { assert(handle == 123 && flash_size == request.content_len && !memcmp(flash, image, flash_size)); assert(!aborts && !locked && s_transitioning && tasks_created == 1 && ota_live); ++ends; ota_live = false; /* End consumes on success AND validation failure. */ return stage_fail == 3 ? ESP_FAIL : ESP_OK; } esp_err_t esp_ota_abort(esp_ota_handle_t handle) { assert(handle == 123 && begins == 1 && !ends && !commits && ota_live); ota_live = false; ++aborts; return ESP_OK; } esp_err_t esp_image_get_metadata(const esp_partition_pos_t *p, esp_image_metadata_t *metadata) { assert(p->offset == target.address && p->size == target.size && ends == 1); ++metadata_reads; metadata->image_len = (uint32_t)parsed_size; if (revoke_at_metadata) final_current = false; return stage_fail == 4 ? ESP_FAIL : ESP_OK; } esp_err_t esp_ota_set_boot_partition(const esp_partition_t *p) { assert(p == &target && ends == 1 && metadata_reads == 1 && final_current && !final_unavailable); assert(tasks_created == 1 && !response_sends && s_transitioning && identity_token && !locked); assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE); if (commit_race) competing_lifecycle(); if (stage_fail == 5) return ESP_FAIL; ++commits; return ESP_OK; } static void reset(void) { assert(!allocations_live && !task_entry && !locked && !ota_live); atomic_store(&s_reboot_gate, false); s_firmware_selected = false; ++cases; memset(&request, 0, sizeof(request)); request.uri = WEB_FIRMWARE_UPDATE_URI; request.method = HTTP_POST; request.content_len = sizeof(image); request.handle = &server_storage; request.headers_valid = true; set_header("Host", "device"); set_header("Origin", "https://device"); set_header("Content-Type", "application/octet-stream"); set_length(sizeof(image)); set_header("Cookie", SESSION_COOKIE "=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); set_header("X-CSRF-Token", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); memset(image, 0x55, sizeof(image)); memset(flash, 0, sizeof(flash)); esp_image_header_t header = {.magic = ESP_IMAGE_HEADER_MAGIC, .chip_id = ESP_CHIP_ID_ESP32S3, .segment_count = 3, .hash_appended = 1}; esp_image_segment_header_t segment = {.data_len = sizeof(esp_app_desc_t)}; uint32_t magic = ESP_APP_DESC_MAGIC_WORD; memcpy(image, &header, sizeof(header)); memcpy(image + sizeof(header), &segment, sizeof(segment)); memcpy(image + sizeof(header) + sizeof(segment), &magic, sizeof(magic)); running = (esp_partition_t){.type = 0, .subtype = 0x10, .address = 0x10000, .size = 0x400000}; target = (esp_partition_t){.type = 0, .subtype = 0x11, .address = 0x410000, .size = 0x400000}; missing_target = missing_running = false; s_ready = lookup_current = final_current = true; s_server_mutex = &mutex_storage; s_server = &server_storage; s_transitioning = false; s_last_error = ESP_OK; s_generation = 11; lookup_unavailable = final_unavailable = malloc_fail = task_fail = identity_busy = mutex_busy = false; receive_race = commit_race = revoke_at_metadata = false; role = USER_ROLE_ADMIN; stage_fail = response_fail = response_step = 0; allocations = allocations_live = tasks_created = tasks_deleted = 0; begins = writes = ends = aborts = metadata_reads = commits = receives = wipes = 0; restarts = response_sends = releases = identity_releases = 0; flash_size = 0; max_chunk = BUFFER_SIZE; cutoff = stall_at = SIZE_MAX; parsed_size = sizeof(image); now_us = 0; read_us = 1000; notification = identity_token = 0; response_status[0] = response_body[0] = 0; } static void rejected(const char *status, const char *code) { bool pre_reserved = s_transitioning; bool gate_reserved = atomic_load(&s_reboot_gate); esp_err_t result = web_firmware_update_handler(&request); assert(!strcmp(response_status, status) && strstr(response_body, code)); assert(result == (request.received < request.content_len ? ESP_FAIL : ESP_OK)); assert(!commits && !allocations_live && !restarts && wipes == 1 && !ota_live); assert(s_transitioning == pre_reserved && !identity_token); assert(atomic_load(&s_reboot_gate) == gate_reserved); if (task_entry) assert(notification == 2); finish_owner(); assert(!restarts); } static void success(void) { assert(web_firmware_update_handler(&request) == ESP_OK); assert(!strcmp(response_status, "200 OK") && !strcmp(response_body, "{\"ok\":true,\"rebooting\":true}")); assert(commits == 1 && ends == 1 && !aborts && s_transitioning && identity_token); assert(!allocations_live && notification == 1 && !restarts && wipes == 1); assert(s_firmware_selected && web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE); assert(request.received == request.content_len && !memcmp(image, flash, sizeof(image))); finish_owner(); assert(restarts == 1); } int main(void) { reset(); assert(web_firmware_update_reserve_reboot() == ESP_OK); rejected("503 Service Unavailable", "busy"); assert(!receives && !begins && !allocations); reset(); s_server_mutex = NULL; s_server = NULL; assert(web_firmware_update_reserve_reboot() == ESP_OK); assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE); reset(); success(); reset(); max_chunk = 1; receive_race = commit_race = true; success(); assert(receives == sizeof(image)); reset(); max_chunk = 17; success(); puts("PASS bounded streaming, bytewise partial prefix, exact writes, delayed owner restart and reservation races"); reset(); lookup_current = false; rejected("401 Unauthorized", "authentication_required"); assert(!receives && !allocations); reset(); lookup_unavailable = true; rejected("503 Service Unavailable", "unavailable"); reset(); role = USER_ROLE_USER; rejected("403 Forbidden", "admin_required"); assert(!receives && !allocations); reset(); set_header("X-CSRF-Token", NULL); rejected("403 Forbidden", "csrf"); reset(); set_header("X-CSRF-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); rejected("403 Forbidden", "csrf"); reset(); set_header("Origin", NULL); rejected("403 Forbidden", "origin"); reset(); set_header("Origin", "null"); rejected("403 Forbidden", "origin"); reset(); set_header("Origin", "https://attacker"); rejected("403 Forbidden", "origin"); reset(); set_header("Sec-Fetch-Site", "cross-site"); rejected("403 Forbidden", "origin"); reset(); set_header("Cookie", NULL); rejected("401 Unauthorized", "authentication_required"); reset(); request.headers_valid = false; rejected("400 Bad Request", "invalid_request"); reset(); request.uri = "/api/firmware?x=1"; rejected("400 Bad Request", "invalid_request"); reset(); request.method = HTTP_GET; rejected("400 Bad Request", "invalid_request"); reset(); s_ready = false; rejected("503 Service Unavailable", "unavailable"); puts("PASS production cookie/Origin/CSRF/admin admission before receive or flash (SDK getters/adapter real, database doubled)"); const char *invalid_lengths[] = {NULL, "", " ", "+12000", "-12000", "12000x", "1.2e4", "12 000", "12000,12000", "12000 ", "12000\t", "\t12000", "11999", "12001", "000000000000000012000"}; for (size_t i = 0; i < sizeof(invalid_lengths) / sizeof(invalid_lengths[0]); ++i) { reset(); set_header("Content-Length", invalid_lengths[i]); rejected("400 Bad Request", "invalid_request"); assert(!receives && !begins && !allocations && !notification); } const char *oversized_lengths[] = {"4194305", "4296815136", "4294967296", "18446744073709551615", "18446744073709551616", "99999999999999999999"}; for (size_t i = 0; i < sizeof(oversized_lengths) / sizeof(oversized_lengths[0]); ++i) { reset(); set_header("Content-Length", oversized_lengths[i]); /* Explicitly model HTTPD's 64-bit -> ESP32 size_t narrowing. */ request.content_len = i == 1 ? 1847840 : i == 2 ? 0 : sizeof(image); rejected("413 Payload Too Large", "firmware_too_large"); assert(!receives && !begins && !allocations && !notification); } reset(); request.headers[request.header_count++] = (test_header_t){"content-length", "12000"}; rejected("400 Bad Request", "invalid_request"); assert(!receives && !begins && !allocations); reset(); set_header("Content-Length", " 12000"); success(); reset(); set_header("Content-Length", "00000000000000012000"); success(); puts("PASS raw Content-Length missing/duplicate/malformed/oversized/64-bit wrap and overflow reject before receive/erase; IDF leading-space semantics"); reset(); set_header("Content-Type", "multipart/form-data"); rejected("415 Unsupported Media Type", "firmware_content_type"); reset(); set_header("Content-Type", NULL); rejected("415 Unsupported Media Type", "firmware_content_type"); reset(); set_length(0); rejected("400 Bad Request", "invalid_firmware"); reset(); set_length(PREFIX_SIZE - 1); rejected("400 Bad Request", "invalid_firmware"); reset(); set_length(target.size + 1); rejected("413 Payload Too Large", "firmware_too_large"); assert(!allocations); reset(); target.size = sizeof(image); success(); reset(); missing_target = true; rejected("503 Service Unavailable", "firmware_unavailable"); reset(); missing_running = true; rejected("503 Service Unavailable", "firmware_unavailable"); reset(); target.address = running.address; rejected("503 Service Unavailable", "firmware_unavailable"); reset(); target.type = 1; rejected("503 Service Unavailable", "firmware_unavailable"); reset(); target.subtype = 0; rejected("503 Service Unavailable", "firmware_unavailable"); for (unsigned field = 0; field < 6; ++field) { reset(); esp_image_header_t header; memcpy(&header, image, sizeof(header)); if (field == 0) header.magic = 0; if (field == 1) header.chip_id = ESP_CHIP_ID_ESP32; if (field == 2) header.segment_count = 0; if (field == 3) header.hash_appended = 0; memcpy(image, &header, sizeof(header)); if (field == 4) memset(image + sizeof(header) + sizeof(esp_image_segment_header_t), 0, 4); if (field == 5) memset(image + sizeof(header), 0, sizeof(esp_image_segment_header_t)); rejected("400 Bad Request", "invalid_firmware"); assert(!begins && !aborts); } puts("PASS content type, length/capacity, inactive app selection, format/chip/descriptor/hash header rejection before erase"); reset(); s_transitioning = true; rejected("503 Service Unavailable", "busy"); reset(); identity_busy = true; rejected("503 Service Unavailable", "busy"); reset(); mutex_busy = true; rejected("503 Service Unavailable", "busy"); reset(); s_server = NULL; rejected("503 Service Unavailable", "busy"); reset(); malloc_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins); reset(); task_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins); for (int stage = 1; stage <= 5; ++stage) { reset(); stage_fail = stage; rejected(stage == 3 || stage == 4 ? "400 Bad Request" : "500 Internal Server Error", stage == 3 || stage == 4 ? "invalid_firmware" : stage == 5 ? "firmware_commit_failed" : "firmware_write_failed"); assert(aborts == (unsigned)(stage == 2)); } reset(); stage_fail = 6; rejected("500 Internal Server Error", "firmware_write_failed"); assert(begins == 1 && aborts == 1 && !writes && !ends && !ota_live); puts("PASS begin failure after handle publication aborts once; unpublished failure and consumed end never double-abort"); reset(); parsed_size--; rejected("400 Bad Request", "invalid_firmware"); reset(); parsed_size++; rejected("400 Bad Request", "invalid_firmware"); reset(); revoke_at_metadata = true; rejected("401 Unauthorized", "authentication_required"); reset(); final_unavailable = true; rejected("401 Unauthorized", "authentication_required"); puts("PASS busy/resources/OTA begin-write-end-metadata-commit failures, final auth revocation and exact SDK image length"); reset(); cutoff = 10; rejected("400 Bad Request", "firmware_incomplete"); assert(!begins); reset(); cutoff = 300; rejected("400 Bad Request", "firmware_incomplete"); assert(aborts == 1); reset(); stall_at = 0; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins && receives == 10); reset(); stall_at = PREFIX_SIZE; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1); reset(); read_us = STALL_US; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins); reset(); max_chunk = 1; read_us = 40000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1 && now_us == TOTAL_US); puts("PASS incomplete body, stalled receive and total slow-drip deadline; no selection/reboot, handle abort when live"); for (int step = 1; step <= 6; ++step) { reset(); response_fail = step; assert(web_firmware_update_handler(&request) == ESP_FAIL); assert(commits == 1 && !s_transitioning && !identity_token && !allocations_live); assert(notification == 2 && !restarts && !aborts); finish_owner(); assert(!restarts); assert(s_firmware_selected && !atomic_load(&s_reboot_gate)); request.received = 0; response_fail = 0; assert(web_firmware_update_handler(&request) == ESP_FAIL); assert(!strcmp(response_status, "409 Conflict") && strstr(response_body, "firmware_selected_reboot_required")); assert(commits == 1 && begins == 1 && !atomic_load(&s_reboot_gate)); assert(web_firmware_update_reserve_reboot() == ESP_OK); assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE); } puts("PASS postcommit response failure: selected image retained, no automatic reboot, resources/reservations released"); printf("PASS %u firmware backend cases; SDK validation, flash and scheduling are mocked, not device evidence\n", cases); return 0; }