Skip to content

Commit d62f601

Browse files
committed
fix(WebRequest): CWE-190/DoS fix and boundary-parsing refactor
Tighten multipart boundary parsing in _parseReqHeader(): - Replace String::charAt() inner loop with a raw C-string pointer and pre-computed length for faster, allocation-free scanning. - Add an early-exit upper bound on the scan loop so that positions where 'boundary=' cannot possibly fit are skipped entirely. - Replace the three-pass quoted-string extraction (find close-quote, substring, then unescape) with a single-pass approach that writes directly into _boundary using a raw pointer+length pair — no intermediate heap allocations and no C++17 dependency. - Enforce the RFC 2046 §5.1 70-character limit on boundary length in both token and quoted-string paths; abort with PARSE_REQ_FAIL on violation to prevent CWE-190 integer-overflow / DoS. - Replace strncmp length guard with a position-based early break that is clearer and avoids the redundant cast. - Fix ESP8266 build: String(const char*, size_t) is unavailable; use a 71-byte stack buffer + String(const char*) instead (#ifdef ESP8266). - Fix LibreTiny/C++14 build: remove std::string_view (C++17 only); replaced with plain const char* + size_t throughout. Ref: #445
1 parent 3469e82 commit d62f601

3 files changed

Lines changed: 365 additions & 54 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// SPDX-License-Identifier: LGPL-3.0-or-later
2+
// Copyright 2016-2026 Hristo Gochkov, Mathieu Carbou, Emil Muratov, Will Miles
3+
4+
/*
5+
Demo to test multi-part upload handling and boundary parsing in AsyncWebServer.
6+
7+
Covers boundary-parsing cases introduced in the CWE-190/DoS hardening commit:
8+
- Token boundary (standard)
9+
- Quoted-string boundary (RFC 2046 §5.1)
10+
- Quoted-string with a quoted-pair escape sequence
11+
- Leading OWS (whitespace) after "boundary="
12+
- boundary= nested inside another quoted parameter value (must be ignored)
13+
- "x-boundary=" prefix must NOT be matched
14+
- Boundary longer than 70 chars → rejected (400)
15+
- Empty boundary → rejected (400)
16+
- Unterminated quoted-string → rejected (400)
17+
18+
── /upload endpoint (all platforms) ──────────────────────────────────────────
19+
20+
1. Standard token boundary (curl -F generates this automatically):
21+
22+
curl -v -F "field=hello" -F "file=@README.md" http://192.168.4.1/upload
23+
24+
2. Quoted-string boundary:
25+
26+
curl -v \
27+
-H 'Content-Type: multipart/form-data; boundary="my-boundary"' \
28+
--data-binary $'--my-boundary\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--my-boundary--\r\n' \
29+
http://192.168.4.1/upload
30+
31+
3. Quoted-string with a quoted-pair escape (\" inside the boundary value):
32+
33+
curl -v \
34+
-H 'Content-Type: multipart/form-data; boundary="my-\"bnd\""' \
35+
--data-binary $'--my-"bnd"\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--my-"bnd"--\r\n' \
36+
http://192.168.4.1/upload
37+
38+
4. Leading whitespace after boundary= (non-RFC but tolerated):
39+
40+
curl -v \
41+
-H 'Content-Type: multipart/form-data; boundary= simple' \
42+
--data-binary $'--simple\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--simple--\r\n' \
43+
http://192.168.4.1/upload
44+
45+
5. boundary= embedded in another quoted parameter value — must be ignored, real boundary is "real":
46+
47+
curl -v \
48+
-H 'Content-Type: multipart/form-data; foo="x; boundary=fake"; boundary=real' \
49+
--data-binary $'--real\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--real--\r\n' \
50+
http://192.168.4.1/upload
51+
52+
6. "x-boundary=" prefix must NOT match — request should be aborted:
53+
54+
curl -v \
55+
-H 'Content-Type: multipart/form-data; x-boundary=notreal' \
56+
--data-binary $'--notreal\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--notreal--\r\n' \
57+
http://192.168.4.1/upload
58+
59+
7. Boundary longer than 70 chars → abort:
60+
61+
curl -v \
62+
-H 'Content-Type: multipart/form-data; boundary=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX' \
63+
--data-binary $'--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX--\r\n' \
64+
http://192.168.4.1/upload
65+
66+
8. Empty boundary → abort:
67+
68+
curl -v \
69+
-H 'Content-Type: multipart/form-data; boundary=' \
70+
--data-binary $'--\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n----\r\n' \
71+
http://192.168.4.1/upload
72+
73+
9. Unterminated quoted-string → abort:
74+
75+
curl -v \
76+
-H 'Content-Type: multipart/form-data; boundary="unterminated' \
77+
--data-binary $'--unterminated\r\nContent-Disposition: form-data; name="field"\r\n\r\nhello\r\n--unterminated--\r\n' \
78+
http://192.168.4.1/upload
79+
80+
── /flash endpoint (ESP32 only) ──────────────────────────────────────────────
81+
82+
Flash firmware and filesystem in one request:
83+
1. Build firmware: pio run -e arduino-3
84+
2. Build FS image: pio run -e arduino-3 -t buildfs
85+
3. Flash both:
86+
87+
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
88+
89+
*/
90+
91+
#include <Arduino.h>
92+
#if defined(ESP32) || defined(LIBRETINY)
93+
#include <AsyncTCP.h>
94+
#include <WiFi.h>
95+
#elif defined(ESP8266)
96+
#include <ESP8266WiFi.h>
97+
#include <ESPAsyncTCP.h>
98+
#elif defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350)
99+
#include <RPAsyncTCP.h>
100+
#include <WiFi.h>
101+
#endif
102+
103+
#include <ESPAsyncWebServer.h>
104+
#include <StreamString.h>
105+
#include <LittleFS.h>
106+
107+
// ESP32 example ONLY
108+
#ifdef ESP32
109+
#include <Update.h>
110+
#endif
111+
112+
static AsyncWebServer server(80);
113+
114+
void setup() {
115+
Serial.begin(115200);
116+
117+
if (!LittleFS.begin()) {
118+
LittleFS.format();
119+
LittleFS.begin();
120+
}
121+
122+
#if ASYNCWEBSERVER_WIFI_SUPPORTED
123+
WiFi.mode(WIFI_AP);
124+
WiFi.softAP("esp-captive");
125+
#endif
126+
127+
// ── /upload — all platforms ───────────────────────────────────────────────
128+
//
129+
// Generic multipart endpoint used to exercise all boundary-parsing cases.
130+
// Responds 200 with a summary of every received parameter, or 400 if the
131+
// request is rejected by the parser (boundary too long, empty, malformed…).
132+
//
133+
server.on(
134+
"/upload", HTTP_POST,
135+
[](AsyncWebServerRequest *request) {
136+
if (request->getResponse()) {
137+
// A 400 was already sent by the upload handler — do not overwrite it.
138+
return;
139+
}
140+
141+
StreamString body;
142+
const size_t params = request->params();
143+
body.printf("Received %u parameter(s):\n", params);
144+
for (size_t i = 0; i < params; i++) {
145+
const AsyncWebParameter *p = request->getParam(i);
146+
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());
147+
}
148+
149+
Serial.print(body);
150+
request->send(200, "text/plain", body);
151+
},
152+
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
153+
if (request->getResponse()) {
154+
// Upload already aborted.
155+
return;
156+
}
157+
if (!index) {
158+
Serial.printf("Upload start: %s\n", filename.isEmpty() ? "(field)" : filename.c_str());
159+
}
160+
if (final) {
161+
Serial.printf("Upload end: %s (%u bytes)\n", filename.isEmpty() ? "(field)" : filename.c_str(), index + len);
162+
}
163+
}
164+
);
165+
166+
// ── /flash — ESP32 only ───────────────────────────────────────────────────
167+
#ifdef ESP32
168+
169+
// Shows how to flash firmware and filesystem images from a multipart upload
170+
// and how to handle multiple files and parameters in a single request.
171+
server.on(
172+
"/flash", HTTP_POST,
173+
[](AsyncWebServerRequest *request) {
174+
if (request->getResponse()) {
175+
// response already created
176+
return;
177+
}
178+
179+
// list all parameters
180+
Serial.println("Request parameters:");
181+
const size_t params = request->params();
182+
for (size_t i = 0; i < params; i++) {
183+
const AsyncWebParameter *p = request->getParam(i);
184+
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());
185+
}
186+
187+
Serial.println("Flash / Filesystem upload completed");
188+
189+
request->send(200, "text/plain", "Upload complete");
190+
},
191+
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
192+
Serial.printf("Upload[%s]: index=%u, len=%u, final=%d\n", filename.c_str(), index, len, final);
193+
194+
if (request->getResponse() != nullptr) {
195+
// upload aborted
196+
return;
197+
}
198+
199+
// start a new content-disposition upload
200+
if (!index) {
201+
// list all parameters
202+
const size_t params = request->params();
203+
for (size_t i = 0; i < params; i++) {
204+
const AsyncWebParameter *p = request->getParam(i);
205+
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());
206+
}
207+
208+
// get the content-disposition parameter
209+
const AsyncWebParameter *p = request->getParam(asyncsrv::T_name, true, true);
210+
if (p == nullptr) {
211+
request->send(400, "text/plain", "Missing content-disposition 'name' parameter");
212+
return;
213+
}
214+
215+
// determine upload type based on the parameter name
216+
if (p->value() == "fs") {
217+
Serial.printf("Filesystem image upload for file: %s\n", filename.c_str());
218+
if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) {
219+
Update.printError(Serial);
220+
request->send(400, "text/plain", "Update begin failed");
221+
return;
222+
}
223+
224+
} else if (p->value() == "fw") {
225+
Serial.printf("Firmware image upload for file: %s\n", filename.c_str());
226+
if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) {
227+
Update.printError(Serial);
228+
request->send(400, "text/plain", "Update begin failed");
229+
return;
230+
}
231+
232+
} else {
233+
Serial.printf("Unknown upload type for file: %s\n", filename.c_str());
234+
request->send(400, "text/plain", "Unknown upload type");
235+
return;
236+
}
237+
}
238+
239+
// some bytes to write ?
240+
if (len) {
241+
if (Update.write(data, len) != len) {
242+
Update.printError(Serial);
243+
Update.end();
244+
request->send(400, "text/plain", "Update write failed");
245+
return;
246+
}
247+
}
248+
249+
// finish the content-disposition upload
250+
if (final) {
251+
if (!Update.end(true)) {
252+
Update.printError(Serial);
253+
request->send(400, "text/plain", "Update end failed");
254+
return;
255+
}
256+
257+
// success response is created in the final request handler when all uploads are completed
258+
Serial.printf("Upload success of file %s\n", filename.c_str());
259+
}
260+
}
261+
);
262+
263+
#endif
264+
265+
server.begin();
266+
}
267+
268+
// not needed
269+
void loop() {
270+
delay(100);
271+
}

platformio.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ lib_dir = .
2222
; src_dir = examples/arduino/Logging
2323
; src_dir = examples/arduino/MessagePack
2424
; src_dir = examples/arduino/Middleware
25+
; src_dir = examples/arduino/MultiPart
2526
; src_dir = examples/arduino/Params
2627
; src_dir = examples/arduino/PartitionDownloader
2728
src_dir = examples/arduino/PerfTests

0 commit comments

Comments
 (0)