Fixing an issue with CSRF tokens in Laravel with EZproxy
I wrote recently about an issue I had been investigating where CSRF validation checks were failing for AJAX requests for some users. It turned out that OCLC’s EZproxy was filtering out the X-CSRF-TOKEN and X-XSRF-TOKEN headers from those requests causing the Laravel server to return a 419 error response.
In that article I said there were three options to fix this issue:
- reach out to all your customers who use a proxy and ask them nicely to update their proxy configurations
- implement a workaround where you pass the CSRF token to the server a different way and rewrite or extend your framework’s CSRF validation middleware to handle it
- turn off CSRF validation
Of those options, 1 is probably the ideal if it’s feasible for your use case, but it’s likely not to be. 3 may or may not be acceptable depending on what the endpoints in question do. I decided to pursue option 2, and this post explains how I did it. My previous post largely applied to any server framework, but this one is specific to the tools in this project: Laravel and Axios.
First I found the code for Laravel’s CSRF validation middleware to work out what it was doing to get hold of the token. This is what it looks like (with line breaks changed a bit to fit Medium formatting):
protected function getTokenFromRequest($request)
{
$token = $request->input('_token')
?: $request->header('X-CSRF-TOKEN'); if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
try {
$token = CookieValuePrefix::remove(
$this->encrypter->decrypt(
$header,
static::serialized()));
} catch (DecryptException $e) {
$token = '';
}
} return $token;
}
So, in order of preference, this code checks for the token in
- a
_token
parameter in$request->input
— so this can either come from a form field or a query parameter - an
X-CSRF-TOKEN
header - in encrypted form in an
X-XSRF-TOKEN
header
So the simplest workaround is just to make sure your AJAX client of choice adds a _token
query parameter to every request with the value of your CSRF token. One way to do that would be to write your CSRF token into the page, say in a meta tag, and then add an Axios intercept to find that value in the DOM and attach it to each request. That would be a reasonable way to do it, except that in my case we also cache (most) pages in a CDN, so we can’t rely on the contents of a page being written specifically for that user. A user could receive a page cached many hours earlier for a different user, so we can’t rely on a CSRF token found in the DOM.
However Laravel also automatically drops an XSRF-TOKEN cookie that contains the encrypted version of the CSRF token. So I decided to use that. Doing so meant slightly amending the CSRF validation middleware behaviour so that it would look for the encrypted form of the token in the query string:
<?php
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
use Illuminate\Http\Request;
class VerifyCsrfToken extends Middleware
{
/**
* Get the CSRF token from the request.
*
* @param Request $request
* @return string
*/
protected function getTokenFromRequest($request)
{
$token = $request->input('_token') ?:
$request->header('X-CSRF-TOKEN');
if (! $token) {
try {
$encrypted = $request->input('_xsrf') ?:
$request->header('X-XSRF-TOKEN');
if ($encrypted) {
$token = CookieValuePrefix::remove(
$this->encrypter->decrypt(
$encrypted,
static::serialized()));
}
}
catch (DecryptException $e) {
$token = '';
}
}
return $token;
}
}
This extends the standard CSRF token middleware and changes the getTokenFromRequest
method so that it checks, in order of preference:
- a form field or query param called
_token
- an
X-CSRF-TOKEN
header - a form field of query parameter called
_xsrf
(in encrypted form) - an
X-XSRF-TOKEN
header (in encrypted form)
With that done I can make sure Axios sends the _xsrf
parameter with all relevant requests:
function getXsrfCookie () {
const match = document.cookie.match(new RegExp('(^|;\\s*)XSRF-TOKEN=([^;]*)'))
return (match ? decodeURIComponent(match[2]) : null)
}
axios.interceptors.request.use(config => {
if (['HEAD', 'GET', 'OPTIONS'].includes(config.method.toUpperCase())) {
// No CSRF checks are done on these methods
return config
}
config.params = Object.assign({}, config.params, { '_xsrf': getXsrfCookie() })
return config
})
The getXsrfCookie
function is largely nabbed from an internal function that Axios already uses to get hold of a cookie to attach to the X-XSRF-HEADER
automatically. I originally wrote my own and then realised Axios did something similar, and so I would do well to make sure they were consistent.
The next part just intercepts every outgoing request and — if it’s one where a CSRF check will occur — attaches an _xsrf
query param with the value it finds in the XSRF-TOKEN
cookie.
Note: Laravel changed how they handle encrypted cookies in version 6. The above code is for Laravel 6+. In earlier versions, there was no need for the call to CookieValuePrefix::remove
and it didn’t set the token to an empty string in the catch block.