The basics
CSRF protection
Oriel protects your forms from cross-site request forgery: every form that changes something must send the session's CSRF token, or the request is refused.
Introduction#
Cross-site request forgery is an attack in which another site makes your users' browsers send requests to your app.
Picture a /user/ route that accepts a POST and changes the signed-in user's email address. Without CSRF
protection, any site the user visits could hold a form that posts its own address to that route, and submit it with
a little JavaScript. The browser would send the user's session cookie along, and your app would change the address.
To stop this, your app checks every POST, PUT, PATCH and DELETE request to a route in the web group for a
secret value, the CSRF token, which only your own pages know.
Preventing CSRF requests#
Every session has a CSRF token. It is kept in the session, so another site can't read it, and it changes whenever the
session is regenerated, as the starter kits do when a user signs in. Read it from the request's session, or with
csrfToken():
Route.get('/token', () => { const token = request().session().token() return csrfToken()})A form you write yourself sends the token in a hidden _token field, which csrfField() writes:
import { csrfField } from '@orieljs/framework'export default function Profile() { return ( <form method="post" action="/profile"> {csrfField()} {/* the same as <input type="hidden" name="_token" value={csrfToken()} autocomplete="off" /> */} <button type="submit">Save</button> </form> )}The VerifyCsrfToken middleware, in the web middleware group, checks
that the token a request sends matches its session's. GET, HEAD and OPTIONS requests pass without one. A
request whose token is missing or wrong gets a 419: the "Page Expired" page, or, when the request wants JSON,
{"message": "CSRF token mismatch."}.
Inertia pages and Live components send the token for you, from the XSRF-TOKEN cookie (see
X-XSRF-TOKEN), so the starter kits' forms have no _token field.
A form that should reach a PUT, PATCH or DELETE route posts with a hidden _method field as well, from
methodField(): see form method spoofing.
CSRF tokens and SPAs#
A single-page app served from your app's own domain sends the token in the X-XSRF-TOKEN header, as Inertia does. A
frontend on another domain, or a mobile app, uses your API routes instead, which have no session and no CSRF check, and
signs in with personal access tokens. Oriel sends no CORS headers, so a browser frontend
on another domain needs a middleware of your own that adds them.
Excluding URIs from CSRF protection#
Some routes can't get the token: a payment provider's webhook, for one, doesn't know it. Such routes are best kept out
of the web group, in routes/. Otherwise, list their URIs in validateCsrfTokens() in bootstrap/:
Application.configure({ basePath: '.' }).withMiddleware((middleware) => { middleware.validateCsrfTokens({ except: ['stripe/*', 'http://example.com/foo/bar', 'http://example.com/foo/*'], })})An entry is a path, with or without its leading /, or a full URL, and * matches anything. The same list can live
in csrf_except in config/; validateCsrfTokens() adds to it. A single route can also leave the check out:
Route.post('/.
X-CSRF-TOKEN#
VerifyCsrfToken also reads the token from an X-CSRF-TOKEN header. You can put the token in a meta tag in your
layout:
import { csrfToken } from '@orieljs/framework'export default function Head({ title }: { title: string }) { return ( <head> <title>{title}</title> <meta name="csrf-token" content={csrfToken()} /> </head> )}and send it from your scripts:
export function postComment(body: string): Promise<Response> { const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '' return fetch('/comments', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token }, body: JSON.stringify({ body }), })}X-XSRF-TOKEN#
Every response that passes VerifyCsrfToken carries the token in an XSRF-TOKEN cookie, encrypted as your other
cookies are. The cookie isn't HttpOnly, so your scripts can read it, and a request may send its value back in an
X-XSRF-TOKEN header, which VerifyCsrfToken decrypts. It takes its lifetime, path, domain, secure and same_site
settings from config/.
The cookie is there for the clients that send it on their own: Inertia's HTTP client and Live's client do, as
Axios and Angular's HttpClient do.