How to test multipart form data
Send the upload to a server that prints the raw request bytes: curl -F "file=@note.txt" http://127.0.0.1:8934/upload. Then compare the boundary value in the Content-Type header with the delimiter lines in the body, which carry two extra hyphens in front, end with CRLF, and close with two more hyphens.
Why check this
Run this when an upload endpoint is built, when it moves behind a new proxy, and whenever a client stops using a form encoder and assembles the body itself. The failure it prevents is a request that looks right in an access log and carries nothing a parser can find: the endpoint answers 400 with "file is required" while the file is plainly in the body.
Two properties hide it. The delimiter is a random string that must match in the Content-Type header and in the body, and the framing lines end with CRLF, which no log viewer or network panel displays.
Prerequisites
- Node 22 for the two scripts below, saved as
echo-8934.jsandparse.js. - curl 8 with
-F. See the curl manual entry for -F. - Port 8934 free. Confirm with
netstat -ano | grep 8934, and stop the server afterwards. - RFC 7578 for parts, RFC 2046 section 5.1.1 for the delimiter.
Two fixtures: the 18 byte note.txt, and lf-body.txt, a hand-built body with LF line endings for step 7.
printf 'line one\nline two\n' > note.txt
printf -- '--X\nContent-Disposition: form-data; name="title"\n\nrelease notes\n--X--\n' > lf-body.txt
The server marks every CRLF, saves the bytes to last-request.bin, and answers 200 without parsing. A raw socket server is needed here, because an HTTP library hides the framing.
// echo-8934.js - prints one raw HTTP request with every CRLF marked, then answers 200.
const net = require('net');
const fs = require('fs');
net.createServer((sock) => {
let buf = Buffer.alloc(0);
sock.on('data', (d) => {
buf = Buffer.concat([buf, d]);
const sep = buf.indexOf('\r\n\r\n');
if (sep === -1) return;
const head = buf.subarray(0, sep).toString('latin1');
const m = /content-length: *(\d+)/i.exec(head);
const total = sep + 4 + (m ? Number(m[1]) : 0);
if (buf.length < total) return;
fs.writeFileSync('last-request.bin', buf.subarray(0, total));
const raw = buf.subarray(0, total).toString('latin1');
console.log(raw.replace(/\r\n/g, '\\r\\n\n'));
console.log('--- end of request, ' + total + ' bytes ---');
sock.end('HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok\n');
});
}).listen(8934, '127.0.0.1', () => console.log('raw echo on 127.0.0.1:8934'));
Steps 6 and 9 use Node 22's own multipart parser.
// parse.js - feeds a captured request to Node 22's own multipart parser. node parse.js <file>
const fs = require('node:fs');
const raw = fs.readFileSync(process.argv[2]);
const sep = raw.indexOf('\r\n\r\n');
const head = raw.subarray(0, sep).toString('latin1');
const ct = /^content-type: *(.*)$/im.exec(head)[1].trim();
const body = raw.subarray(sep + 4);
new Response(body, { headers: { 'content-type': ct } })
.formData()
.then((f) => {
for (const [k, v] of f) {
console.log(k + ' = ' + (v instanceof File ? `File(name=${v.name}, type=${v.type}, size=${v.size})` : JSON.stringify(v)));
}
})
.catch((e) => console.log('parse failed: ' + e.message));
Steps
- Step 1.
Start the server in its own terminal.
node echo-8934.js | tee upload.lograw echo on 127.0.0.1:8934 - Step 2.
Send a form with one text field and one file.
curl -s http://127.0.0.1:8934/upload -F "title=release notes" -F "file=@note.txt;type=text/plain"ok - Step 3.
Read the bytes the server received.
tail -n +2 upload.logPOST /upload HTTP/1.1\r\n Host: 127.0.0.1:8934\r\n User-Agent: curl/8.21.0\r\n Accept: */*\r\n Content-Length: 329\r\n Content-Type: multipart/form-data; boundary=------------------------OeKC5xOk20aR0VyuzlEaBr\r\n \r\n --------------------------OeKC5xOk20aR0VyuzlEaBr\r\n Content-Disposition: form-data; name="title"\r\n \r\n release notes\r\n --------------------------OeKC5xOk20aR0VyuzlEaBr\r\n Content-Disposition: form-data; name="file"; filename="note.txt"\r\n Content-Type: text/plain\r\n \r\n line one line two \r\n --------------------------OeKC5xOk20aR0VyuzlEaBr--\r\n --- end of request, 527 bytes ---The boundary appears in the header and at the start of each delimiter line, with two hyphens in front. Each part has a
Content-Disposition: form-dataline naming the field, a blank line, then its content. The file keeps its own LF endings: part content is opaque bytes the framing never touches. - Step 4.
Confirm the boundary values agree and the closing delimiter is present.
node -e "const b=require('fs').readFileSync('last-request.bin').toString('latin1');const bd=/boundary=(.*)\r/.exec(b)[1];console.log('boundary in header : '+bd);console.log('characters : '+bd.length);console.log('delimiter lines : '+(b.split('\r\n--'+bd).length-1));console.log('closing delimiter : '+b.includes('\r\n--'+bd+'--\r\n'));"boundary in header : ------------------------OeKC5xOk20aR0VyuzlEaBr characters : 46 delimiter lines : 3 closing delimiter : trueTwo parts produce three delimiter lines. The third is the closing one, and its absence is what an interrupted upload looks like.
- Step 5.
Read the same request from the client side, for when the server cannot be instrumented.
curl -s -o /dev/null --trace-ascii - http://127.0.0.1:8934/upload -F "title=release notes" -F "file=@note.txt;type=text/plain"=> Send header, 198 bytes (0xc6) 0000: POST /upload HTTP/1.1 0017: Host: 127.0.0.1:8934 002d: User-Agent: curl/8.21.0 0046: Accept: */* 0053: Content-Length: 329 0068: Content-Type: multipart/form-data; boundary=-------------------- 00a8: ----FMMXXqZzs9koBb45v44L8L 00c4: => Send data, 329 bytes (0x149) 0000: --------------------------FMMXXqZzs9koBb45v44L8L 0032: Content-Disposition: form-data; name="title" 0060: 0062: release notes 0071: --------------------------FMMXXqZzs9koBb45v44L8L 00a3: Content-Disposition: form-data; name="file"; filename="note.txt" 00e5: Content-Type: text/plain 00ff: 0101: line one.line two. 0115: --------------------------FMMXXqZzs9koBb45v44L8L-- * upload completely sent off: 329 bytes …The boundary differs from step 2 because curl generates a new one per request, so no test may hard code it. The left column is the byte offset, and it is the only place the line endings survive here: the delimiter is 48 characters and the next offset is 0x32, that is 50, so a CRLF sits between them.
- Step 6.
Hand the captured bytes to a real parser.
node parse.js last-request.bintitle = "release notes" file = File(name=note.txt, type=text/plain, size=18)The 18 byte size matches
note.txton disk. - Step 7.
Send the same form with a body you built by hand, using LF where CRLF belongs.
curl -s http://127.0.0.1:8934/upload -H "Content-Type: multipart/form-data; boundary=X" --data-binary @lf-body.txtokThe server answers 200, because it stores bytes without parsing them. Nothing at the HTTP level marks the body as broken.
- Step 8.
Read the bytes of that second request.
tail -n 14 upload.logPOST /upload HTTP/1.1\r\n Host: 127.0.0.1:8934\r\n User-Agent: curl/8.21.0\r\n Accept: */*\r\n Content-Type: multipart/form-data; boundary=X\r\n Content-Length: 70\r\n \r\n --X Content-Disposition: form-data; name="title" release notes --X-- --- end of request, 222 bytes ---The request headers carry their
\r\nmarkers and the body lines have none. That difference is the whole defect. - Step 9.
Parse the LF body with the same parser that accepted step 6.
node parse.js last-request.binparse failed: Failed to parse body as FormData.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| The header boundary value opens every delimiter line with two hyphens in front | The framing is consistent | Nothing. Move on to the part headers. |
| delimiter lines : 3 for a two-part form | Every part is delimited and the body is closed | Nothing. This is the shape a parser expects. |
| closing delimiter : false | The body ends without the terminating delimiter | Compare Content-Length with the bytes received. An upload cut short ends this way. |
| Body lines with no \r\n marker in step 8 | The client used LF line endings | Rebuild the body with CRLF, or hand the form to the client's own encoder. |
| parse failed: Failed to parse body as FormData. | The parser found no usable part | Check the header boundary first, then the line endings. The message covers both. |
| A part with no Content-Disposition line | The part has no field name | Fix the client. RFC 7578 section 4.2 requires the header and the name parameter on every part. |
Common mistakes
Thresholds
The boundary curl generated in step 4 is 46 characters, inside that limit.
What to check next
- How to check content disposition header: the same header name on a response, naming a download.
- How to check if file type is validated on the server: the part
Content-Typein step 3 is client supplied. - How to test a unicode file name in content disposition: the
filenameparameter outside ASCII. - How to check sha256 of a file: confirm the stored file matches the 18 bytes reported.
- How to test file upload size limit: what to exercise once the framing is correct.
FAQ
What is boundary in multipart form data?
A delimiter string chosen by the client and announced in the Content-Type header. Every part starts with two hyphens plus that string, and the body ends with the same line plus two more hyphens. It must not appear inside any part, so curl generates a random one per request.
How to upload a file with curl?
curl -F "file=@note.txt;type=text/plain" http://host/upload. The @ reads the file and sets filename from the path. The ;type= suffix sets that part's Content-Type, which RFC 7578 section 4.4 defaults to text/plain when it is absent.
Is content disposition form data the same header as on a download?
Same header name, different place and meaning. In a request part it names the form field, as in Content-Disposition: form-data; name="file". On a response, disposition type attachment tells the browser to save rather than render, which is How to check content disposition header.
How to test file upload in postman?
Body tab, form-data, set the key type to File and pick the file. Postman builds the boundary and the part headers itself, so it exercises the server and not your client's framing. Use the Console, not the Body panel, for the bytes this page reads.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny