/* SPDX-License-Identifier: GPL-3.0-only */ #include "web_firmware_update.h" #include #include #include #include "esp_app_desc.h" #include "esp_app_format.h" #include "esp_image_format.h" #include "esp_ota_ops.h" #include "esp_system.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "secure_random.h" #include "web_cookie_auth.h" #include "web_httpd_adapter.h" #include "web_security.h" #define BUFFER_SIZE 4096U #define STALL_US 10000000LL #define TOTAL_US 120000000LL #define PREFIX_SIZE (sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t)) /* A successful reservation is retained through reset, not a check-then-reset. * Only the upload owner accesses the selected latch while holding this gate. */ static atomic_bool s_reboot_gate; static bool s_firmware_selected; esp_err_t web_firmware_update_reserve_reboot(void) { bool expected = false; return atomic_compare_exchange_strong(&s_reboot_gate, &expected, true) ? ESP_OK : ESP_ERR_INVALID_STATE; } /* Allocated before boot selection. No HTTPD stop, queue allocation, captured * request or socket in this owner. A successful send is not proof of receipt. */ static void reboot_owner(void *argument) { (void)argument; uint32_t decision = 0; xTaskNotifyWait(0, UINT32_MAX, &decision, portMAX_DELAY); if (decision == 1) { vTaskDelay(pdMS_TO_TICKS(500)); esp_restart(); } vTaskDelete(NULL); } static esp_err_t respond(httpd_req_t *request, const char *status, const char *body) { esp_err_t error = httpd_resp_set_status(request, status); if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8"); if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store"); if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); if (error == ESP_OK) error = httpd_resp_sendstr(request, body); /* Reject without HTTPD's unbounded discard of the remaining request body. */ return web_httpd_unread_body(request) ? ESP_FAIL : error; } static esp_err_t content_length(httpd_req_t *request, size_t limit) { /* Auth's adapter already rejects duplicate headers. IDF 5.5.0 parses a * uint64_t length then narrows it to size_t; never trust that value alone. * Its getters strip leading spaces only. Require 1..20 decimal digits. */ char raw[21]; size_t length = httpd_req_get_hdr_value_len(request, "Content-Length"); if (!length || length >= sizeof(raw) || httpd_req_get_hdr_value_str(request, "Content-Length", raw, sizeof(raw)) != ESP_OK || strspn(raw, "0123456789") != length) return ESP_ERR_INVALID_ARG; size_t value = 0; for (size_t i = 0; i < length; ++i) { unsigned digit = (unsigned)(raw[i] - '0'); if (value > limit / 10 || (value == limit / 10 && digit > limit % 10)) return ESP_ERR_INVALID_SIZE; value = value * 10 + digit; } return value == request->content_len ? ESP_OK : ESP_ERR_INVALID_ARG; } static bool application_prefix(const uint8_t *buffer) { esp_image_header_t header; esp_image_segment_header_t segment; uint32_t magic; memcpy(&header, buffer, sizeof(header)); memcpy(&segment, buffer + sizeof(header), sizeof(segment)); memcpy(&magic, buffer + sizeof(header) + sizeof(segment), sizeof(magic)); /* App descriptor distinguishes app images from bootloader/full-flash data. * SDK end validation remains authoritative for revision, segments and hash. */ return header.magic == ESP_IMAGE_HEADER_MAGIC && header.chip_id == ESP_CHIP_ID_ESP32S3 && header.segment_count > 0 && header.segment_count <= ESP_IMAGE_MAX_SEGMENTS && header.hash_appended == 1 && segment.data_len >= sizeof(esp_app_desc_t) && magic == ESP_APP_DESC_MAGIC_WORD; } esp_err_t web_firmware_update_handler(httpd_req_t *request) { web_session_view_t view = {0}; bool allowed = false, reserved = false, active = false, gate_owned = false; uint32_t identity_token = 0; uint8_t *buffer = NULL; TaskHandle_t reboot_task = NULL; esp_ota_handle_t ota = 0; const char *status = "500 Internal Server Error"; const char *body = "{\"error\":\"firmware_write_failed\"}"; /* Auth rejects ambiguous headers/transfer encoding before any body IO. * The actual inactive partition, not a compiled slot size, bounds the body. */ esp_err_t error = web_cookie_auth_require_body(request, SIZE_MAX, &view, &allowed); if (error != ESP_OK || !allowed) goto done; #define REJECT(s, b) do { status = (s); body = "{\"error\":\"" b "\"}"; goto failed; } while (0) if (view.principal.role != USER_ROLE_ADMIN) REJECT("403 Forbidden", "admin_required"); char type[40] = {0}; if (httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) != ESP_OK || strcmp(type, "application/octet-stream")) REJECT("415 Unsupported Media Type", "firmware_content_type"); const esp_partition_t *running = esp_ota_get_running_partition(); const esp_partition_t *target = esp_ota_get_next_update_partition(NULL); if (!running || !target || target->type != ESP_PARTITION_TYPE_APP || target->subtype < ESP_PARTITION_SUBTYPE_APP_OTA_0 || target->subtype > ESP_PARTITION_SUBTYPE_APP_OTA_15 || target->address == running->address) REJECT("503 Service Unavailable", "firmware_unavailable"); esp_err_t length_error = content_length(request, target->size); if (length_error == ESP_ERR_INVALID_SIZE) REJECT("413 Payload Too Large", "firmware_too_large"); if (length_error != ESP_OK) REJECT("400 Bad Request", "invalid_request"); if (request->content_len < PREFIX_SIZE) REJECT("400 Bad Request", "invalid_firmware"); if (web_firmware_update_reserve_reboot() != ESP_OK) REJECT("503 Service Unavailable", "busy"); gate_owned = true; if (s_firmware_selected) REJECT("409 Conflict", "firmware_selected_reboot_required"); if (web_firmware_update_reserve(request->handle) != ESP_OK) REJECT("503 Service Unavailable", "busy"); reserved = true; /* Also exclude direct canonical identity mutations which bypass server. */ if (web_security_reserve_identity(0, false, &identity_token) != ESP_OK) REJECT("503 Service Unavailable", "busy"); buffer = malloc(BUFFER_SIZE); if (!buffer || xTaskCreate(reboot_owner, "fw_reboot", 2048, NULL, 5, &reboot_task) != pdPASS) REJECT("503 Service Unavailable", "firmware_resources"); size_t received = 0, prefix = 0; int64_t started = esp_timer_get_time(), last_progress = started; while (received < request->content_len) { int64_t now = esp_timer_get_time(); if (now - started >= TOTAL_US || now - last_progress >= STALL_US) REJECT("408 Request Timeout", "firmware_timeout"); /* Accumulate the entire prefix, even when TLS gives one byte at a time. */ size_t want = active ? request->content_len - received : PREFIX_SIZE - prefix; if (want > BUFFER_SIZE) want = BUFFER_SIZE; int count = httpd_req_recv(request, (char *)buffer + (active ? 0 : prefix), want); now = esp_timer_get_time(); if (now - started >= TOTAL_US || now - last_progress >= STALL_US) REJECT("408 Request Timeout", "firmware_timeout"); if (count == HTTPD_SOCK_ERR_TIMEOUT) continue; if (count <= 0 || (size_t)count > want) REJECT("400 Bad Request", "firmware_incomplete"); received += (size_t)count; last_progress = now; size_t write_size = (size_t)count; if (!active) { prefix += (size_t)count; if (prefix < PREFIX_SIZE) continue; if (!application_prefix(buffer)) REJECT("400 Bad Request", "invalid_firmware"); esp_err_t begin_error = esp_ota_begin(target, request->content_len, &ota); /* IDF 5.5.0 can publish a live handle before an erase failure. */ active = ota != 0; if (begin_error != ESP_OK) goto failed; write_size = prefix; } if (esp_ota_write(ota, buffer, write_size) != ESP_OK) goto failed; } if (esp_timer_get_time() - started >= TOTAL_US) REJECT("408 Request Timeout", "firmware_timeout"); /* esp_ota_end consumes the handle even on validation failure. */ active = false; if (esp_ota_end(ota) != ESP_OK) REJECT("400 Bad Request", "invalid_firmware"); /* SDK end validates flash, but does not compare its parsed image length to * our HTTP length. Reject truncation into old flash and appended garbage. */ _Static_assert(sizeof(esp_image_metadata_t) <= BUFFER_SIZE, "metadata fits upload buffer"); esp_image_metadata_t *metadata = (esp_image_metadata_t *)buffer; esp_partition_pos_t position = {.offset = target->address, .size = target->size}; if (esp_image_get_metadata(&position, metadata) != ESP_OK || metadata->image_len != received) REJECT("400 Bad Request", "invalid_firmware"); bool current = false; if (web_session_store_check_principal(view.id, &view.principal, ¤t) != ESP_OK || !current) REJECT("401 Unauthorized", "authentication_required"); if (esp_ota_set_boot_partition(target) != ESP_OK) REJECT("500 Internal Server Error", "firmware_commit_failed"); s_firmware_selected = true; /* Commit is irreversible here. Failed send leaves the selected image for a * later reboot, but deliberately does not schedule this upload's reboot. * Never retry an upload automatically after a lost acknowledgement. */ error = respond(request, "200 OK", "{\"ok\":true,\"rebooting\":true}"); if (error == ESP_OK) { xTaskNotify(reboot_task, 1, eSetValueWithOverwrite); reboot_task = NULL; reserved = false; /* Retain service/identity reservations until reset. */ identity_token = 0; gate_owned = false; /* Reboot owner retains exclusion until reset. */ } goto done; failed: if (active) { esp_ota_abort(ota); active = false; } error = respond(request, status, body); done: if (reboot_task) xTaskNotify(reboot_task, 2, eSetValueWithOverwrite); if (identity_token) web_security_release_identity(identity_token); if (reserved) web_firmware_update_release(); free(buffer); secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(request)); if (gate_owned) atomic_store(&s_reboot_gate, false); return error; #undef REJECT }