Skip to content

Fixed HTTP request body problems escaping as exceptions or echoing the body - #634

Open
xperiandri wants to merge 3 commits into
devfrom
http-body-hardening
Open

xperiandri wants to merge 3 commits into
devfrom
http-body-hardening

Conversation

@xperiandri

Copy link
Copy Markdown
Collaborator

Problem

The ASP.NET Core request handler turned request body problems into unhandled exceptions (a 500 under most hosts), or into a 400 that repeated the body back:

  • Unparsable multipart body: a missing or unused boundary, a truncated body or a malformed part header threw InvalidDataException or IOException out of HandleAsync.
  • Body too large: a body over Kestrel's MaxRequestBodySize threw BadHttpRequestException out of HandleAsync.
  • Missing operations field: a form request without it got a 400 that repeated the whole multipart body, files included.
  • Invalid JSON: the 400 for a body that is not valid JSON repeated the body in its received member.

Changes

  • 400: a body the form reader cannot parse gets 400.
  • 413:
    • a body over MaxRequestBodySize: every BadHttpRequestException gets the status code the server gives;
    • a body over a size limit of FormOptions: multipart body, headers length or count, value count, key or value length, and buffer.
  • Telling limits from malformed bodies: the form reader reports both with the same exception, so only messages that start with the prefix of a size limit get 413. A boundary parameter over its length limit, a delimiter line followed by other text, and a malformed part header, whose text the client controls, get 400.
  • Boundary too long for the reader's buffer: this needs a raised MultipartBoundaryLengthLimit. It now gets 400, instead of an ArgumentOutOfRangeException whose message repeats the boundary.
  • Missing operations field: the 400 names the missing field.
  • map field: it must be a JSON object of non-empty arrays of variables. paths, else 400. Its keys are not checked against the file fields, because the client of this library keys the map by index but names the file fields by upload name.
  • Breaking: the 400 for invalid JSON no longer has received, and every reason quoted from the deserializer, the form reader or the server is cut to 256 characters.

Tests

The tests are xUnit, in the new RequestBodyTests.fs and in RequestHandlerExtensibilityTests.fs:

  • multipart: a missing, unused, over-long or buffer-exceeding boundary; a truncated body; malformed part headers; a malformed delimiter line; a missing operations field; invalid maps;
  • size limits: form body, value count and buffer limits, and the server's limit;
  • echoing: no response repeats the body;
  • the client's multipart format is still executed.

Reverting the source makes them fail.

An independent review tried many more bodies in process and on a real Kestrel host. None gave a 500 or echoed the body or a file:

  • truncated, unbounded and over-limit bodies, chunked or not;
  • non-UTF-8 and BOM bodies;
  • every kind of JSON value as the body;
  • URL-encoded forms.

All 46 upload integration tests pass with the real client provider.

Verification:

  • Unit tests: the whole project passes, 801 tests with 5 skipped.
  • FAKE: the full pipeline passes, including the integration tests.

Left for later

These are not changed here:

  • Bodies buffered but never read: checkIfHasBody judges such a body by its length, which is 0 until it is read. A query sent after a middleware called EnableBuffering() without reading the body gets the introspection result instead of being executed.
  • Buffer limit: through HandleAsync, FormOptions.BufferBodyLengthLimit is never reached, because the handler buffers the body without a limit first.

🤖 Generated with Claude Code

xperiandri and others added 2 commits October 3, 2026 13:14
…e body

The ASP.NET Core request handler turned several request body problems into
unhandled exceptions (a 500 under most hosts) or into a 400 that repeated the
whole request body back to the client:

- A multipart body the form reader cannot parse (missing or unused boundary,
  truncated body, malformed part header) threw InvalidDataException or
  IOException out of HandleAsync. It is now answered with 400.
- A body over Kestrel's MaxRequestBodySize threw BadHttpRequestException out
  of HandleAsync. Every BadHttpRequestException raised while reading the body
  is now answered with the status code the server gives (413 for a body that
  is too large). A body over a FormOptions limit, which the form reader reports
  as an InvalidDataException reading "<what> limit <number> exceeded.", is
  answered with 413.
- A form request without an 'operations' field was answered with a 400 that
  repeated the whole multipart body, files included. It is now answered with a
  400 that names the missing field.
- The 'map' field was ignored. One that is not a JSON object of non-empty
  arrays of 'variables.' paths is now answered with 400. Its keys are not
  checked against the file fields, because the client of this library keys the
  map by index while it names the file fields by upload name.
- The 400 response to a body that is not valid JSON read the whole body back
  into its 'received' member. That member is removed, and every reason quoted
  from the deserializer, the form reader or the server is cut to 256
  characters, because such a reason can quote the body (the multipart reader
  repeats a malformed header line in its message).

Every body problem keeps the existing problem details response format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The form reader reports both a broken FormOptions limit and a malformed body
with InvalidDataException, and any message containing " limit " and ending
with " exceeded." was answered with 413. That also matched an over-long
boundary parameter, a delimiter line followed by other text and a malformed
part header, whose text the client controls.

- Only the messages starting with the prefix of a size limit of FormOptions
  (multipart body, headers length or count, form value count, key or value
  length) are answered with 413; every other form reader problem with 400.
- A body over FormOptions.BufferBodyLengthLimit, reported as an IOException
  "Buffer limit exceeded.", is answered with 413 instead of being described
  as a truncated multipart body.
- A boundary too long for the buffer of the multipart reader, which only a
  raised MultipartBoundaryLengthLimit lets through, is answered with 400
  instead of escaping as ArgumentOutOfRangeException, whose message repeats
  the boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A valid overlong multipart header is incorrectly classified as 400 rather than the documented 413.

3 open findings
What changed in this PR

Improves ASP.NET Core request-body error handling to return safe 400/413 problem responses without echoing entire bodies.

Changes:

  • Handles malformed, oversized, and invalid JSON/form bodies.
  • Validates multipart operations and upload maps.
  • Adds extensive request-body regression tests and release notes.
File Description
RELEASE_NOTES.md Documents breaking response changes.
GraphQLRequestHandler.fs Handles server body-read failures.
HttpContext.fs Centralizes body parsing, validation, and errors.
FSharp.Data.GraphQL.Tests.fsproj Includes the new test file.
RequestBodyTests.fs Tests malformed and oversized bodies.
RequestHandlerExtensibilityTests.fs Exposes reusable handler test setup.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

/// </remarks>
let private sizeLimitMessagePrefixes = [|
"Multipart body length limit "
"Multipart headers length limit "
Comment on lines +198 to +200
response
|> assertProblem StatusCodes.Status400BadRequest "Invalid multipart request"
response |> assertDoesNotEcho Marker
Comment on lines +423 to +424
response |> assertDoesNotEcho longHeaderLine
Assert.Contains (Marker, response.Body, StringComparison.Ordinal)
… tests

The tests take media types and header names from `MediaTypeNames` and
`HeaderNames`, and the form field names and problem titles from the literals
of `RequestBody`, instead of spelling them out again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Test Results

    9 files      9 suites   14m 38s ⏱️
  909 tests   904 ✅  5 💤 0 ❌
2 727 runs  2 712 ✅ 15 💤 0 ❌

Results for commit 8d6be80.

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.

2 participants