Skip to content

fix core: don't fail the request when the multipart body is malformed - #1360

Open
SSE4 wants to merge 1 commit into
userver-framework:developfrom
SSE4:multipart-parse-failure-non-fatal
Open

SSE4 wants to merge 1 commit into
userver-framework:developfrom
SSE4:multipart-parse-failure-non-fatal

Conversation

@SSE4

@SSE4 SSE4 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

closes: #1359

userver parses the multipart/form-data body of every request whose
Content-Type says so, whether or not the handler ever reads form arguments,
and turns any parse failure into 400 invalid body of multipart/form-data request before the handler runs. There is no way to opt out.

With this change a parse failure is logged and the handler is handed an empty
form, so the handler decides what the response should be.

Why this is the framework's call to stop making

RFC 9110 §2.4: "Unless
noted otherwise, 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."

That is explicitly an application-level choice, and the request constructor is
not the application. Only the handler knows whether a missing or malformed form
is fatal to the operation being requested — a handler that never looks at form
arguments has no reason to fail at all.

And when 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 body that does not match its
declared media type is a content-format problem, and HTTP has
415 and
422 for exactly that.
So the status is wrong as well as misplaced — but picking 415 in the framework
would only make it wrong more precisely. The fix is to stop choosing.

The inconsistency this removes

The block immediately above the multipart one does the same category of work
with the opposite default:

step gated? failure
query + urlencoded args (:209-220) yes — per-handler parse_args_from_body throws → kParseArgsError
multipart (:222-231) no, always bool → kParseMultipartFormDataError

Two adjacent blocks, two conventions. This moves multipart to the more
conservative one: parsing never decides the response.

Why no config option

An earlier draft added a per-handler option selecting the behaviour, defaulting
to today's 400. That shape is wrong:

  • Correct behaviour should be the default. An opt-in flag means every service
    must discover and set an option just to stop the framework returning a wrong
    status on its behalf. Nobody goes looking for a flag to fix behaviour they
    assume is already right.
  • Options are expensive here. Measured against parse_args_from_body, one
    bool in this subsystem costs ~13 files: the struct field, the YAML→struct
    parse, a JSON-schema declaration for strict static validation, all duplicated
    across handler-defaults and the per-handler level, a copy into the
    per-request config, two positional aggregate initialisers, and the doc-sample
    config. That is a lot of surface to add speculatively.
  • The current behaviour is not contract (see below), so there is nothing to
    preserve compatibly.

If back-compat turns out to matter for a service someone can name, I'd rather add
an opt-out that restores the legacy 400, defaulting to the new behaviour,
than an opt-in to correctness — and add it during review, when the need is
concrete, rather than building the plumbing up front.

The current behaviour is not contract

kParseMultipartFormDataError exists in exactly three places — the enum value,
the one SetStatus call, and the one CheckStatus case that maps it to 400.
Nothing in the test suite asserts it: no unit test, no functional test, no
testsuite test. The string invalid body of multipart/form-data request appears
only at its single production site. samples/multipart_service's
test_bad_content_type asserts the handler's 400, not the framework's.

So the status becomes unreachable and this PR removes it.

