Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fixed form body problems that are no size limit being answered with 413
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>
  • Loading branch information
xperiandri and claude committed Oct 10, 2026
commit aa9ed74ea41278da854ba3dc7b1b24bcd22192ed
2 changes: 1 addition & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@
* Added cancellation of a streamed `Define.TaskSeqField` enumeration when the client unsubscribes, and delivery of a failure raised acquiring the sequence's enumerator, while enumerating, or disposing it, as a deferred error for the field, after every item already pulled has been resolved and delivered, so a slower item can never be overtaken by the error that follows it; an item resolution that throws stops the enumeration and is delivered the same way, while an item whose own fields fail is delivered as that item's deferred errors and streaming continues, exactly as for `@stream` on an ordinary list; a concurrency slot is never leaked even if delivering an item's result fails
* Added `maxConcurrency` to `Define.TaskSeqField`, bounding how many items of a streamed sequence are pulled and resolved at the same time; defaults to `Environment.ProcessorCount`
* **Breaking Change** Removed the request body from the 400 problem details response that `FSharp.Data.GraphQL.Server.AspNetCore` gives to a body that is not a JSON GraphQL request: the `received` member that repeated the whole body is gone, and `detail` quotes at most 256 characters of the deserializer's reason, the same cap that applies to every reason quoted from the form reader or the server
* **Breaking Change** Fixed `FSharp.Data.GraphQL.Server.AspNetCore` letting request body problems escape as unhandled exceptions: a multipart body the form reader cannot parse (missing or unused boundary, truncated body, malformed part header) is now answered with 400, and a body over Kestrel's `MaxRequestBodySize` or a `FormOptions` limit with 413, as problem details. A form request without an `operations` field is now answered with a 400 that names the missing field instead of one that repeated the whole body, files included, and a form request whose `map` field is not a JSON object of non-empty arrays of `variables.` paths is now answered with 400 instead of being executed
* **Breaking Change** Fixed `FSharp.Data.GraphQL.Server.AspNetCore` letting request body problems escape as unhandled exceptions: a multipart body the form reader cannot parse (missing, unused or too long boundary, truncated body, malformed part header) is now answered with 400, and a body over Kestrel's `MaxRequestBodySize` or a size limit of `FormOptions` (body, buffer, header, key, value or value count) with 413, as problem details. A form request without an `operations` field is now answered with a 400 that names the missing field instead of one that repeated the whole body, files included, and a form request whose `map` field is not a JSON object of non-empty arrays of `variables.` paths is now answered with 400 instead of being executed
* Added `StreamBatching` to group streamed items of a `Define.TaskSeqField` into batches of a fixed size or of a size computed from the sequence, such as a page size kept with a paged SDK sequence. The `preferredBatchSize` argument of `@stream` takes precedence, and the batching function itself is evaluated lazily, only for a `@stream` query that does not supply its own `preferredBatchSize`
* Added `Microsoft.Bcl.AsyncInterfaces` dependency of `FSharp.Data.GraphQL.Shared` for `netstandard2.0`
* Added `Human.friendsStream` field to the Star Wars sample to demonstrate `@stream`
Expand Down
68 changes: 57 additions & 11 deletions src/FSharp.Data.GraphQL.Server.AspNetCore/HttpContext.fs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ module internal RequestBody =
[<Literal>]
let private VariablesPathPrefix = "variables."

/// Cuts a reason that may quote the request body to at most MaxQuotedReasonLength characters
/// <summary>Cuts a reason that may quote the request body to at most <see cref="MaxQuotedReasonLength"/> characters</summary>
let quote (reason : string) =
if reason.Length <= MaxQuotedReasonLength then
reason
Expand Down Expand Up @@ -86,8 +86,11 @@ module internal RequestBody =
extensions = extensions
)

/// <summary>
/// The response to the exception a server throws from the body stream with the status code to answer with:
/// Kestrel answers a body over MaxRequestBodySize with 413, and a body that ends before its Content-Length with 400
/// Kestrel answers a body over <see cref="Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerLimits.MaxRequestBodySize"/>
/// with 413, and a body that ends before its Content-Length with 400
/// </summary>
let ofBadHttpRequest (request : HttpRequest) (ex : BadHttpRequestException) : IResult =
let title =
if ex.StatusCode = StatusCodes.Status413PayloadTooLarge then
Expand All @@ -97,30 +100,61 @@ module internal RequestBody =
problem request ex.StatusCode title (quote ex.Message)

