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
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.