How

 if (IsMultipartFormDataContentType(content_type)) {
     utils::impl::TransparentMap<...> form_data_args;
-    if (!ParseMultipartFormData(content_type, request.RequestBody(), form_data_args)) {
-        SetStatus(Status::kParseMultipartFormDataError);
-    } else {
+    if (ParseMultipartFormData(content_type, request.RequestBody(), form_data_args)) {
         builder_.SetFormDataArgs(std::move(form_data_args));
+    } else {
+        LOG_LIMITED_WARNING() << kMultipartParseFailureWarning;
     }
 }

Plus removal of the now-unreachable enum value and its CheckStatus case.

Three deliberate details:

  • SetFormDataArgs is not called on failure. The parser does fill in
    arguments before failing partway — ParseMultipartFormDataValue inserts per
    part and a later failure returns false without clearing, and the
    default-charset fixup only runs on the success path. "Empty form" is a semantic
    worth documenting; "arbitrary prefix of a form, with charsets possibly unset"
    is not.
  • LOG_LIMITED_WARNING, not LOG_WARNING. The trigger is
    attacker-controlled request content, so an unthrottled line per request is a
    log-flooding vector. The parser already logs the specific reason; this line adds
    the consequence.
  • Handlers needing form arguments are unaffected, safely.
    HttpRequestImpl::form_data_args is a default-constructed map and
    GetFormDataArg returns a static empty FormDataArg when the name is absent,
    so never calling SetFormDataArgs is safe by construction — the same state a
    plain GET already reaches the handler in.

Tests

Four functional tests. This behaviour is only observable at the HTTP level,
because the question is who chose the status.

New core/functional_tests/basic_chaos/tests-nonchaos/handlers/test_multipart_parse_failure.py,
against /chaos/httpserver?type=echo — a handler that echoes the body and never
touches form args:

  • test_malformed_multipart_body_reaches_handler — a garbage body with a
    multipart/form-data content type now gets the handler's own 200, body echoed.
  • test_empty_multipart_body_reaches_handler — same for a zero-length body.

samples/multipart_service/tests/test_multipart.py, against a handler that does
read form args:

  • test_malformed_body_is_rejected_by_the_handler — still 400, but the body is
    now the handler's Expecting PNG image format rather than the framework's
    invalid body of multipart/form-data request. Same status, different decider.
  • test_partially_parsed_body_exposes_no_args — first part parses cleanly
    (profileImage with the PNG magic bytes), second part is truncated. If partial
    args leaked, the handler would clear its PNG check and then 500 on
    formats::json::FromString("") for the missing address; asserting the 400
    pins that they do not.

All four fail without the production change and pass with it. No existing test
needed changing; all 7 basic-chaos suites and 2216 core unit tests stay green.

Relation to // TODO: split logic

The // TODO: split logic on :222 sits exactly on the boundary this PR moves.
cbb7230fc ("cc http: move out HttpRequestImplBuilder") moved cookie parsing
into HttpRequestBuilder::Build(), where ParseCookies() returns void and
cannot fail, and left query/urlencoded args and multipart behind in the
constructor with two different failure conventions.

This PR is a step in the direction the TODO points — parsing stops deciding the
response — but not the whole split. The larger refactor (parsers return results;
one place maps result → status; or interpretation moves to first access in
GetArg/GetFormDataArg) can follow independently, so I've left the TODO in
place.

One visible leftover I noticed but deliberately did not touch here:
kParseCookiesError is still declared and still has a live CheckStatus case
producing 400 invalid cookies, but nothing sets it — it went dead when cookie
parsing moved into the builder. Happy to send that as a separate one-line
cleanup; it didn't belong in a PR that changes a default.

userver parsed the multipart/form-data body of every request whose Content-Type
says so, whether or not the handler ever reads form arguments, and turned any
parse failure into `400 invalid body of multipart/form-data request` before the
handler ran. There was no way to opt out.

Both are wrong defaults. RFC 9110 2.4 leaves error handling for an invalid
construct to the application, and 15.5.1 scopes 400 to malformed request
syntax, invalid framing and deceptive routing -- a body that does not match its
declared media type is a content-format problem, which has 415 and 422. Only
the handler knows whether a missing or malformed form is fatal to the operation
being requested.

A parse failure is now logged and the handler is handed an empty form, so it
can answer with a status that describes its own contract. Arguments the parser
filled in before failing partway are dropped rather than exposed: "empty form"
is a semantic worth documenting, "arbitrary prefix of a form" is not.

Status::kParseMultipartFormDataError becomes unreachable and is removed. No
test asserted the framework's 400.

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.

malformed multipart/form-data body must not fail the whole request

1 participant