From 2a36f1d2a8e8f17a8669b68dc19944c0953f1a67 Mon Sep 17 00:00:00 2001 From: Michael Glass Date: Sun, 19 Apr 2026 10:48:16 +0200 Subject: [PATCH 1/2] fix: reuse cached form in consumeForm to fix multipart+antiforgery re-read bug When antiforgery validation runs before Request.getForm (e.g. via getFormOptions / mapForm / validateCsrfToken), IAntiforgery.ValidateRequestAsync reads and buffers the form body via ReadFormAsync. For multipart/form-data POSTs, consumeForm would then branch into StreamFormAsync, which re-reads the (now-drained) body and fails with 'Unexpected end of Stream'. Detect that the form has already been parsed by checking the request's IFormFeature and reuse the cached ctx.Request.Form in that case, instead of attempting to re-read the stream. --- src/Falco/Request.fs | 20 +++++-- test/Falco.IntegrationTests.App/Program.fs | 27 +++++++++ test/Falco.IntegrationTests/Program.fs | 66 ++++++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/Falco/Request.fs b/src/Falco/Request.fs index 7e769f1..dbb8736 100644 --- a/src/Falco/Request.fs +++ b/src/Falco/Request.fs @@ -93,15 +93,27 @@ let getQuery (ctx : HttpContext) : RequestData = RequestValue.parseQuery ctx.Request.Query |> RequestData +/// If ASP.NET's form pipeline has already parsed and cached the form (e.g. +/// antiforgery validation pre-read the body via ReadFormAsync), return it. +/// Needed because the multipart `StreamFormAsync` path in `consumeForm` can +/// only be taken once per request — a second read fails with +/// "Unexpected end of Stream". +let private tryGetCachedForm (ctx : HttpContext) : IFormCollection option = + if not ctx.Request.HasFormContentType then None + else + let feature = ctx.Features.Get() + if isNull feature || isNull feature.Form then None + else Some feature.Form + let private consumeForm (maxSize : int64) (ctx : HttpContext) = use tokenSource = new CancellationTokenSource() task { let! form = - if ctx.Request.IsMultipart() then - ctx.Request.StreamFormAsync (tokenSource.Token, maxSize) - else - ctx.Request.ReadFormAsync tokenSource.Token + match tryGetCachedForm ctx with + | Some cached -> Task.FromResult cached + | None when ctx.Request.IsMultipart() -> ctx.Request.StreamFormAsync (tokenSource.Token, maxSize) + | None -> ctx.Request.ReadFormAsync tokenSource.Token let files = if isNull form.Files then None else Some form.Files let requestValue = RequestValue.parseForm (form, Some ctx.Request.RouteValues) diff --git a/test/Falco.IntegrationTests.App/Program.fs b/test/Falco.IntegrationTests.App/Program.fs index f4b1d6d..392f49f 100644 --- a/test/Falco.IntegrationTests.App/Program.fs +++ b/test/Falco.IntegrationTests.App/Program.fs @@ -3,7 +3,10 @@ module Falco.IntegrationTests.App open Falco open Falco.Markup open Falco.Routing +open Falco.Security +open Microsoft.AspNetCore.Antiforgery open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Http open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Hosting @@ -63,6 +66,30 @@ let endpoints = post "/api/message" (Request.mapJson Response.ofJson) + + // Endpoint that emits an antiforgery token (for tests) + get "/csrf-token" (fun ctx -> + let token = Xsrf.getToken ctx + Response.ofJson + {| FormFieldName = token.FormFieldName + RequestToken = token.RequestToken |} + ctx) + + // Endpoint that consumes a form while antiforgery is enabled. This + // exercises Request.getForm, which validates the CSRF token and then + // reads the form. For multipart requests with a valid token, the + // antiforgery validation pre-reads the body; previously, consumeForm + // would then call StreamFormAsync again and fail with + // "Unexpected end of Stream". + post "/form-with-csrf" (fun ctx -> task { + let! form = Request.getForm ctx + match form with + | Some f -> + let name = f.Get("name").AsStringNonEmpty("") + return! Response.ofJson {| Message = $"Hello {name}!" |} ctx + | None -> + ctx.Response.StatusCode <- 400 + return! Response.ofPlainText "invalid" ctx }) ] let bldr = WebApplication.CreateBuilder() diff --git a/test/Falco.IntegrationTests/Program.fs b/test/Falco.IntegrationTests/Program.fs index dd57db2..10bcbca 100644 --- a/test/Falco.IntegrationTests/Program.fs +++ b/test/Falco.IntegrationTests/Program.fs @@ -157,4 +157,70 @@ module Tests = let ct = response.Content.Headers.ContentType.ToString() Assert.Contains("text/plain", ct) +module AntiforgeryMultipartTests = + open System.Net + open Microsoft.AspNetCore.Hosting + open Microsoft.AspNetCore.TestHost + open Microsoft.Extensions.DependencyInjection + + // Build a factory that registers antiforgery services on top of the + // integration test app. The antiforgery service is not registered by the + // base app so as not to affect other tests. + let private factory = + let baseFactory = FalcoOpenApiTestServer.createFactory () + baseFactory.WithWebHostBuilder(fun b -> + b.ConfigureTestServices(fun services -> + services.AddAntiforgery() |> ignore) + |> ignore) + + type private CsrfToken = { FormFieldName : string; RequestToken : string } + + // The TestServer HttpClient has a default CookieContainer that replays + // Set-Cookie across requests — no manual cookie forwarding needed. + let private getCsrfToken (client : HttpClient) : CsrfToken = + let response = client.GetAsync("/csrf-token").Result + response.EnsureSuccessStatusCode() |> ignore + let body = response.Content.ReadAsStringAsync().Result + use doc = JsonDocument.Parse(body) + let root = doc.RootElement + { FormFieldName = root.GetProperty("FormFieldName").GetString() + RequestToken = root.GetProperty("RequestToken").GetString() } + + [] + let ``POST multipart/form-data with valid CSRF token succeeds via getForm`` () = + use client = factory.CreateClient () + let token = getCsrfToken client + + use content = new MultipartFormDataContent() + content.Add(new StringContent("Alice"), "name") + content.Add(new StringContent(token.RequestToken), token.FormFieldName) + + let response = client.PostAsync("/form-with-csrf", content).Result + let body = response.Content.ReadAsStringAsync().Result + + // Before the fix: antiforgery validation pre-reads the multipart body via + // ReadFormAsync, then consumeForm calls StreamFormAsync which fails with + // "Unexpected end of Stream", surfacing as a 500. + Assert.Equal(HttpStatusCode.OK, response.StatusCode) + Assert.Equal("""{"Message":"Hello Alice!"}""", body) + + [] + let ``POST urlencoded form with valid CSRF token succeeds via getForm`` () = + // Regression guard for the non-multipart path, which was already working. + use client = factory.CreateClient () + let token = getCsrfToken client + + let form = + new FormUrlEncodedContent( + dict [ + ("name", "Bob") + (token.FormFieldName, token.RequestToken) + ]) + + let response = client.PostAsync("/form-with-csrf", form).Result + let body = response.Content.ReadAsStringAsync().Result + + Assert.Equal(HttpStatusCode.OK, response.StatusCode) + Assert.Equal("""{"Message":"Hello Bob!"}""", body) + module Program = let [] main _ = 0 From b6618caf0802f7e28133b094c14bac16f4d71315 Mon Sep 17 00:00:00 2001 From: Michael Glass Date: Fri, 24 Apr 2026 11:29:33 +0200 Subject: [PATCH 2/2] fix(tests): use explicit entry point in integration test app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level module statements (`let bldr = CreateBuilder()` / `let wapp = bldr.Build()` / `wapp.Run()`) compile into one-shot static initialization. When a second WebApplicationFactory (e.g. the derived factory in AntiforgeryMultipartTests via WithWebHostBuilder) re-invokes the entry point to spin up its own test host, DeferredHostBuilder.Build() runs against the already-built builder and throws "InvalidOperationException: Build can only be called once" — reliably on CI's parallel collections, intermittently elsewhere. Move construction into an explicit [] main so each factory invocation gets a fresh builder and WebApplication. Co-Authored-By: Claude Opus 4.7 --- test/Falco.IntegrationTests.App/Program.fs | 27 ++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/test/Falco.IntegrationTests.App/Program.fs b/test/Falco.IntegrationTests.App/Program.fs index 392f49f..5bac50c 100644 --- a/test/Falco.IntegrationTests.App/Program.fs +++ b/test/Falco.IntegrationTests.App/Program.fs @@ -92,21 +92,24 @@ let endpoints = return! Response.ofPlainText "invalid" ctx }) ] -let bldr = WebApplication.CreateBuilder() +type Program() = class end -bldr.Services - .AddSingleton() -|> ignore +module Main = + [] + let main args = + let bldr = WebApplication.CreateBuilder(args) -let wapp = bldr.Build() + bldr.Services + .AddSingleton() + |> ignore -wapp.UseHttpsRedirection() -|> ignore + let wapp = bldr.Build() -wapp.UseRouting() - .UseFalco(endpoints) -|> ignore + wapp.UseHttpsRedirection() |> ignore -wapp.Run() + wapp.UseRouting() + .UseFalco(endpoints) + |> ignore -type Program() = class end + wapp.Run() + 0