Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
20 changes: 16 additions & 4 deletions src/Falco/Request.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Microsoft.AspNetCore.Http.Features.IFormFeature>()
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)
Expand Down
54 changes: 42 additions & 12 deletions test/Falco.IntegrationTests.App/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<IGreeter, FriendlyGreeter>()
|> ignore
module Main =
[<EntryPoint>]
let main args =
let bldr = WebApplication.CreateBuilder(args)

let wapp = bldr.Build()
bldr.Services
.AddSingleton<IGreeter, FriendlyGreeter>()
|> 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
66 changes: 66 additions & 0 deletions test/Falco.IntegrationTests/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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() }

[<Fact>]
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)

[<Fact>]
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 [<EntryPoint>] main _ = 0
Loading