Skip to content

Commit 4753be6

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 all rewind loop variables from uint8_t to size_t (BOUNDARY_OR_DATA, DASH3_OR_RETURN2, EXPECT_FEED2). - WebRequest.cpp (_parseMultipartPostByte / EXPECT_BOUNDARY): add a comment explaining the intentional unsigned underflow when _parsedLength < 2. - WebRequest.cpp (_parseReqHeader): validate the boundary value extracted from the Content-Type header before any body parsing begins: * Media type comparison changed from case-sensitive startsWith() to a case-insensitive check on a pre-lowercased copy of the value, so that e.g. 'Multipart/form-data' is correctly recognised (RFC 2045 §5.1). * Require the 'boundary=' token to be a proper RFC 2045 parameter (immediately preceded by ';'), preventing false matches on tokens whose name merely ends in 'boundary=' (e.g. 'x-boundary='). * Parse the boundary value as either a token or a quoted-string per RFC 2046 §5.1. Quoted-string form correctly handles backslash-escaped quotes ("), pairs of backslashes (\), and rejects unterminated quoted strings. The closing-quote detector counts consecutive backslashes before the quote so that an even count (backslashes cancelling each other) correctly identifies the real closing quote. * 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. * Trim _contentType defensively after extraction. - AsyncWebHeader.cpp (parse): fix OWS handling per RFC 7230 §3.2.3. The previous code only skipped a single space after the colon; the correct rule is OWS = *( SP / HTAB ). This affected every parsed header: Content-Type, Upgrade, Expect, Transfer-Encoding, Host, etc. The fix is applied at the source so all header branches benefit. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent a87fcdb commit 4753be6

4 files changed

Lines changed: 134 additions & 12 deletions

File tree

src/AsyncWebHeader.cpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ const AsyncWebHeader AsyncWebHeader::parse(const char *data) {
2323
return AsyncWebHeader(); // Header name cannot be empty
2424
}
2525
const char *startOfValue = colon + 1; // Skip the colon
26-
// skip one optional whitespace after the colon
27-
if (*startOfValue == ' ') {
26+
// RFC 7230 §3.2.3: OWS (optional whitespace) = *( SP / HTAB )
27+
// Skip all leading spaces and tabs, not just the first one.
28+
while (*startOfValue == ' ' || *startOfValue == '\t') {
2829
startOfValue++;
2930
}
3031
String name;

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: 126 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -473,9 +473,122 @@ 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+
// Trim _contentType defensively; AsyncWebHeader::parse now strips all
477+
// leading OWS per RFC 7230, but trim() guards against any future change.
478+
_contentType.trim();
479+
// Media types are case-insensitive (RFC 2045 §5.1), so lowercase the
480+
// entire header value once and reuse it for all subsequent searches.
481+
String lowcase(value);
482+
lowcase.toLowerCase();
483+
if (lowcase.startsWith(T_MULTIPART_)) {
484+
// Case-insensitive search for the boundary parameter.
485+
// A simple indexOf("boundary=") would incorrectly match a token whose
486+
// name merely ends in "boundary=" (e.g. "x-boundary="). We require
487+
// the match to be immediately preceded by a ';' (with optional
488+
// whitespace) to ensure it is a proper RFC 2045 parameter.
489+
490+
int bpos = -1;
491+
int searchFrom = 0;
492+
while (true) {
493+
int pos = lowcase.indexOf(T_BOUNDARY, searchFrom);
494+
if (pos < 0) {
495+
break;
496+
}
497+
498+
// Only match a real parameter (must be preceded by ';' with optional whitespace)
499+
int p = pos - 1;
500+
while (p >= 0 && (lowcase.charAt(p) == ' ' || lowcase.charAt(p) == '\t')) {
501+
p--;
502+
}
503+
if (p >= 0 && lowcase.charAt(p) == ';') {
504+
bpos = pos;
505+
break;
506+
}
507+
508+
searchFrom = pos + 1;
509+
}
510+
511+
if (bpos < 0) {
512+
// No valid boundary parameter found — cannot parse multipart body.
513+
async_ws_log_d("Missing multipart boundary parameter, aborting");
514+
_parseState = PARSE_REQ_FAIL;
515+
abort();
516+
return true;
517+
}
518+
519+
// Extract the boundary value that follows "boundary=" and strip leading/
520+
// trailing whitespace. The value may be either a token or a
521+
// quoted-string (RFC 2045 §5.1 / RFC 2046 §5.1).
522+
_boundary = value.substring(bpos + (int)T_BOUNDARY_LEN);
523+
_boundary.trim();
524+
525+
if (_boundary.startsWith("\"")) {
526+
// Quoted-string form: scan forward from position 1 for the closing
527+
// double-quote. A quote is escaped only when preceded by an ODD
528+
// number of consecutive backslashes (e.g. \" is escaped, \\" is not
529+
// because the two backslashes escape each other, leaving the quote
530+
// unescaped). Checking only the immediately preceding character
531+
// would mishandle the \\" case.
532+
int endQuote = 1;
533+
while (true) {
534+
endQuote = _boundary.indexOf('"', endQuote);
535+
if (endQuote < 0) {
536+
break; // string ran out — unterminated quote
537+
}
538+
// Count consecutive backslashes immediately before this quote.
539+
int backslashes = 0;
540+
while (endQuote - 1 - backslashes >= 0 && _boundary.charAt(endQuote - 1 - backslashes) == '\\') {
541+
backslashes++;
542+
}
543+
if (backslashes % 2 == 0) {
544+
break; // even number of backslashes → quote is real closing quote
545+
}
546+
endQuote++; // odd number → quote is escaped, keep scanning
547+
}
548+
if (endQuote < 0) {
549+
// Opening quote was never closed — malformed header.
550+
async_ws_log_d("Invalid multipart boundary (unterminated quote), aborting");
551+
_parseState = PARSE_REQ_FAIL;
552+
abort();
553+
return true;
554+
}
555+
// Strip the surrounding quotes; content between them is the boundary.
556+
_boundary = _boundary.substring(1, endQuote);
557+
558+
// Unescape quoted-pair sequences so the boundary matches the actual bytes on the wire.
559+
String unescaped;
560+
unescaped.reserve(_boundary.length());
561+
for (size_t i = 0; i < _boundary.length(); ++i) {
562+
char c = _boundary.charAt(i);
563+
if (c == '\\' && (i + 1) < _boundary.length()) {
564+
c = _boundary.charAt(++i);
565+
}
566+
unescaped += c;
567+
}
568+
_boundary = unescaped;
569+
} else {
570+
// Token form: the value ends at the next ';' (start of the next
571+
// parameter) or at the end of the header field.
572+
int semi = _boundary.indexOf(';');
573+
if (semi >= 0) {
574+
_boundary = _boundary.substring(0, semi);
575+
}
576+
_boundary.trim();
577+
}
578+
579+
// CWE-190 / DoS fix: RFC 2046 §5.1 limits boundary strings to 70
580+
// characters. _boundaryPosition was formerly uint8_t, so a boundary
581+
// of exactly 256 bytes would overflow the counter to 0, preventing the
582+
// BOUNDARY_OR_DATA loop from ever reaching the end of the boundary and
583+
// causing unbounded CPU consumption (watchdog reset on ESP32/ESP8266).
584+
// Reject boundaries outside the valid range before any parsing begins.
585+
if (_boundary.length() == 0 || _boundary.length() > 70) {
586+
async_ws_log_d("Invalid multipart boundary length (%u), aborting", _boundary.length());
587+
_parseState = PARSE_REQ_FAIL;
588+
abort();
589+
return true;
590+
}
591+
479592
_isMultipart = true;
480593
}
481594
} else if (name.equalsIgnoreCase(T_Content_Length) || name.equalsIgnoreCase(T_X_Expected_Entity_Length)) {
@@ -628,6 +741,11 @@ void AsyncWebServerRequest::_parseMultipartPostByte(uint8_t data, bool last) {
628741
_multiParseState = EXPECT_FEED1;
629742
}
630743
} else if (_multiParseState == EXPECT_BOUNDARY) {
744+
// Note: when _parsedLength < 2, the subtractions below (_parsedLength - 2
745+
// and _parsedLength - 3) wrap around to very large size_t values. This is
746+
// intentional: the wrapped values are always greater than _boundary.length()
747+
// (which is at most 70), so those comparisons evaluate to false and the
748+
// first two bytes (the '--' prefix) are consumed without error.
631749
if (_parsedLength < 2 && data != '-') {
632750
_multiParseState = PARSE_ERROR;
633751
return;
@@ -743,8 +861,9 @@ void AsyncWebServerRequest::_parseMultipartPostByte(uint8_t data, bool last) {
743861
itemWriteByte('\n');
744862
itemWriteByte('-');
745863
itemWriteByte('-');
746-
uint8_t i;
747-
for (i = 0; i < _boundaryPosition; i++) {
864+
// CWE-190 fix: loop variable was uint8_t, matching the old type of
865+
// _boundaryPosition. Changed to size_t for consistency.
866+
for (size_t i = 0; i < _boundaryPosition; i++) {
748867
itemWriteByte(_boundary.c_str()[i]);
749868
}
750869
_parseMultipartPostByte(data, last);
@@ -784,8 +903,7 @@ void AsyncWebServerRequest::_parseMultipartPostByte(uint8_t data, bool last) {
784903
itemWriteByte('\n');
785904
itemWriteByte('-');
786905
itemWriteByte('-');
787-
uint8_t i;
788-
for (i = 0; i < _boundary.length(); i++) {
906+
for (size_t i = 0; i < _boundary.length(); i++) {
789907
itemWriteByte(_boundary.c_str()[i]);
790908
}
791909
_parseMultipartPostByte(data, last);
@@ -800,8 +918,7 @@ void AsyncWebServerRequest::_parseMultipartPostByte(uint8_t data, bool last) {
800918
itemWriteByte('\n');
801919
itemWriteByte('-');
802920
itemWriteByte('-');
803-
uint8_t i;
804-
for (i = 0; i < _boundary.length(); i++) {
921+
for (size_t i = 0; i < _boundary.length(); i++) {
805922
itemWriteByte(_boundary.c_str()[i]);
806923
}
807924
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)