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..5bac50c 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,23 +66,50 @@ 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() +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 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