/// <summary>
/// Tells whether the form reader rejected the body because it breaks a <see cref="Microsoft.AspNetCore.Http.Features.FormOptions"/> limit.
/// The beginnings of the messages with which the form reader reports a body over a size limit of
/// <see cref="Microsoft.AspNetCore.Http.Features.FormOptions"/>; each goes on with the limit and ends with
/// <c>" exceeded."</c>.
/// </summary>
/// <remarks>
/// <para>
/// The form reader reports a broken limit with <see cref="InvalidDataException"/>, the same type it reports a malformed
/// body with, so only the message tells them apart. Every limit message of ASP.NET Core reads
/// <c>"&lt;what&gt; limit &lt;number&gt; exceeded."</c>; a message of another shape is answered as a malformed body.
/// body with, so only the message tells them apart. ASP.NET Core does not localize these messages.
/// </para>
/// <para>
/// Only the limits on the size of the body are listed. The other limit messages are about a malformed request:
/// <c>Multipart boundary length limit</c> is on a parameter of the <c>Content-Type</c> header, <c>Line length limit</c>
/// is also reported for a delimiter line followed by other text, and <c>Multipart header length limit</c> for a body
/// that does not start with its boundary. The messages that quote the body, such as the one of a malformed part
/// header, never start with one of these.
/// </para>
/// </remarks>
let private isFormLimitExceeded (ex : InvalidDataException) =
let message = ex.Message.AsSpan ()
message.Contains (" limit ", StringComparison.Ordinal)
&& message.EndsWith (" exceeded.", StringComparison.Ordinal)
let private sizeLimitMessagePrefixes = [|
"Multipart body length limit "
"Multipart headers length limit "
"Multipart headers count limit "
"Form value count limit "
"Form key length limit "
"Form value length limit "
"Form key or value length limit "
|]

/// Tells whether the form reader rejected the body because it breaks a size limit of the form options
let private isFormSizeLimitExceeded (ex : InvalidDataException) =
let message = ex.Message
message.EndsWith (" exceeded.", StringComparison.Ordinal)
&& sizeLimitMessagePrefixes
|> Array.exists (fun prefix -> message.StartsWith (prefix, StringComparison.Ordinal))

/// <summary>
/// The message of the exception the buffer of a request body throws once the body grows over
/// <see cref="Microsoft.AspNetCore.Http.Features.FormOptions.BufferBodyLengthLimit"/>
/// </summary>
[<Literal>]
let private BufferLimitExceededMessage = "Buffer limit exceeded."

/// Reads the form of a multipart or URL-encoded request, answering a body the form reader cannot parse
/// with 400 and a body over a server or form limit with 413
/// with 400 and a body over a server or form size limit with 413
let readFormAsync (cancellationToken : CancellationToken) (request : HttpRequest) : Task<Result<IFormCollection, IResult>> = task {
try
let! form = request.ReadFormAsync cancellationToken
return Ok form
with
// BadHttpRequestException derives from IOException, so it must be matched first
| :? BadHttpRequestException as ex -> return Error (ofBadHttpRequest request ex)
| :? InvalidDataException as ex when isFormLimitExceeded ex ->
| :? InvalidDataException as ex when isFormSizeLimitExceeded ex ->
return Error (problem request StatusCodes.Status413PayloadTooLarge BodyTooLargeTitle (quote ex.Message))
| :? InvalidDataException as ex -> return Error (problem request StatusCodes.Status400BadRequest UnreadableBodyTitle (quote ex.Message))
| :? IOException as ex when String.Equals (ex.Message, BufferLimitExceededMessage, StringComparison.Ordinal) ->
return Error (problem request StatusCodes.Status413PayloadTooLarge BodyTooLargeTitle BufferLimitExceededMessage)
| :? IOException ->
// The multipart reader throws IOException when the body ends before the closing boundary,
// which is also what a boundary the body does not use looks like
Expand All @@ -132,6 +166,18 @@ module internal RequestBody =
UnreadableBodyTitle
"The multipart body ends before its closing boundary, or its parts are not delimited by the boundary its Content-Type declares."
)
| :? ArgumentOutOfRangeException ->
// The multipart reader throws it for a boundary too long for its buffer, which only a
// FormOptions.MultipartBoundaryLengthLimit raised above the buffer size lets through; its message repeats
// the boundary, so it is not quoted
return
Error (
problem
request
StatusCodes.Status400BadRequest
UnreadableBodyTitle
"The multipart boundary its Content-Type declares is too long."
)
}

