howdy/csrf

Cross-site request forgery protection for cookie-authenticated writes.

A page on another site can make a browser send a request to yours, and the browser attaches the user’s cookies because it decides by destination, not by who asked. An HTML form post needs no CORS preflight, so nothing stops the request from arriving. This middleware rejects writes that did not come from an origin you named.

import howdy/csrf

howdy.new()
|> howdy.middleware(csrf.middleware(csrf.new(["https://example.com"])))

GET, HEAD and OPTIONS pass through untouched, so pages and preflights still work. Every other method must carry exactly one origin header matching one of the origins, or it gets 403 before the handler runs. Browsers send that header on cross-site writes.

Some proxies and privacy tools strip origin. When it is missing, a single sec-fetch-site: same-origin header is accepted in its place. Browsers set that header themselves and scripts cannot forge it. An origin that is present always decides, and a request with neither header fails closed.

The protection is header-based by design and uses no tokens. Every current browser sends these headers, Go’s standard library protects against CSRF the same way with http.CrossOriginProtection, and OWASP’s verification standard (ASVS 4.0, 13.2.3) lists origin header checks among the accepted protections.

Because this rejects requests with neither header, it will also reject non-browser clients such as curl and native apps. Where those authenticate with a bearer token rather than a cookie they cannot be made to send one by a hostile page, so exempt them:

csrf.new(["https://example.com"])
|> csrf.exempt(fn(ctx) {
  case request.get_header(ctx.request, "authorization") {
    Ok("Bearer " <> _) -> True
    _ -> False
  }
})

This is a complete defence on its own for browser clients. Keep SameSite=Lax cookies as well, which cookie.defaults already sets: the two fail in different ways. Applications must not perform writes on GET routes, since those are not checked.

howdy_auth already applies the same check to its own routes and to any route behind its guard, so an application using it for every write does not need this middleware as well.

Types

Which origins may write, and which requests skip the check. Build with new, then turn it into middleware with middleware.

pub opaque type Policy

Values

pub fn check(
  ctx: context.Context(Nil),
  origins: List(String),
) -> Result(Nil, service.Error)

Whether a request passes the check against origins: exactly one origin header matching one of them or, when there is no origin at all, exactly one sec-fetch-site: same-origin. Use it in a guard, or for a check of your own on one route. The method is not considered. Unlike new, this does not reject a malformed origin, so it never matches one.

pub fn exempt(
  policy: Policy,
  allow: fn(context.Context(Nil)) -> Bool,
) -> Policy

Skip the check for requests the function accepts, such as those authenticated with a bearer token rather than a cookie. It runs on every write. The default exempts nothing.

pub fn middleware(
  policy: Policy,
) -> fn(
  context.Context(Nil),
  fn(context.Context(Nil)) -> response.Response(content.Content),
) -> response.Response(content.Content)

Turn a policy into middleware.

pub fn new(origins: List(String)) -> Policy

Allow writes from exactly these origins. Each must be of the form scheme://host with an optional port and no trailing slash; anything else panics as a configuration error. Comparison is case-insensitive.

The literal origin null cannot be allowed. Sandboxed frames and some redirects send it, so trusting it would defeat the check.

✨ Search Document