fix(decode): handle unquoted Content-Disposition name parameter#45
Conversation
The name extraction unconditionally stripped the first and last characters assuming surrounding quotes. When the name value was unquoted (e.g. name=login_id), this truncated the field name (login_id -> ogin_i), making it inaccessible by its real key.
Luacheck Report2 tests 2 ✅ 0s ⏱️ Results for commit ade7587. |
There was a problem hiding this comment.
Pull request overview
Fixes multipart Content-Disposition parsing so name= values are no longer incorrectly truncated when the parameter is unquoted, restoring access to fields by their real keys (FTI-7402).
Changes:
- Update
decode()to extract thenameparameter without assuming surrounding quotes. - Add a regression test covering unquoted
name=login_id/name=api_keymultipart parts.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/multipart.lua |
Adjusts Content-Disposition name extraction pattern to handle unquoted values. |
spec/multipart_spec.lua |
Adds test ensuring unquoted field names are decoded and retrievable correctly. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if pos then | ||
| local current_value = v:match("^%s*([^=]*)", pos):gsub("%s*$", "") | ||
| part_name = sub(current_value, 2, #current_value - 1) | ||
| part_name = v:match('^%s*"?([^"]*)"?', pos):gsub("%s*$", "") |
There was a problem hiding this comment.
The new name extraction only strips optional double quotes. Previously, single-quoted values like name='login_id' would be normalized to login_id, but with this change they will be returned including the single quotes (a backwards-incompatible behavior change). Consider supporting both quote styles (similar to boundary parsing) or explicitly stripping matching surrounding quotes for either ' or ", and add a regression test if this input is expected.
| part_name = v:match('^%s*"?([^"]*)"?', pos):gsub("%s*$", "") | |
| local raw_name = v:match("^%s*(.-)%s*$", pos) | |
| if raw_name then | |
| local first = sub(raw_name, 1, 1) | |
| local last = sub(raw_name, -1) | |
| if #raw_name >= 2 and first == last and (first == '"' or first == "'") then | |
| part_name = sub(raw_name, 2, -2) | |
| else | |
| part_name = raw_name | |
| end | |
| part_name = part_name:gsub("%s*$", "") | |
| end |
There was a problem hiding this comment.
HTTP generally uses double quotes. For single quotes the following tests shows that Python (httpie)'s and Go (mccutchen/go-httpbin)'s implementation escapes double quotes but leave single quotes unchanged, making single quotes as part of the key name as-is. I don't have a strong opinion though.
$ http :5000/post foo=1 \"bar\"=2 --multipart -v
POST /post HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 214
Content-Type: multipart/form-data; boundary=0ae2a49dc7bb496eacb1ad2a56f78eae
Host: localhost:5000
User-Agent: HTTPie/3.2.4
--0ae2a49dc7bb496eacb1ad2a56f78eae
Content-Disposition: form-data; name="foo"
1
--0ae2a49dc7bb496eacb1ad2a56f78eae
Content-Disposition: form-data; name="%22bar%22"
2
--0ae2a49dc7bb496eacb1ad2a56f78eae--
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Content-Length: 857
Content-Type: application/json; charset=utf-8
Date: Mon, 30 Mar 2026 08:34:55 GMT
{
"args": {},
"data": "--0ae2a49dc7bb496eacb1ad2a56f78eae\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n1\r\n--0ae2a49dc7bb496eacb1ad2a56f78eae\r\nContent-Disposition: form-data; name=\"%22bar%22\"\r\n\r\n2\r\n--0ae2a49dc7bb496eacb1ad2a56f78eae--\r\n",
"files": {},
"form": {
"%22bar%22": [
"2"
],
"foo": [
"1"
]
},
"headers": {
"Accept": [
"*/*"
],
"Accept-Encoding": [
"gzip, deflate"
],
"Connection": [
"keep-alive"
],
"Content-Length": [
"214"
],
"Content-Type": [
"multipart/form-data; boundary=0ae2a49dc7bb496eacb1ad2a56f78eae"
],
"Host": [
"localhost:5000"
],
"User-Agent": [
"HTTPie/3.2.4"
]
},
"json": null,
"method": "POST",
"origin": "192.168.10.49",
"url": "http://localhost:5000/post"
}$ http :5000/post foo=1 \'bar\'=2 --multipart -v
POST /post HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 210
Content-Type: multipart/form-data; boundary=691735ed9a344534a3664dff07a9c3f9
Host: localhost:5000
User-Agent: HTTPie/3.2.4
--691735ed9a344534a3664dff07a9c3f9
Content-Disposition: form-data; name="foo"
1
--691735ed9a344534a3664dff07a9c3f9
Content-Disposition: form-data; name="'bar'"
2
--691735ed9a344534a3664dff07a9c3f9--
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Content-Length: 849
Content-Type: application/json; charset=utf-8
Date: Mon, 30 Mar 2026 08:34:39 GMT
{
"args": {},
"data": "--691735ed9a344534a3664dff07a9c3f9\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n1\r\n--691735ed9a344534a3664dff07a9c3f9\r\nContent-Disposition: form-data; name=\"'bar'\"\r\n\r\n2\r\n--691735ed9a344534a3664dff07a9c3f9--\r\n",
"files": {},
"form": {
"'bar'": [
"2"
],
"foo": [
"1"
]
},
"headers": {
"Accept": [
"*/*"
],
"Accept-Encoding": [
"gzip, deflate"
],
"Connection": [
"keep-alive"
],
"Content-Length": [
"210"
],
"Content-Type": [
"multipart/form-data; boundary=691735ed9a344534a3664dff07a9c3f9"
],
"Host": [
"localhost:5000"
],
"User-Agent": [
"HTTPie/3.2.4"
]
},
"json": null,
"method": "POST",
"origin": "192.168.10.49",
"url": "http://localhost:5000/post"
}There was a problem hiding this comment.
A string of text is parsed as a single value if it is quoted using double-quote marks.
https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
From this document, it seems that only double quotes need to be handled.
The name extraction unconditionally stripped the first and last characters assuming surrounding quotes. When the name value was unquoted (e.g. name=login_id), this truncated the field name (login_id -> ogin_i), making it inaccessible by its real key.
In the lua pattern I changed
[^=]to[^"]and I think it should be OK.Fix FTI-7402