diff --git a/examples/arduino/MultiPart/MultiPart.ino b/examples/arduino/MultiPart/MultiPart.ino new file mode 100644 index 00000000..4ad3eed2 --- /dev/null +++ b/examples/arduino/MultiPart/MultiPart.ino @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright 2016-2026 Hristo Gochkov, Mathieu Carbou, Emil Muratov, Will Miles + +/* + Demo to test multi-part upload handling and boundary parsing in AsyncWebServer. + + Covers boundary-parsing cases introduced in the CWE-190/DoS hardening commit: + - Token boundary (standard) + - Quoted-string boundary (RFC 2046 §5.1) + - Quoted-string with a quoted-pair escape sequence + - Leading OWS (whitespace) after "boundary=" + - boundary= nested inside another quoted parameter value (must be ignored) + - "x-boundary=" prefix must NOT be matched + - Boundary longer than 70 chars → rejected (400) + - Empty boundary → rejected (400) + - Unterminated quoted-string → rejected (400) + + ── /upload endpoint (all platforms) ────────────────────────────────────────── + + 1. Standard token boundary (curl -F generates this automatically): + +curl -v -F "field=hello" -F "file=@README.md" http://192.168.4.1/upload + + 2. Quoted-string boundary: + +curl -v \ + -H 'Content-Type: multipart/form-data; boundary="my-boundary"' \ + --data-binary $'--my-boundary\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--my-boundary--\r\n' \ + http://192.168.4.1/upload + + 3. Quoted-string with a quoted-pair escape (\" inside the boundary value): + +curl -v \ + -H 'Content-Type: multipart/form-data; boundary="my-\"bnd\""' \ + --data-binary $'--my-"bnd"\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--my-"bnd"--\r\n' \ + http://192.168.4.1/upload + + 4. Leading whitespace after boundary= (non-RFC but tolerated): + +curl -v \ + -H 'Content-Type: multipart/form-data; boundary= simple' \ + --data-binary $'--simple\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--simple--\r\n' \ + http://192.168.4.1/upload + + 5. boundary= embedded in another quoted parameter value — must be ignored, real boundary is "real": + +curl -v \ + -H 'Content-Type: multipart/form-data; foo="x; boundary=fake"; boundary=real' \ + --data-binary $'--real\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--real--\r\n' \ + http://192.168.4.1/upload + + 6. "x-boundary=" prefix must NOT match — request should be aborted: + +curl -v \ + -H 'Content-Type: multipart/form-data; x-boundary=notreal' \ + --data-binary $'--notreal\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--notreal--\r\n' \ + http://192.168.4.1/upload + + 7. Boundary longer than 70 chars → abort: + +curl -v \ + -H 'Content-Type: multipart/form-data; boundary=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX' \ + --data-binary $'--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX--\r\n' \ + http://192.168.4.1/upload + + 8. Empty boundary → abort: + +curl -v \ + -H 'Content-Type: multipart/form-data; boundary=' \ + --data-binary $'--\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n----\r\n' \ + http://192.168.4.1/upload + + 9. Unterminated quoted-string → abort: + +curl -v \ + -H 'Content-Type: multipart/form-data; boundary="unterminated' \ + --data-binary $'--unterminated\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--unterminated--\r\n' \ + http://192.168.4.1/upload + + ── /flash endpoint (ESP32 only) ────────────────────────────────────────────── + + Flash firmware and filesystem in one request: + 1. Build firmware: pio run -e arduino-3 + 2. Build FS image: pio run -e arduino-3 -t buildfs + 3. Flash both: + +curl -v -F "name=Bob" -F "fw=@.pio/build/arduino-3/firmware.bin" -F "fs=@.pio/build/arduino-3/littlefs.bin" http://192.168.4.1/flash?name=Bill + +*/ + +#include +#if defined(ESP32) || defined(LIBRETINY) +#include +#include +#elif defined(ESP8266) +#include +#include +#elif defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) +#include +#include +#endif + +#include +#include +#include + +// ESP32 example ONLY +#ifdef ESP32 +#include +#endif + +static AsyncWebServer server(80); + +void setup() { + Serial.begin(115200); + + if (!LittleFS.begin()) { + LittleFS.format(); + LittleFS.begin(); + } + +#if ASYNCWEBSERVER_WIFI_SUPPORTED + WiFi.mode(WIFI_AP); + WiFi.softAP("esp-captive"); +#endif + + // ── /upload — all platforms ─────────────────────────────────────────────── + // + // Generic multipart endpoint used to exercise all boundary-parsing cases. + // Responds 200 with a summary of every received parameter, or 400 if the + // request is rejected by the parser (boundary too long, empty, malformed…). + // + server.on( + "/upload", HTTP_POST, + [](AsyncWebServerRequest *request) { + if (request->getResponse()) { + // A 400 was already sent by the upload handler — do not overwrite it. + return; + } + + StreamString body; + const size_t params = request->params(); + body.printf("Received %u parameter(s):\n", params); + for (size_t i = 0; i < params; i++) { + const AsyncWebParameter *p = request->getParam(i); + body.printf("[%u] %s=%s (post=%d file=%d size=%u)\n", i, p->name().c_str(), p->value().c_str(), p->isPost(), p->isFile(), p->size()); + } + + Serial.print(body); + request->send(200, "text/plain", body); + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + if (request->getResponse()) { + // Upload already aborted. + return; + } + if (!index) { + Serial.printf("Upload start: %s\n", filename.isEmpty() ? "(field)" : filename.c_str()); + } + if (final) { + Serial.printf("Upload end: %s (%u bytes)\n", filename.isEmpty() ? "(field)" : filename.c_str(), index + len); + } + } + ); + +// ── /flash — ESP32 only ─────────────────────────────────────────────────── +#ifdef ESP32 + + // Shows how to flash firmware and filesystem images from a multipart upload + // and how to handle multiple files and parameters in a single request. + server.on( + "/flash", HTTP_POST, + [](AsyncWebServerRequest *request) { + if (request->getResponse()) { + // response already created + return; + } + + // list all parameters + Serial.println("Request parameters:"); + const size_t params = request->params(); + for (size_t i = 0; i < params; i++) { + const AsyncWebParameter *p = request->getParam(i); + Serial.printf("Param[%u]: %s=%s, isPost=%d, isFile=%d, size=%u\n", i, p->name().c_str(), p->value().c_str(), p->isPost(), p->isFile(), p->size()); + } + + Serial.println("Flash / Filesystem upload completed"); + + request->send(200, "text/plain", "Upload complete"); + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + Serial.printf("Upload[%s]: index=%u, len=%u, final=%d\n", filename.c_str(), index, len, final); + + if (request->getResponse() != nullptr) { + // upload aborted + return; + } + + // start a new content-disposition upload + if (!index) { + // list all parameters + const size_t params = request->params(); + for (size_t i = 0; i < params; i++) { + const AsyncWebParameter *p = request->getParam(i); + Serial.printf("Param[%u]: %s=%s, isPost=%d, isFile=%d, size=%u\n", i, p->name().c_str(), p->value().c_str(), p->isPost(), p->isFile(), p->size()); + } + + // get the content-disposition parameter + const AsyncWebParameter *p = request->getParam(asyncsrv::T_name, true, true); + if (p == nullptr) { + request->send(400, "text/plain", "Missing content-disposition 'name' parameter"); + return; + } + + // determine upload type based on the parameter name + if (p->value() == "fs") { + Serial.printf("Filesystem image upload for file: %s\n", filename.c_str()); + if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { + Update.printError(Serial); + request->send(400, "text/plain", "Update begin failed"); + return; + } + + } else if (p->value() == "fw") { + Serial.printf("Firmware image upload for file: %s\n", filename.c_str()); + if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) { + Update.printError(Serial); + request->send(400, "text/plain", "Update begin failed"); + return; + } + + } else { + Serial.printf("Unknown upload type for file: %s\n", filename.c_str()); + request->send(400, "text/plain", "Unknown upload type"); + return; + } + } + + // some bytes to write ? + if (len) { + if (Update.write(data, len) != len) { + Update.printError(Serial); + Update.end(); + request->send(400, "text/plain", "Update write failed"); + return; + } + } + + // finish the content-disposition upload + if (final) { + if (!Update.end(true)) { + Update.printError(Serial); + request->send(400, "text/plain", "Update end failed"); + return; + } + + // success response is created in the final request handler when all uploads are completed + Serial.printf("Upload success of file %s\n", filename.c_str()); + } + } + ); + +#endif + + server.begin(); +} + +// not needed +void loop() { + delay(100); +} diff --git a/platformio.ini b/platformio.ini index 0cf1c736..3810f6cb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -22,6 +22,7 @@ lib_dir = . ; src_dir = examples/arduino/Logging ; src_dir = examples/arduino/MessagePack ; src_dir = examples/arduino/Middleware +; src_dir = examples/arduino/MultiPart ; src_dir = examples/arduino/Params ; src_dir = examples/arduino/PartitionDownloader src_dir = examples/arduino/PerfTests diff --git a/src/WebRequest.cpp b/src/WebRequest.cpp index 2d8d588c..31b9145a 100644 --- a/src/WebRequest.cpp +++ b/src/WebRequest.cpp @@ -490,10 +490,17 @@ bool AsyncWebServerRequest::_parseReqHeader() { // optional OWS) so that e.g. 'x-boundary=' is not matched. int bpos = -1; bool inQuotes = false; - for (int i = 0; i < (int)lowcase.length(); i++) { - char c = lowcase.charAt(i); + const char *lc = lowcase.c_str(); + const int llen = (int)lowcase.length(); + // Stop early: a ';' followed by OWS and 'boundary=' needs at least + // T_BOUNDARY_LEN+1 chars after it. Without OWS the minimum is + // i+1+T_BOUNDARY_LEN+1 <= llen, i.e. i < llen-(T_BOUNDARY_LEN+1). + // The inner OWS-aware check below still guards the OWS case. + const int lscan = llen - (int)(T_BOUNDARY_LEN + 1); + for (int i = 0; i < lscan; i++) { + char c = lc[i]; if (inQuotes) { - if (c == '\\' && i + 1 < (int)lowcase.length()) { + if (c == '\\' && i + 1 < llen) { i++; // skip quoted-pair — the escaped character cannot terminate the quoted-string } else if (c == '"') { inQuotes = false; @@ -504,13 +511,17 @@ bool AsyncWebServerRequest::_parseReqHeader() { } else if (c == ';') { // Skip OWS after the ';' and check for 'boundary=' int j = i + 1; - while (j < (int)lowcase.length() && (lowcase.charAt(j) == ' ' || lowcase.charAt(j) == '\t')) { + while (j < llen && (lc[j] == ' ' || lc[j] == '\t')) { j++; } - // Use strncmp on the raw C string to avoid a heap allocation - // from String::substring() — this code runs on attacker-controlled - // input in a scan loop, so zero-allocation is preferred. - if ((size_t)j + T_BOUNDARY_LEN <= lowcase.length() && std::strncmp(lowcase.c_str() + j, T_BOUNDARY, T_BOUNDARY_LEN) == 0) { + // If there is not enough room left for "boundary=" plus at least + // one value byte, no later ';' can match either — stop scanning. + if (j + (int)T_BOUNDARY_LEN + 1 > llen) { + break; + } + // strncmp stops at the null terminator, so no separate length + // guard is needed: a short suffix causes a non-matching result. + if (std::strncmp(lc + j, T_BOUNDARY, T_BOUNDARY_LEN) == 0) { bpos = j; break; } @@ -526,64 +537,92 @@ bool AsyncWebServerRequest::_parseReqHeader() { return true; } - // Extract the boundary value that follows "boundary=" and strip leading/ - // trailing whitespace. The value may be either a token or a - // quoted-string (RFC 2045 §5.1 / RFC 2046 §5.1). - _boundary = value.substring(bpos + (int)T_BOUNDARY_LEN); - _boundary.trim(); - - if (_boundary.startsWith("\"")) { - // Quoted-string form: scan forward from position 1 for the closing - // double-quote. A quote is escaped only when preceded by an ODD - // number of consecutive backslashes (e.g. \" is escaped, \\" is not - // because the two backslashes escape each other, leaving the quote - // unescaped). Checking only the immediately preceding character - // would mishandle the \\" case. - int endQuote = 1; - while (true) { - endQuote = _boundary.indexOf('"', endQuote); - if (endQuote < 0) { - break; // string ran out — unterminated quote - } - // Count consecutive backslashes immediately before this quote. - int backslashes = 0; - while (endQuote - 1 - backslashes >= 0 && _boundary.charAt(endQuote - 1 - backslashes) == '\\') { - backslashes++; + // Use a raw pointer + length pair into the original (mixed-case) header + // value to extract the boundary without any intermediate heap allocation. + // Avoids std::string_view which requires C++17 (unavailable on LibreTiny). + // The value may be either a token or a quoted-string (RFC 2046 §5.1). + const char *bvdata = value.c_str() + bpos + T_BOUNDARY_LEN; + size_t bvlen = value.length() - bpos - T_BOUNDARY_LEN; + + // Strip leading OWS (space/tab) after 'boundary=' to preserve prior + // trim() behavior and handle non-RFC but common whitespace variants. + while (bvlen > 0 && (bvdata[0] == ' ' || bvdata[0] == '\t')) { + bvdata++; + bvlen--; + } + + // Clear any previous value — duplicate Content-Type headers must not + // concatenate into the boundary string. + _boundary = String(); + + if (bvlen > 0 && bvdata[0] == '"') { + // Quoted-string form: scan once from the opening '"', unescaping + // quoted-pairs on the fly and writing into _boundary directly. + bvdata++; // skip opening '"' + bvlen--; + // Reserve at most 70 chars — the RFC 2046 §5.1 maximum — rather than + // the full (attacker-controlled) remaining suffix length. + if (!_boundary.reserve(70)) { + async_ws_log_e("Failed to allocate"); + _parseState = PARSE_REQ_FAIL; + abort(); + return true; + } + bool closed = false; + for (size_t i = 0; i < bvlen; ++i) { + char c = bvdata[i]; + if (c == '\\' && i + 1 < bvlen) { + _boundary += bvdata[++i]; // quoted-pair: keep only the escaped char + } else if (c == '"') { + closed = true; + break; + } else { + _boundary += c; } - if (backslashes % 2 == 0) { - break; // even number of backslashes → quote is real closing quote + if (_boundary.length() > 70) { + async_ws_log_d("Invalid multipart boundary length (%u), aborting", _boundary.length()); + _parseState = PARSE_REQ_FAIL; + abort(); + return true; } - endQuote++; // odd number → quote is escaped, keep scanning } - if (endQuote < 0) { + if (!closed) { // Opening quote was never closed — malformed header. async_ws_log_d("Invalid multipart boundary (unterminated quote), aborting"); _parseState = PARSE_REQ_FAIL; abort(); return true; } - // Strip the surrounding quotes; content between them is the boundary. - _boundary = _boundary.substring(1, endQuote); - - // Unescape quoted-pair sequences so the boundary matches the actual bytes on the wire. - String unescaped; - unescaped.reserve(_boundary.length()); - for (size_t i = 0; i < _boundary.length(); ++i) { - char c = _boundary.charAt(i); - if (c == '\\' && (i + 1) < _boundary.length()) { - c = _boundary.charAt(++i); + } else { + // Token form: value ends at the next ';' or end of string. + // Trim trailing OWS — no copy until the final assign. + for (size_t k = 0; k < bvlen; ++k) { + if (bvdata[k] == ';') { + bvlen = k; + break; } - unescaped += c; } - _boundary = unescaped; - } else { - // Token form: the value ends at the next ';' (start of the next - // parameter) or at the end of the header field. - int semi = _boundary.indexOf(';'); - if (semi >= 0) { - _boundary = _boundary.substring(0, semi); + while (bvlen > 0 && (bvdata[bvlen - 1] == ' ' || bvdata[bvlen - 1] == '\t')) { + bvlen--; } - _boundary.trim(); + if (bvlen == 0 || bvlen > 70) { + async_ws_log_d("Invalid multipart boundary length (%u), aborting", (unsigned)bvlen); + _parseState = PARSE_REQ_FAIL; + abort(); + return true; + } +#ifdef ESP8266 + { + // ESP8266 Arduino String lacks String(const char*, size_t). + // bvlen <= 70 is guaranteed above, so a stack buffer is safe. + char buf[71]; + memcpy(buf, bvdata, bvlen); + buf[bvlen] = '\0'; + _boundary = String(buf); + } +#else + _boundary = String(bvdata, (unsigned int)bvlen); +#endif } // CWE-190 / DoS fix: RFC 2046 §5.1 limits boundary strings to 70