Skip to content

Commit 92ed83b

Browse files
fhirschmannclaude
andcommitted
feat(webdav): enable the server + harden the request path against crashes
Compile WEBDAV_ENABLE back in (SD card mounts as a network drive on http://<ip>:81/) and fix the crash/robustness issues an adversarial review of the single-threaded server surfaced. Empirical fuzzing on the dev unit could not crash the current code, so these are the verified-real latent bugs, hardened defensively (the box has only ~53 KB internal heap, where an OOM reboots): - Chunked request bodies (macOS Finder sends Transfer-Encoding: chunked on PROPFIND/LOCK/PROPPATCH) were never consumed, so client.stop() closed the socket with the body unread -> TCP RST truncated our reply -> Finder retried in a loop. All body-bearing methods now drain a chunked body before responding. Verified: 60 back-to-back chunked PROPFINDs now all return a clean 207 with the heap flat. - Cross-task enable/disable race: Enable/Disable/Exit are called from the web, MQTT, command and shutdown paths. A start racing the previous task's self-teardown (delete webdavServer) could new the server against the concurrent delete -> use-after-free/double-free reboot. The start/stop decision is now atomic behind a portMUX. Verified: 15x rapid enable/disable leaves the server up with the heap flat. - Unbounded header/request-line String growth could exhaust the internal heap (a header with no newline, or a header flood). Request line capped at 2 KB (414), total headers at 8 KB / 1 KB per line (431). - Webdav_Exit only waited 1500 ms, letting the task outlive teardown and dereference a WiFi stack being shut down; the GET/PUT loops now watch webdavShouldRun and bail out fast, and Exit waits up to 9 s for a clean stop. - PUT with a negative Content-Length returned success on a file already truncated to empty (data loss); now rejected with 400. Verified on the dev unit: PROPFIND/OPTIONS/GET still work, and browsing the share (PROPFIND + GET) while an audiobook plays costs +0.7 s drift with no crash. No serial panics across all stress runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8c33f55 commit 92ed83b

2 files changed

Lines changed: 106 additions & 23 deletions

File tree

src/Webdav.cpp

Lines changed: 105 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ static WiFiServer *webdavServer = nullptr;
2424
static TaskHandle_t webdavTaskHandle = nullptr;
2525
static volatile bool webdavShouldRun = false;
2626
static volatile bool webdavRunning = false;
27+
// Enable/Disable/Exit are called from several tasks (web, MQTT, command, system-shutdown). Without
28+
// serialization, a start racing the previous task's self-teardown (delete webdavServer) could spawn
29+
// a second task that `new`s the server against the concurrent delete -> use-after-free / double-free
30+
// reboot. This mutex makes the start/stop decision atomic; the task lifecycle itself stays lock-free.
31+
static portMUX_TYPE webdavStateMux = portMUX_INITIALIZER_UNLOCKED;
2732

2833
String Webdav_User = "esp32"; // default; kept for compatibility but ignored for auth (any username is accepted)
2934
String Webdav_Password = "esp32"; // the shared device password (set on the Security tab)
@@ -193,6 +198,30 @@ static void webdavDrain(WiFiClient &client, long n) {
193198
}
194199
}
195200

201+
// Discard a chunked request body (Transfer-Encoding: chunked, no Content-Length). macOS Finder
202+
// sends chunked bodies on PROPFIND/LOCK/PROPPATCH; without draining them, client.stop() closes
203+
// the socket with the body still unread, which RSTs the connection and can truncate our reply -
204+
// Finder then never sees a valid response and retries in a tight loop. We don't need the decoded
205+
// content, so just read until the terminating 0-length chunk or the peer stops sending.
206+
static void webdavDrainChunked(WiFiClient &client) {
207+
uint8_t tmp[256];
208+
uint32_t idle = millis();
209+
while (client.connected() && (millis() - idle) < 2000) {
210+
int got = client.read(tmp, sizeof(tmp));
211+
if (got > 0) {
212+
idle = millis();
213+
// the last chunk is "0\r\n\r\n"; once we see a standalone 0-size chunk trailer we're done
214+
if (got >= 5 && tmp[0] == '0' && (tmp[1] == '\r' || tmp[1] == '\n')) {
215+
break;
216+
}
217+
continue;
218+
}
219+
if (!client.available()) {
220+
vTaskDelay(pdMS_TO_TICKS(5));
221+
}
222+
}
223+
}
224+
196225
// ---------------------------------------------------------------------------- PROPFIND
197226

