Skip to content

restore_payload drops the body of multipart/form-data and x-www-form-urlencoded PUT/PATCH requests #41

Description

@Hoffs

restore_payload drops the body of multipart/form-data and x-www-form-urlencoded PUT/PATCH requests

Summary

restore_payload() only reconstructs a consumed form body when request.method == "POST". For any other method that legitimately carries a form body — notably PUT and PATCH — it returns the already-emptied request.data, so the body is silently lost. This surfaces when rolo is used to proxy requests (e.g. the API Gateway HTTP API AWS_PROXY integration in LocalStack): a multipart/form-data PUT/PATCH reaches the target with an empty body while Content-Length is preserved.

The HTTP spec places no restriction tying multipart/form-data (or any media type) to POST — RFC 9110 lets PUT/PATCH carry any representation, and clients routinely send multipart via fetch(url, {method: 'PUT', body: formData}). The POST-only assumption here appears to trace back to HTML <form> (which only supports GET/POST), not to HTTP itself.

Affected code

rolo/request.pyrestore_payload (rolo 0.8.3):

def restore_payload(request: Request) -> bytes:
    if request.shallow:
        return b""
    data = request.data
    if request.method != "POST":          # <-- drops PUT/PATCH form bodies
        return data
    if request.mimetype == "multipart/form-data":
        boundary = request.content_type.split("=")[1]
        fields = MultiDict()
        fields.update(request.form)
        fields.update(request.files)
        _, data_files = encode_multipart(fields, boundary)
        data += data_files
    elif request.mimetype == "application/x-www-form-urlencoded":
        data += urlencode(list(request.form.items(multi=True))).encode("utf-8")
    return data

For a form content-type, Werkzeug's request.data is empty (the body is reserved for form parsing), so the method != "POST" early-return yields b"" for PUT/PATCH instead of re-encoding request.form + request.files.

Reproduction

from rolo.request import Request, restore_payload

BODY = (
    b'--b\r\nContent-Disposition: form-data; name="field"\r\n\r\nval\r\n'
    b'--b\r\nContent-Disposition: form-data; name="file"; filename="f.txt"\r\n'
    b'Content-Type: text/plain\r\n\r\nfilebytes\r\n--b--\r\n'
)

for method in ("POST", "PUT", "PATCH"):
    r = Request(method=method, path="/x",
                headers={"Content-Type": "multipart/form-data; boundary=b"}, body=BODY)
    out = restore_payload(r)
    print(f"{method:5} len={len(out)}")

Output (rolo 0.8.3):

POST  len=174     # reconstructed
PUT   len=0       # body lost
PATCH len=0       # body lost

Impact

Observed end-to-end on LocalStack 2026.06.0 (HTTP API v2 → Lambda AWS_PROXY): a multipart/form-data PUT (and PATCH) is delivered to the Lambda with "body": "" while Content-Length is forwarded, so the handler sees an empty/truncated form (multipart: NextPart: EOF). The same request as POST works. Any rolo-based proxy path for non-POST form uploads is affected.

Suggested fix

Reconstruct for every method that carries a form body, or key off content-type rather than method:

-    if request.method != "POST":
-        return data
+    if request.method not in ("POST", "PUT", "PATCH"):
+        return data

(Or drop the method check entirely and gate solely on request.mimetype, since the reconstruction is a no-op for non-form bodies.)

Notes

  • restore_payload was last touched in fix restore payload #13, which fixed the multipart-vs-urlencoded re-encoding but left the method != "POST" guard in place.
  • Environment: rolo 0.8.3, Werkzeug (as shipped in LocalStack 2026.06.0), Python 3.13.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions