Skip to content

Commit

Permalink
feat: add request limits on accept, content-type, and content-length …
Browse files Browse the repository at this point in the history
…headers (#502)

* feat: add request limits on accept, content-type, and content-length  headers

* Update DOCUMENTATION.md

Co-authored-by: Vinícius Gajo <[email protected]>

* refac: change the name of the helper middlewares, add more unit tests, change their implementation to accept OptionalErrorHandlers so the user can configure the response, and update DOCUMENTATION due to the changes

* fantomas lint

---------

Co-authored-by: Vinícius Gajo <[email protected]>
Co-authored-by: 64J0 <[email protected]>
  • Loading branch information
3 people authored Sep 6, 2024
1 parent 1edaf3f commit 54f38a4
Show file tree
Hide file tree
Showing 5 changed files with 718 additions and 0 deletions.
69 changes: 69 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ An in depth functional reference to all of Giraffe's default features.
- [File Uploads](#file-uploads)
- [Authentication and Authorization](#authentication-and-authorization)
- [Conditional Requests](#conditional-requests)
- [Request Limitation](#request-limitation)
- [Response Writing](#response-writing)
- [Content Negotiation](#content-negotiation)
- [Streaming](#streaming)
Expand Down Expand Up @@ -2377,6 +2378,74 @@ let webApp =
]
```

### Request Limitation

With this feature, we can add guards or limitations to the kind of requests that reach the server. Requests with a certain value for the `Accept`, `Content-Type` or `Content-Length` headers can be checked for acceptable values and a configurable user-friendly error message is send back to the consumer automatically when the conditions are not met.

In order to configure this response, you must use a [record](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/records) type named `OptionalErrorHandlers`:

```fsharp
// the type definition
type OptionalErrorHandlers =
{ InvalidHeaderValue: HttpHandler option
HeaderNotFound: HttpHandler option }
// to use the default handlers
let optionalErrorHandlers =
{ InvalidHeaderValue = None; HeaderNotFound = None }
```

As shown at the previous code block, you can simply use `None` for the record and use our default handlers, which will change the response status code to 406 (not acceptable), and return a piece of text to the client explaining what happened.

For now, the helper middlewares we offer are:

**Accept**
Guards http request based on its `Accept` header:

```fsharp
// Only allow http requests with an `Accept` header equals `application/json`.
let webApp =
GET >=> mustAcceptAny [ "application/json" ] optionalErrorHandlers >=> text "Hello World"
// Http request with `Accept` = `application/json` -> Pass through
// Http request without `Accept` = `application/json -> Error status code 406.
// If you define your custom error handler, we use them, otherwise will return one of the following text messages:
// 1) Request rejected because 'Accept' header was not found
// 2) Request rejected because 'Accept' header hasn't got expected MIME type
```

**Content-Type**
Guards http request based on its `Content-Type` header:

```fsharp
// Only allow http request with a `Content-Type` header `equals `application/json`.
let webApp =
GET >=> hasAnyContentTypes "application/json" optionalErrorHandlers >=> text "Hello World"
// Http request with `Content-Type` = `application/json` -> Pass through
// Http request without `Content-Type` = `application/json` -> Error status code 406.
// If you define your custom error handler, we use them, otherwise will return one of the following text messages:
// 1) Request rejected because 'Content-Type' header was not found
// 2) Request rejected because 'Content-Type' header hasn't got expected value
```

* Note: with `hasAnyContentTypes` multiple `Content-Type` headers can be passed to verify if the http request has any of the provided header values.

**Content-Length**
Guards http request based on its `Content-Length` header:

```fsharp
// Only allow http request with a `Content-Length` header less than or equal than provided maximum bytes.
let webApp =
GET >=> maxContentLength 100L >=> text "Hello World"
// Http request with `Content-Length` = `45` -> Pass through
// Http request without `Content-Length` = `3042` -> Error status code 406.
// If you define your custom error handler, we use them, otherwise will return one of the following text messages:
// 1) Request rejected because there is no 'Content-Length' header
// 2) Request rejected because 'Content-Length' header is too large
```

### Response Writing

Sending a response back to a client in Giraffe can be done through a small range of `HttpContext` extension methods and their equivalent `HttpHandler` functions.
Expand Down
1 change: 1 addition & 0 deletions src/Giraffe/Giraffe.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
<Compile Include="Streaming.fs" />
<Compile Include="Negotiation.fs" />
<Compile Include="HttpStatusCodeHandlers.fs" />
<Compile Include="RequestLimitation.fs" />
<Compile Include="Middleware.fs" />
<Compile Include="EndpointRouting.fs" />
</ItemGroup>
Expand Down
124 changes: 124 additions & 0 deletions src/Giraffe/RequestLimitation.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
[<AutoOpen>]
module Giraffe.RequestLimitation

open System
open Microsoft.AspNetCore.Http

/// <summary>
/// Use this record to specify your custom error handlers. If you use the Option.None value, we'll use the default
/// handlers that changes the status code to 406 (not acceptable) and responds with a piece of text.
/// </summary>
type OptionalErrorHandlers =
{
InvalidHeaderValue: HttpHandler option
HeaderNotFound: HttpHandler option
}

/// <summary>
/// Filters an incoming HTTP request based on the accepted mime types of the client (Accept HTTP header).
/// If the client doesn't accept any of the provided mimeTypes then the handler will not continue executing the next <see cref="HttpHandler"/> function.
/// </summary>
/// <param name="mimeTypes">List of mime types of which the client has to accept at least one.</param>
/// <param name="optionalErrorHandler">OptionalErrorHandlers record with HttpHandler options to define the server
/// response either if the header does not exist or has an invalid value. If both are `Option.None`, we use default
/// handlers.</param>
/// <param name="next"></param>
/// <param name="ctx"></param>
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
let mustAcceptAny (mimeTypes: string list) (optionalErrorHandler: OptionalErrorHandlers) : HttpHandler =
fun (next: HttpFunc) (ctx: HttpContext) ->
let headers = ctx.Request.GetTypedHeaders()

let headerNotFoundHandler =
optionalErrorHandler.HeaderNotFound
|> Option.defaultValue (
RequestErrors.notAcceptable (text "Request rejected because 'Accept' header was not found")
)

let invalidHeaderValueHandler =
optionalErrorHandler.InvalidHeaderValue
|> Option.defaultValue (
RequestErrors.notAcceptable (
text "Request rejected because 'Accept' header hasn't got expected MIME type"
)
)

match Option.ofObj (headers.Accept :> _ seq) with
| Some xs when Seq.map (_.ToString()) xs |> Seq.exists (fun x -> Seq.contains x mimeTypes) -> next ctx
| Some xs when Seq.isEmpty xs -> headerNotFoundHandler earlyReturn ctx
| Some _ -> invalidHeaderValueHandler earlyReturn ctx
| None -> headerNotFoundHandler earlyReturn ctx

/// <summary>
/// Limits to only requests with one of the specified `Content-Type` headers,
/// returning `406 NotAcceptable` when the request header doesn't exists in the set of specified types.
/// </summary>
/// <param name="contentTypes">The sequence of accepted content types.</param>
/// <param name="optionalErrorHandler">OptionalErrorHandlers record with HttpHandler options to define the server
/// response either if the header does not exist or has an invalid value. If both are `Option.None`, we use default
/// handlers.</param>
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
let hasAnyContentTypes (contentTypes: string list) (optionalErrorHandler: OptionalErrorHandlers) =
fun (next: HttpFunc) (ctx: HttpContext) ->
let headerNotFoundHandler =
optionalErrorHandler.HeaderNotFound
|> Option.defaultValue (
RequestErrors.notAcceptable (text "Request rejected because 'Content-Type' header was not found")
)

let invalidHeaderValueHandler =
optionalErrorHandler.InvalidHeaderValue
|> Option.defaultValue (
RequestErrors.notAcceptable (
text "Request rejected because 'Content-Type' header hasn't got expected value"
)
)

match Option.ofObj ctx.Request.ContentType with
| Some header when Seq.contains header contentTypes -> next ctx
| Some header when String.IsNullOrEmpty header -> headerNotFoundHandler earlyReturn ctx
| Some _ -> invalidHeaderValueHandler earlyReturn ctx
| None -> headerNotFoundHandler earlyReturn ctx


/// <summary>
/// Limits to only requests with a specific `Content-Type` header,
/// returning `406 NotAcceptable` when the request header value doesn't match the specified type.
/// </summary>
/// <param name="contentType">The single accepted content type.</param>
/// <param name="optionalErrorHandler">OptionalErrorHandlers record with HttpHandler options to define the server
/// response either if the header does not exist or has an invalid value. If both are `Option.None`, we use default
/// handlers.</param>
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
let hasContentType (contentType: string) (optionalErrorHandler: OptionalErrorHandlers) =
hasAnyContentTypes [ contentType ] (optionalErrorHandler: OptionalErrorHandlers)

/// <summary>
/// Limits request `Content-Length` header to a specified length,
/// returning `406 NotAcceptable` when no such header is present or the value exceeds the maximum specified length.
/// </summary>
/// <param name="maxLength">The maximum accepted length of the incoming request.</param>
/// <param name="optionalErrorHandler">OptionalErrorHandlers record with HttpHandler options to define the server
/// response either if the header does not exist or has an invalid value. If both are `Option.None`, we use default
/// handlers.</param>
/// <returns>A Giraffe <see cref="HttpHandler"/> function which can be composed into a bigger web application.</returns>
let maxContentLength (maxLength: int64) (optionalErrorHandler: OptionalErrorHandlers) =
fun (next: HttpFunc) (ctx: HttpContext) ->
let header = ctx.Request.ContentLength

let headerNotFoundHandler =
optionalErrorHandler.HeaderNotFound
|> Option.defaultValue (
RequestErrors.notAcceptable (text "Request rejected because there is no 'Content-Length' header")
)

let invalidHeaderValueHandler =
optionalErrorHandler.InvalidHeaderValue
|> Option.defaultValue (
RequestErrors.notAcceptable (text "Request rejected because 'Content-Length' header is too large")
)

match Option.ofNullable header with
| Some v when v <= maxLength -> next ctx
| Some _ -> invalidHeaderValueHandler earlyReturn ctx
| None -> headerNotFoundHandler earlyReturn ctx
1 change: 1 addition & 0 deletions tests/Giraffe.Tests/Giraffe.Tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<Compile Include="FormatExpressionTests.fs" />
<Compile Include="HttpHandlerTests.fs" />
<Compile Include="RoutingTests.fs" />
<Compile Include="RequestLimitationTests.fs" />
<Compile Include="EndpointRoutingTests.fs" />
<Compile Include="AuthTests.fs" />
<Compile Include="ModelBindingTests.fs" />
Expand Down
Loading

0 comments on commit 54f38a4

Please sign in to comment.