Skip to content

Commit 6d590d7

Browse files
Fix CWE-190: integer overflow in multipart boundary parser (DoS)
_boundaryPosition was declared as uint8_t. A remote attacker could send a Content-Type header with a boundary string of exactly 256 bytes, causing the increment in _parseMultipartPostByte() to overflow back to 0. The parser would then loop indefinitely in the BOUNDARY_OR_DATA state, consuming 100% CPU and triggering the FreeRTOS watchdog on ESP32/ESP8266. Fixes: - ESPAsyncWebServer.h: change _boundaryPosition from uint8_t to size_t, eliminating the overflow entirely. - WebRequest.cpp (_parseMultipartPostByte): change the rewind loop variable from uint8_t to size_t for consistency. - WebRequest.cpp (_parseReqHeader): validate the boundary value extracted from the Content-Type header before any body parsing begins: * Require the 'boundary=' token to be a proper RFC 2045 parameter (immediately preceded by ';'), preventing substring false-matches. * Strip optional surrounding whitespace, quotes and trailing parameters (RFC 2046 quoted-string form). * Reject boundaries that are empty or longer than 70 characters (RFC 2046 §5.1 hard limit); drop the connection immediately with PARSE_REQ_FAIL + abort() instead of continuing. Fix CWE-190: properly parse RFC 2046 quoted-string boundary values The previous code stripped all double-quote characters from the boundary string using a blanket replace(), which would silently corrupt boundary values that contain escaped quotes (\") and could leave a trailing semi-colon if the quoted string was followed by another parameter. Replace with a proper RFC 2045/2046 quoted-string parser: - Token form (no leading quote): trim and stop at the next ';'. - Quoted-string form (leading '"'): scan forward for the closing quote, skipping backslash-escaped quotes (\") along the way; extract the content between the outer quotes. Reject unterminated quoted strings (missing closing quote) immediately with PARSE_REQ_FAIL + abort(). This was reviewed and proposed by GitHub Copilot Autofix. Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent a87fcdb commit 6d590d7

3 files changed

Lines changed: 148 additions & 10 deletions

File tree

src/ESPAsyncWebServer.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,9 @@ class AsyncWebServerRequest {
465465
std::unordered_map<const char *, String, std::hash<const char *>, std::equal_to<const char *>> _attributes;
466466

467467
uint8_t _multiParseState;
468-
uint8_t _boundaryPosition;
468+
// CWE-190 fix: was uint8_t, which would overflow to 0 for boundaries >= 256
469+
// bytes and cause the BOUNDARY_OR_DATA parser loop to never terminate (DoS).
470+
size_t _boundaryPosition;
469471
size_t _itemStartIndex;
470472
size_t _itemSize;
471473
String _itemName;

src/WebRequest.cpp

Lines changed: 143 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -473,9 +473,139 @@ bool AsyncWebServerRequest::_parseReqHeader() {
473473
_host = value;
474474
} else if (name.equalsIgnoreCase(T_Content_Type)) {
475475
_contentType = value.substring(0, value.indexOf(';'));
476-
if (value.startsWith(T_MULTIPART_)) {
477-
_boundary = value.substring(value.indexOf('=') + 1);
478-
_boundary.replace(String('"'), String());
476+
// AsyncWebHeader::parse skips only a single space after the colon, so
477+
// extra leading whitespace (e.g. two spaces or a tab) would survive into
478+
// value. Trim _contentType so callers always receive a clean value.
479+
_contentType.trim();
480+
// Media types are case-insensitive (RFC 2045 §5.1), so lowercase the
481+
// entire header value once and reuse it for all subsequent searches.
482+
// Header values are stored as-is from the raw request; they are never
483+
// normalized to lowercase by the header parser.
484+
String lowcase(value);
485+
lowcase.toLowerCase();
486+
// Strip any leading whitespace from the lowercased copy so that
487+
// startsWith(T_MULTIPART_) works even when AsyncWebHeader::parse left
488+
// more than one space after the colon. Track the offset so that
489+
// absolute positions found in lowcase can be mapped back into value
490+
// (which retains its original case for case-sensitive boundary extraction).
491+
int valueOffset = 0;
492+
while (valueOffset < (int)lowcase.length() && (lowcase.charAt(valueOffset) == ' ' || lowcase.charAt(valueOffset) == '\t')) {
493+
valueOffset++;
494+
}
495+
if (valueOffset > 0) {
496+
lowcase = lowcase.substring(valueOffset);
497+
}
498+
if (lowcase.startsWith(T_MULTIPART_)) {
499+
// Case-insensitive search for the boundary parameter.
500+
// A simple indexOf("boundary=") would incorrectly match a token whose
501+
// name merely ends in "boundary=" (e.g. "x-boundary="). We require
502+
// the match to be immediately preceded by a ';' (with optional
503+
// whitespace) to ensure it is a proper RFC 2045 parameter.
504+
505+
int bpos = -1;
506+
int searchFrom = 0;
507+
while (true) {
508+
int pos = lowcase.indexOf(T_BOUNDARY, searchFrom);
509+
if (pos < 0) {
510+
break;
511+
}
512+
513+
// Only match a real parameter (must be preceded by ';' with optional whitespace)
514+
int p = pos - 1;
515+
while (p >= 0 && (lowcase.charAt(p) == ' ' || lowcase.charAt(p) == '\t')) {
516+
p--;
517+
}
518+
if (p >= 0 && lowcase.charAt(p) == ';') {
519+
bpos = pos;
520+
break;
521+
}
522+
523+
searchFrom = pos + 1;
524+
}
525+
526+
if (bpos < 0) {
527+
// No valid boundary parameter found — cannot parse multipart body.
528+
async_ws_log_d("Missing multipart boundary parameter, aborting");
529+
_parseState = PARSE_REQ_FAIL;
530+
abort();
531+
return true;
532+
}
533+
534+
// Extract the boundary value that follows "boundary=" and strip leading/
535+
// trailing whitespace. The value may be either a token or a
536+
// quoted-string (RFC 2045 §5.1 / RFC 2046 §5.1).
537+
// bpos is relative to the trimmed lowcase, so add valueOffset to map
538+
// back to the original (case-preserving) value string.
539+
_boundary = value.substring(valueOffset + bpos + (int)T_BOUNDARY_LEN);
540+
_boundary.trim();
541+
542+
if (_boundary.startsWith("\"")) {
543+
// Quoted-string form: scan forward from position 1 for the closing
544+
// double-quote. A quote is escaped only when preceded by an ODD
545+
// number of consecutive backslashes (e.g. \" is escaped, \\" is not
546+
// because the two backslashes escape each other, leaving the quote
547+
// unescaped). Checking only the immediately preceding character
548+
// would mishandle the \\" case.
549+
int endQuote = 1;
550+
while (true) {
551+
endQuote = _boundary.indexOf('"', endQuote);
552+
if (endQuote < 0) {
553+
break; // string ran out — unterminated quote
554+
}
555+
// Count consecutive backslashes immediately before this quote.
556+
int backslashes = 0;
557+
while (endQuote - 1 - backslashes >= 0 && _boundary.charAt(endQuote - 1 - backslashes) == '\\') {
558+
backslashes++;
559+
}
560+
if (backslashes % 2 == 0) {
561+
break; // even number of backslashes → quote is real closing quote
562+
}
563+
endQuote++; // odd number → quote is escaped, keep scanning
564+
}
565+
if (endQuote < 0) {
566+
// Opening quote was never closed — malformed header.
567+
async_ws_log_d("Invalid multipart boundary (unterminated quote), aborting");
568+
_parseState = PARSE_REQ_FAIL;
569+
abort();
570+
return true;
571+
}
572+
// Strip the surrounding quotes; content between them is the boundary.
573+
_boundary = _boundary.substring(1, endQuote);
574+
575+
// Unescape quoted-pair sequences so the boundary matches the actual bytes on the wire.
576+
String unescaped;
577+
unescaped.reserve(_boundary.length());
578+
for (size_t i = 0; i < _boundary.length(); ++i) {
579+
char c = _boundary.charAt(i);
580+
if (c == '\\' && (i + 1) < _boundary.length()) {
581+
c = _boundary.charAt(++i);
582+
}
583+
unescaped += c;
584+
}
585+
_boundary = unescaped;
586+
} else {
587+
// Token form: the value ends at the next ';' (start of the next
588+
// parameter) or at the end of the header field.
589+
int semi = _boundary.indexOf(';');
590+
if (semi >= 0) {
591+
_boundary = _boundary.substring(0, semi);
592+
}
593+
_boundary.trim();
594+
}
595+
596+
// CWE-190 / DoS fix: RFC 2046 §5.1 limits boundary strings to 70
597+
// characters. _boundaryPosition was formerly uint8_t, so a boundary
598+
// of exactly 256 bytes would overflow the counter to 0, preventing the
599+
// BOUNDARY_OR_DATA loop from ever reaching the end of the boundary and
600+
// causing unbounded CPU consumption (watchdog reset on ESP32/ESP8266).
601+
// Reject boundaries outside the valid range before any parsing begins.
602+
if (_boundary.length() == 0 || _boundary.length() > 70) {
603+
async_ws_log_d("Invalid multipart boundary length (%u), aborting", _boundary.length());
604+
_parseState = PARSE_REQ_FAIL;
605+
abort();
606+
return true;
607+
}
608+
479609
_isMultipart = true;
480610
}
481611
} else if (name.equalsIgnoreCase(T_Content_Length) || name.equalsIgnoreCase(T_X_Expected_Entity_Length)) {
@@ -628,6 +758,11 @@ void AsyncWebServerRequest::_parseMultipartPostByte(uint8_t data, bool last) {
628758
_multiParseState = EXPECT_FEED1;
629759
}
630760
} else if (_multiParseState == EXPECT_BOUNDARY) {
761+
// Note: when _parsedLength < 2, the subtractions below (_parsedLength - 2
762+
// and _parsedLength - 3) wrap around to very large size_t values. This is
763+
// intentional: the wrapped values are always greater than _boundary.length()
764+
// (which is at most 70), so those comparisons evaluate to false and the
765+
// first two bytes (the '--' prefix) are consumed without error.
631766
if (_parsedLength < 2 && data != '-') {
632767
_multiParseState = PARSE_ERROR;
633768
return;
@@ -743,8 +878,9 @@ void AsyncWebServerRequest::_parseMultipartPostByte(uint8_t data, bool last) {
743878
itemWriteByte('\n');
744879
itemWriteByte('-');
745880
itemWriteByte('-');
746-
uint8_t i;
747-
for (i = 0; i < _boundaryPosition; i++) {
881+
// CWE-190 fix: loop variable was uint8_t, matching the old type of
882+
// _boundaryPosition. Changed to size_t for consistency.
883+
for (size_t i = 0; i < _boundaryPosition; i++) {
748884
itemWriteByte(_boundary.c_str()[i]);
749885
}
750886
_parseMultipartPostByte(data, last);
@@ -784,8 +920,7 @@ void AsyncWebServerRequest::_parseMultipartPostByte(uint8_t data, bool last) {
784920
itemWriteByte('\n');
785921
itemWriteByte('-');
786922
itemWriteByte('-');
787-
uint8_t i;
788-
for (i = 0; i < _boundary.length(); i++) {
923+
for (size_t i = 0; i < _boundary.length(); i++) {
789924
itemWriteByte(_boundary.c_str()[i]);
790925
}
791926
_parseMultipartPostByte(data, last);
@@ -800,8 +935,7 @@ void AsyncWebServerRequest::_parseMultipartPostByte(uint8_t data, bool last) {
800935
itemWriteByte('\n');
801936
itemWriteByte('-');
802937
itemWriteByte('-');
803-
uint8_t i;
804-
for (i = 0; i < _boundary.length(); i++) {
938+
for (size_t i = 0; i < _boundary.length(); i++) {
805939
itemWriteByte(_boundary.c_str()[i]);
806940
}
807941
itemWriteByte('\r');

src/literals.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ static constexpr const char T_username[] = "username";
108108
static constexpr const char T_WS[] = "websocket";
109109
static constexpr const char T_WWW_AUTH[] = "WWW-Authenticate";
110110
static constexpr const char T_X_Expected_Entity_Length[] = "X-Expected-Entity-Length";
111+
static constexpr const char T_BOUNDARY[] = "boundary=";
112+
static constexpr size_t T_BOUNDARY_LEN = sizeof(T_BOUNDARY) - 1;
111113

112114
// HTTP Methods
113115
static constexpr const char T_GET[] = "GET";

0 commit comments

Comments
 (0)