Skip to content

fix core: treat an empty multipart/form-data body as an empty form - #1362

Open
SSE4 wants to merge 1 commit into
userver-framework:developfrom
SSE4:multipart-empty-body-as-empty-form
Open

SSE4 wants to merge 1 commit into
userver-framework:developfrom
SSE4:multipart-empty-body-as-empty-form

Conversation

@SSE4

@SSE4 SSE4 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

closes: #1361

A request with a multipart/form-data Content-Type and a zero-length body
(Content-Length: 0) is rejected with
400 invalid body of multipart/form-data request before any handler runs. The
parser now reports an empty body as an empty form and lets the handler decide.

Why

The honest version of this argument has two halves, because the input is
genuinely odd.

The content really is malformed.
RFC 2046 §5.1.1:

multipart-body := [preamble CRLF]
                  dash-boundary transport-padding CRLF
                  body-part *encapsulation
                  close-delimiter transport-padding
                  [CRLF epilogue]

Only *encapsulation is zero-or-more; dash-boundary, one body-part and
close-delimiter are all mandatory, so a zero-length body cannot match. No
argument there.

But the request is valid HTTP, and that's what userver is answering.
RFC 9112 §6 gives
message-body = *OCTET and states that "request message framing is independent
of method semantics"
.
RFC 9113 §8.1.1
enumerates malformed HTTP/2 messages exhaustively — extraneous frames,
prohibited or absent pseudo-header fields, invalid field names, content-length
not matching the sum of DATA payload lengths — and a zero content-length with
no DATA frames matches none of them. content-type does not appear in that
definition at all. No HTTP specification requires content to conform to the
grammar of its declared media type.

So rejecting is permitted, not required —
RFC 9110 §2.4: "a
recipient MAY attempt to recover a usable protocol element from an invalid
construct. HTTP does not define specific error handling mechanisms except when
they have a direct impact on security, since different applications of the
protocol require different error handling strategies."

And if a server does reject,
§15.5.1 scopes 400 to
"malformed request syntax, invalid request message framing, or deceptive request
routing"
— all HTTP-level concerns. A content-format problem has
415 and
422. So the current
400 is the wrong code even under the strictest reading.

userver already has the concept

A body of exactly --boundary-- — an empty form that spells itself out — is
already accepted with no form arguments (ParseEmptyForm, and NoFinalCrLf2
without the trailing CRLF). The parser therefore already has a "valid request,
no form arguments" outcome; the zero-byte case simply isn't routed to it. This PR
routes it there.

That also makes the current behaviour hard to defend as deliberate: two inputs
that mean the same thing to a handler get a 200 and a 400.

How

 LOG_TRACE() << "body=" << body << ", body.size()=" << body.size();
+if (body.empty()) {
+    // ... RFC 2046 5.1.1 / RFC 9112 6 ...
+    return true;
+}
 std::string_view crlf = "\r\n";

One early return at the top of ParseMultipartFormDataBody. The RFC-citation
comment follows the existing convention in this file, which already annotates
ReadToken and ReadHeaderValue with the grammar rule and link they implement.

Handlers that require form arguments are unaffected: GetFormDataArg returns a
static empty FormDataArg when the name is absent, so they can answer
415/422/400 themselves with a message that describes their own contract.

What still fails

The leniency is exactly zero-length, nothing more:

input result
multipart/form-data with no boundary, empty body still rejected — 'boundary' parameter of multipart/form-data not found
body "\r\n" still rejected — Unexpected request body end
body "--zzz\r\n" (truncated) still rejected, same
garbage, missing close-delimiter, etc. unchanged

All four are pinned by ParseEmptyBodyLeniencyIsNarrow.

Tests

  • ParseEmptyBody — an empty body parses to an empty form, for all three
    strict_cr_lf settings (default, true, false).
  • ParseEmptyBodyLeniencyIsNarrow — the negative table above.
  • test_empty_body_is_rejected_by_the_handler in
    samples/multipart_service — posting an empty body now gets the handler's own
    Expecting PNG image format instead of the framework's
    invalid body of multipart/form-data request. The status is still 400; what
    changed is who chose it and what it says.

Verified locally: ParseEmptyBody and the functional test fail without the
production change and pass with it; ParseEmptyBodyLeniencyIsNarrow passes
either way. Full core unit suite 2218/2219 (one pre-existing skipped death test)
and all 7 basic-chaos suites stay green.

Relation to #1360

#1360 makes any
multipart parse failure non-fatal, which overlaps this at HTTP level: with that
merged, an empty body already reaches the handler. The two are still worth
having separately, and are independent commits on develop:

Happy to land them in either order. Both touch
samples/multipart_service/tests/test_multipart.py, so whichever merges second
needs a trivial rebase there — say the word and I'll do it.

A request with a multipart/form-data Content-Type and a zero-length body was
rejected with `400 invalid body of multipart/form-data request` before any
handler ran.

The body really is not a valid MIME entity -- `multipart-body` in RFC 2046
5.1.1 requires a dash-boundary, one body-part and a close-delimiter, none of
which a zero-length body has. But it is a valid HTTP request: RFC 9112 6 gives
`message-body = *OCTET` and notes that request framing is independent of method
semantics, and RFC 9113 8.1.1 does not list it among malformed HTTP/2 messages.
No HTTP specification requires content to conform to the grammar of its
declared media type.

RFC 9110 2.4 leaves the choice to the application, so the parser now reports an
empty body as an empty form and lets the handler decide whether a missing form
is an error -- which is also what it already does for `--boundary--`, an empty
form that spells itself out.

The leniency is exactly zero-length: a Content-Type without `boundary`, a
whitespace-only body and a truncated one all still fail.
@apolukhin

Copy link
Copy Markdown
Member

LGTM

@robot-magpie

robot-magpie Bot commented Oct 7, 2026

Copy link
Copy Markdown

Many thanks for the PR! @apolukhin is now importing your pull request into our internal upstream repository.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

400 on a multipart/form-data request with an empty body

2 participants