198227
// Escape the five XML-significant characters we can actually emit inside element text (a filename can
@@ -361,7 +390,7 @@ static void webdavHandleGet(WiFiClient &client, const String &path, bool headOnl
361390
uint8_t *buf = (uint8_t *) malloc(WEBDAV_BUFFER_SIZE);
362391
if (buf) {
363392
uint32_t remaining = length;
364-
while (remaining > 0 && client.connected()) {
393+
while (remaining > 0 && client.connected() && webdavShouldRun) { // abort a big GET promptly on shutdown
365394
size_t want = (remaining > WEBDAV_BUFFER_SIZE) ? WEBDAV_BUFFER_SIZE : remaining;
366395
int got = f.read(buf, want);
367396
if (got <= 0) {
@@ -391,7 +420,7 @@ static void webdavHandlePut(WiFiClient &client, const String &path, long content
391420
bool ok = (buf != nullptr);
392421
long remaining = contentLength;
393422
uint32_t idleStart = millis();
394-
while (ok && remaining > 0 && client.connected()) {
423+
while (ok && remaining > 0 && client.connected() && webdavShouldRun) { // abort a big PUT promptly on shutdown
395424
int avail = client.available();
396425
if (avail <= 0) {
397426
if (millis() - idleStart > 8000) {
@@ -619,6 +648,12 @@ static void webdavHandleClient(WiFiClient &client) {
619648
return; // parked/empty connection -- caller closes it, freeing us to accept the next at once
620649
}
621650
String reqLine = client.readStringUntil('\n');
651+
// Cap the request line: a client that never sends a newline would otherwise grow this String
652+
// until the (~55 KB) internal heap is exhausted, OOM-crashing the device.
653+
if (reqLine.length() > 2048) {
654+
webdavSendStatus(client, 414, "URI Too Long");
655+
return;
656+
}
622657
reqLine.trim();
623658
if (reqLine.isEmpty()) {
624659
return;
@@ -635,11 +670,18 @@ static void webdavHandleClient(WiFiClient &client) {
635670
long contentLength = 0;
636671
String depth = "infinity";
637672
String destination, authz, overwrite = "T", range, transferEncoding;
673+
size_t headerBytes = 0;
638674
while (client.connected()) {
639675
String line = client.readStringUntil('\n');
640676
if (line == "\r" || line.length() == 0 || line == "\n") {
641677
break;
642678
}
679+
// Bound total header size: a flood of headers (or one endless line) would otherwise
680+
// exhaust the internal heap. 8 KB is far more than any real WebDAV request needs.
681+
if (line.length() > 1024 || (headerBytes += line.length()) > 8192) {
682+
webdavSendStatus(client, 431, "Request Header Fields Too Large");
683+
return;
684+
}
643685
line.trim();
644686
if (line.isEmpty()) {
645687
break;
@@ -673,7 +715,11 @@ static void webdavHandleClient(WiFiClient &client) {
673715
// Authentication (HTTP Basic). Any username is accepted; only the password is checked.
674716
// When no password is configured the drive is open.
675717
if (!webdavCheckAuth(authz)) {
676-
webdavDrain(client, contentLength);
718+
if (transferEncoding.indexOf("hunked") >= 0) {
719+
webdavDrainChunked(client);
720+
} else {
721+
webdavDrain(client, contentLength);
722+
}
677723
client.print("HTTP/1.1 401 Unauthorized\r\n");
678724
client.print("WWW-Authenticate: Basic realm=\"ESPuino WebDAV\"\r\n");
679725
client.print("Connection: close\r\n");
@@ -684,47 +730,68 @@ static void webdavHandleClient(WiFiClient &client) {
684730
String path = webdavUriToPath(rawUri);
685731
bool overwriteFlag = !overwrite.equalsIgnoreCase("F");
686732

733+
// Whether the request carries a chunked body (no Content-Length). We don't decode chunked
734+
// content, but every non-PUT method below still has to *consume* it before closing the socket,
735+
// or the RST-on-close truncates our reply and Finder retries in a loop.
736+
bool bodyIsChunked = false;
737+
{
738+
String te = transferEncoding;
739+
te.toLowerCase();
740+
bodyIsChunked = te.indexOf("chunked") >= 0;
741+
}
742+
// Consume the request body (fixed length or chunked) so client.stop() never RSTs on unread data.
743+
auto consumeBody = [&]() {
744+
if (bodyIsChunked) {
745+
webdavDrainChunked(client);
746+
} else {
747+
webdavDrain(client, contentLength);
748+
}
749+
};
750+
687751
if (method == "OPTIONS") {
688-
webdavDrain(client, contentLength);
752+
consumeBody();
689753
client.print("HTTP/1.1 200 OK\r\n");
690754
client.print("Connection: close\r\n");
691755
client.print("DAV: 1, 2\r\n");
692756
client.print("MS-Author-Via: DAV\r\n");
693757
client.print("Allow: OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK\r\n");
694758
client.print("Content-Length: 0\r\n\r\n");
695759
} else if (method == "PROPFIND") {
696-
webdavDrain(client, contentLength);
760+
consumeBody();
697761
webdavHandlePropfind(client, path, depth);
698762
} else if (method == "GET" || method == "HEAD") {
699-
webdavDrain(client, contentLength);
763+
consumeBody();
700764
webdavHandleGet(client, path, method == "HEAD", range);
701765
} else if (method == "PUT") {
702766
// A chunked body has no Content-Length, so we'd otherwise treat it as a 0-byte PUT and truncate
703-
// the target to empty. We don't decode chunked transfer-encoding, so per RFC 7230 demand a length.
704-
String te = transferEncoding;
705-
te.toLowerCase();
706-
if (te.indexOf("chunked") >= 0) {
767+
// the target to empty. We don't decode chunked transfer-encoding, so per RFC 7230 demand a length
768+
// (draining the body first so the 411 reaches Finder cleanly instead of being RST-truncated).
769+
if (bodyIsChunked) {
770+
webdavDrainChunked(client);
707771
webdavSendStatus(client, 411, "Length Required");
772+
} else if (contentLength < 0) {
773+
// a negative length would skip the copy loop and leave the freshly-truncated target empty
774+
webdavSendStatus(client, 400, "Bad Request");
708775
} else {
709776
webdavHandlePut(client, path, contentLength);
710777
}
711778
} else if (method == "DELETE") {
712-
webdavDrain(client, contentLength);
779+
consumeBody();
713780
webdavHandleDelete(client, path);
714781
} else if (method == "MKCOL") {
715-
webdavDrain(client, contentLength);
782+
consumeBody();
716783
webdavHandleMkcol(client, path);
717784
} else if (method == "MOVE" || method == "COPY") {
718-
webdavDrain(client, contentLength);
785+
consumeBody();
719786
webdavHandleMoveCopy(client, path, destination, method == "MOVE", overwriteFlag);
720787
} else if (method == "LOCK") {
721-
webdavDrain(client, contentLength);
788+
consumeBody();
722789
webdavHandleLock(client, path);
723790
} else if (method == "UNLOCK") {
724-
webdavDrain(client, contentLength);
791+
consumeBody();
725792
webdavSendStatus(client, 204, "No Content");
726793
} else if (method == "PROPPATCH") {
727-
webdavDrain(client, contentLength);
794+
consumeBody();
728795
// We don't persist arbitrary props (e.g. Win32 timestamps); acknowledge so writes complete.
729796
String href = Url_EncodePath((path == "/") ? "" : path);
730797
if (href.isEmpty()) {
@@ -738,7 +805,7 @@ static void webdavHandleClient(WiFiClient &client) {
738805
client.printf("Content-Length: %u\r\n\r\n", (unsigned) body.length());
739806
client.print(body);
740807
} else {
741-
webdavDrain(client, contentLength);
808+
consumeBody();
742809
webdavSendStatus(client, 405, "Method Not Allowed");
743810
}
744811
}
@@ -816,18 +883,29 @@ void Webdav_Cyclic(void) {
816883
}
817884

818885
void Webdav_EnableServer(void) {
819-
if (webdavTaskHandle != nullptr || webdavRunning) {
820-
return; // already running
821-
}
822886
if (!Wlan_IsConnected()) {
823887
Log_Println("WebDAV: cannot start, no WiFi", LOGLEVEL_ERROR);
824888
System_IndicateError();
825889
return;
826890
}
827-
webdavShouldRun = true;
891+
// Claim the start atomically: only proceed if the server is fully stopped (no task, not running,
892+
// and not asked to run). Anything else means a start is already live or the previous instance
893+
// hasn't finished tearing down - either way, don't spawn a second task.
894+
bool claimed = false;
895+
portENTER_CRITICAL(&webdavStateMux);
896+
if (webdavTaskHandle == nullptr && !webdavRunning && !webdavShouldRun) {
897+
webdavShouldRun = true;
898+
claimed = true;
899+
}
900+
portEXIT_CRITICAL(&webdavStateMux);
901+
if (!claimed) {
902+
return; // already running or mid-transition
903+
}
828904
if (xTaskCreatePinnedToCore(webdavTask, "webdav", 8192, nullptr, 1, &webdavTaskHandle, 0) != pdPASS) {
905+
portENTER_CRITICAL(&webdavStateMux);
829906
webdavShouldRun = false;
830907
webdavTaskHandle = nullptr;
908+
portEXIT_CRITICAL(&webdavStateMux);
831909
Log_Println("WebDAV: failed to create task", LOGLEVEL_ERROR);
832910
System_IndicateError();
833911
return;
@@ -844,9 +922,14 @@ void Webdav_DisableServer(void) {
844922
}
845923

846924
void Webdav_Exit(void) {
925+
// Signal the task to stop; the GET/PUT transfer loops watch webdavShouldRun and bail out fast,
926+
// so an in-flight transfer no longer runs its full 8 s timeout before the task tears down. Wait
927+
// long enough (9 s) that the task always finishes deleting webdavServer and clearing its state
928+
// before we return - a shorter cap let the task outlive teardown and dereference a WiFi stack
929+
// that was already being shut down.
847930
webdavShouldRun = false;
848931
uint32_t start = millis();
849-
while (webdavRunning && (millis() - start < 1500)) {
932+
while (webdavRunning && (millis() - start < 9000)) {
850933
vTaskDelay(pdMS_TO_TICKS(20));
851934
}
852935
}

src/settings.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
#define MDNS_ENABLE // When enabled, you don't have to handle with ESPuino's IP-address. If hostname is set to "ESPuino", you can reach it via ESPuino.local
3939
#define MQTT_ENABLE // Make sure to configure mqtt-server and (optionally) username+pwd
4040
#define FTP_ENABLE // Enables FTP-server; DON'T FORGET TO ACTIVATE AFTER BOOT BY PRESSING PAUSE + NEXT-BUTTONS (IN PARALLEL)!
41-
//#define WEBDAV_ENABLE // WebDAV-server (mount the SD card as a network drive on http://<ip>:81/). Disabled: macOS Finder doesn't browse it reliably with the current single-threaded server (needs proper HTTP keep-alive / concurrent connections).
41+
#define WEBDAV_ENABLE // WebDAV-server (mount the SD card as a network drive on http://<ip>:81/)
4242
#define HOMEKIT_ENABLE // Apple HomeKit (control + Siri + Television) via HomeSpan. Pairs over the existing WiFi; poll task pinned to core 0
4343
#define NEOPIXEL_ENABLE // Don't forget configuration of NUM_LEDS if enabled
4444
//#define NEOPIXEL_REVERSE_ROTATION // Some Neopixels are adressed/soldered counter-clockwise. This can be configured here.

0 commit comments

Comments
 (0)