Skip to content

Commit ef03828

Browse files
Merge pull request #447 from ESP32Async/CWE-190
fix(WebRequest): CWE-190/DoS fix and boundary-parsing refactor
2 parents cf5c3b9 + 4a172a0 commit ef03828

3 files changed

Lines changed: 395 additions & 90 deletions

File tree

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

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)