CSRF

Http4s provides Middleware, named CSRF, to prevent Cross-site request forgery attacks. This middleware is modeled after the double submit cookie pattern.

Examples in this document have the following dependencies.

libraryDependencies ++= Seq(
  "org.http4s" %% "http4s-dsl" % http4sVersion,
  "org.http4s" %% "http4s-server" % http4sVersion
)

And we need some imports.

import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.http4s.server.middleware._

If you're in a REPL, we also need a runtime:

import cats.effect.unsafe.IORuntime
implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global

Let's start by making a simple service.

val service = HttpRoutes.of[IO] {
  case _ => Ok()
}

val request = Request[IO](Method.GET, uri"/")
service.orNotFound(request).unsafeRunSync()
// res0: Response[[A]IO[A]] = (
//    = Status(code = 200),
//    = HttpVersion(major = 1, minor = 1),
//    = Headers(Content-Length: 0),
//    = Stream(..),
//    = org.typelevel.vault.Vault@7dac9caa
// )

That didn't do all that much. Lets build out our CSRF Middleware by creating a CSRFBuilder

val cookieName = "csrf-token"
val key  = CSRF.generateSigningKey[IO].unsafeRunSync()
val defaultOriginCheck: Request[IO] => Boolean =
  CSRF.defaultOriginCheck[IO](_, "localhost", Uri.Scheme.http, None)
val csrfBuilder = CSRF[IO,IO](key, defaultOriginCheck)

More info on what is possible in the CSRFBuilder Docs, but we will create a fairly simple CSRF Middleware in our example.

val csrf = csrfBuilder
  .withCookieName(cookieName)
  .withCookieDomain(Some("localhost"))
  .withCookiePath(Some("/"))
  .build

Now we need to wrap this around our service! We're gonna start with a safe call

val dummyRequest: Request[IO] =
    Request[IO](method = Method.GET).putHeaders("Origin" -> "http://localhost")
val resp = csrf.validate()(service.orNotFound)(dummyRequest).unsafeRunSync()
// resp: Response[IO] = (
//    = Status(code = 200),
//    = HttpVersion(major = 1, minor = 1),
//    = Headers(Content-Length: 0, Set-Cookie: csrf-token=58B216DA1B280DD236E8C829B5B976A6181C171BD7B6122D44C5D30714A46274-1732129694855-90190EC0D220109209D33309898F303745EE0BFD; Domain=localhost; Path=/; SameSite=Lax; HttpOnly),
//    = Stream(..),
//    = org.typelevel.vault.Vault@30e30efc
// )

Notice how the response has the CSRF cookies added. How easy was that? And, as described in Middleware, services and middleware can be composed such that only some of your endpoints are CSRF enabled. By default, safe methods will update the CSRF token, while unsafe methods will validate them.

Without getting too deep into it, safe methods are OPTIONS, GET, and HEAD. While unsafe methods are POST, PUT, PATCH, DELETE, and TRACE. To put it simply, state changing methods are unsafe. For more information, check out this cheat sheet on CSRF Prevention.

Unsafe requests (like POST) require us to send the CSRF token in the X-Csrf-Token header (this is the default name, but it can be changed), so we are going to get the value and send it up in our POST. I've also added the response cookie as a RequestCookie, normally the browser would send this up with our request, but I needed to do it manually for the purpose of this demo.

val cookie = resp.cookies.head
// cookie: ResponseCookie = ResponseCookie(
//   name = "csrf-token",
//   content = "58B216DA1B280DD236E8C829B5B976A6181C171BD7B6122D44C5D30714A46274-1732129694855-90190EC0D220109209D33309898F303745EE0BFD",
//   expires = None,
//   maxAge = None,
//   domain = Some(value = "localhost"),
//   path = Some(value = "/"),
//   sameSite = Some(value = Lax),
//   secure = false,
//   httpOnly = true,
//   extension = None
// )
val dummyPostRequest: Request[IO] =
    Request[IO](method = Method.POST).putHeaders(
      "Origin" -> "http://localhost",
      "X-Csrf-Token" -> cookie.content
    ).addCookie(RequestCookie(cookie.name,cookie.content))
// dummyPostRequest: Request[IO] = (
//    = POST,
//    = Uri(scheme = None, authority = None, path = /, query = , fragment = None),
//    = HttpVersion(major = 1, minor = 1),
//    = Headers(Origin: http://localhost, X-Csrf-Token: 58B216DA1B280DD236E8C829B5B976A6181C171BD7B6122D44C5D30714A46274-1732129694855-90190EC0D220109209D33309898F303745EE0BFD, Cookie: csrf-token=58B216DA1B280DD236E8C829B5B976A6181C171BD7B6122D44C5D30714A46274-1732129694855-90190EC0D220109209D33309898F303745EE0BFD),
//    = Stream(..),
//    = org.typelevel.vault.Vault@4a7491b4
// )
val validateResp =
  csrf.validate()(service.orNotFound)(dummyPostRequest).unsafeRunSync()
// validateResp: Response[IO] = (
//    = Status(code = 200),
//    = HttpVersion(major = 1, minor = 1),
//    = Headers(Content-Length: 0, Set-Cookie: csrf-token=58B216DA1B280DD236E8C829B5B976A6181C171BD7B6122D44C5D30714A46274-1732129694876-239E70B1243CCEAC6773E8F382F0DFEBB65603A4; Domain=localhost; Path=/; SameSite=Lax; HttpOnly),
//    = Stream(..),
//    = org.typelevel.vault.Vault@78ba72a1
// )