/// A map path names a variable of the single operation a request carries, such as variables.file or variables.files.0
Expand Down
139 changes: 123 additions & 16 deletions tests/FSharp.Data.GraphQL.Tests/AspNetCore/RequestBodyTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ let private clientStyleParts map = [
/// What a result wrote into the HTTP response
type private WrittenResponse = { StatusCode : int; ContentType : string; Body : string }

/// A request body that the server refuses to read, as Kestrel's body stream does once a read
/// breaks a server limit such as MaxRequestBodySize or the connection ends too early
/// <summary>
/// A request body that the server refuses to read, as Kestrel's body stream does once a read breaks a server limit such as
/// <see cref="Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerLimits.MaxRequestBodySize"/> or the connection ends too early
/// </summary>
type private RejectingRequestBody (error : exn) =
inherit Stream ()

Expand All @@ -78,8 +80,45 @@ type private RejectingRequestBody (error : exn) =
override _.SetLength (_ : int64) = raise (NotSupportedException ())
override _.Write (_ : byte[], _ : int, _ : int) = raise (NotSupportedException ())

/// A request body that can only be read forward, as the body stream of Kestrel, so that the form reader buffers it
type private ForwardOnlyRequestBody (content : byte array) =
inherit Stream ()

let inner = new MemoryStream (content)

override _.CanRead = true
override _.CanSeek = false
override _.CanWrite = false
override _.Length = raise (NotSupportedException ())

override _.Position
with get () = raise (NotSupportedException ())
and set _ = raise (NotSupportedException ())

override _.Flush () = ()
override _.Read (buffer : byte[], offset : int, count : int) : int = inner.Read (buffer, offset, count)
override _.Seek (_ : int64, _ : SeekOrigin) : int64 = raise (NotSupportedException ())
override _.SetLength (_ : int64) = raise (NotSupportedException ())
override _.Write (_ : byte[], _ : int, _ : int) = raise (NotSupportedException ())

let private bodyStream (body : string) = new MemoryStream (Encoding.UTF8.GetBytes body) :> Stream

/// Writes the result a request is answered with into the response of the request, so that the test can inspect it
let private writeAsync (ctx : HttpContext) (result : IResult) : Task<WrittenResponse> = task {
let responseBody = new MemoryStream ()
ctx.Response.Body <- responseBody
do! result.ExecuteAsync ctx

return {
StatusCode = ctx.Response.StatusCode
ContentType =
match ctx.Response.ContentType with
| null -> ""
| contentType -> contentType
Body = Encoding.UTF8.GetString (responseBody.ToArray ())
}
}

/// Sends a POST request with the given body through the default handler and writes the result it
/// answers with, success or error, into a response the test can inspect.
let private postAsync (contentType : string) (body : Stream) (configureRequest : HttpContext -> unit) : Task<WrittenResponse> = task {
Expand Down Expand Up @@ -107,18 +146,7 @@ let private postAsync (contentType : string) (body : Stream) (configureRequest :
| Ok result
| Error result -> result

let responseBody = new MemoryStream ()
ctx.Response.Body <- responseBody
do! result.ExecuteAsync ctx

return {
StatusCode = ctx.Response.StatusCode
ContentType =
match ctx.Response.ContentType with
| null -> ""
| contentType -> contentType
Body = Encoding.UTF8.GetString (responseBody.ToArray ())
}
return! writeAsync ctx result
}

let private postTextAsync contentType (body : string) = postAsync contentType (bodyStream body) ignore
Expand Down Expand Up @@ -157,7 +185,7 @@ let ``Multipart request that follows the client format is executed`` () : Task =
let! response = postTextAsync MultipartContentType body

assertStatus StatusCodes.Status200OK response
Assert.Contains ("\"1000\"", response.Body)
Assert.Contains ("\"1000\"", response.Body, StringComparison.Ordinal)
}

[<Fact>]
Expand Down Expand Up @@ -260,6 +288,85 @@ let ``Multipart request over the form value count limit is answered with 413`` (
|> assertProblem StatusCodes.Status413PayloadTooLarge "Request body too large"
}

/// A multipart body of the operations alone, delimited by the given boundary
let private operationsBodyWith (boundary : string) =
$"--%s{boundary}\r\nContent-Disposition: form-data; name=\"operations\"\r\n\r\n%s{ValidOperations}\r\n--%s{boundary}--\r\n"

[<Fact>]
let ``Multipart request with a boundary over the form boundary length limit is answered with 400`` () : Task = task {
// The limit is on a parameter of the Content-Type header, not on the size of the body
let boundary = String ('b', 200)

let! response = postTextAsync $"multipart/form-data; boundary=%s{boundary}" (operationsBodyWith boundary)

response
|> assertProblem StatusCodes.Status400BadRequest "Unreadable request body"
}

[<Fact>]
let ``Multipart request with a boundary longer than the buffer of the form reader is answered with 400`` () : Task = task {
// A boundary length limit raised above the buffer of the multipart reader lets through a boundary that the reader
// then refuses with an exception repeating the boundary
let boundary = String.replicate 300 Marker

let! response =
postAsync $"multipart/form-data; boundary=%s{boundary}" (bodyStream (operationsBodyWith boundary)) (fun ctx ->
(ctx :?> DefaultHttpContext).FormOptions <- FormOptions (MultipartBoundaryLengthLimit = 10_000))

response
|> assertProblem StatusCodes.Status400BadRequest "Unreadable request body"
response |> assertDoesNotEcho Marker
}

[<Fact>]
let ``Multipart request with text after a delimiter on its line is answered with 400`` () : Task = task {
// The form reader limits the rest of a delimiter line to 100 characters, which is no limit on the size of the body
let body =
String.Concat (formPart "operations" ValidOperations, $"--%s{Boundary}%s{String ('x', 200)}\r\n", closingBoundary)

let! response = postTextAsync MultipartContentType body

response
|> assertProblem StatusCodes.Status400BadRequest "Unreadable request body"
}

[<Fact>]
let ``Multipart request with a malformed part header reading like a limit message is answered with 400`` () : Task = task {
// The form reader quotes a malformed header line, so text of the client must not decide the status code
let body =
multipartBody [
formPart "operations" ValidOperations
$"--%s{Boundary}\r\nx limit 1 exceeded.\r\n\r\ncontent\r\n"
]

let! response = postTextAsync MultipartContentType body

response
|> assertProblem StatusCodes.Status400BadRequest "Unreadable request body"
}

[<Fact>]
let ``Multipart request over the form buffer limit is answered with 413 when bound directly`` () : Task = task {
// The request handler buffers a body without a limit before it binds it, so the buffer limit of the form options
// only applies when an application binds the body itself
let body = multipartBody (clientStyleParts """{"0":["variables.file"]}""")
let _, _, ctx, scope =
createHandlerFor<DefaultGraphQLRequestHandler<Root>>(fun ctx ->
ctx.Request.Method <- HttpMethods.Post
ctx.Request.ContentType <- MultipartContentType
ctx.Request.Body <- new ForwardOnlyRequestBody (Encoding.UTF8.GetBytes body)
(ctx :?> DefaultHttpContext).FormOptions <- FormOptions (BufferBody = true, BufferBodyLengthLimit = 100L))
use _ = scope

match! ctx.TryBindJsonAsync<JsonElement> "{}" with
| Ok _ -> fail "Expected the body over the buffer limit not to be bound, but it was"
| Error result ->
let! response = writeAsync ctx result
response
|> assertProblem StatusCodes.Status413PayloadTooLarge "Request body too large"
response |> assertDoesNotEcho Marker
}

[<Theory>]
[<InlineData(JsonContentType)>]
[<InlineData(MultipartContentType)>]
Expand Down Expand Up @@ -314,7 +421,7 @@ let ``Multipart request with a malformed part header quotes at most a short exce
response
|> assertProblem StatusCodes.Status400BadRequest "Unreadable request body"
response |> assertDoesNotEcho longHeaderLine
Assert.Contains (Marker, response.Body)
Assert.Contains (Marker, response.Body, StringComparison.Ordinal)
Comment on lines +423 to +424
}

[<Theory>]
Expand Down
Loading