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.

A man attempts to violently verify a CSRF token with his foot

In that article I said there were three options to fix this issue:

  1. reach out to all your customers who use a proxy and ask them nicely to update their proxy configurations
  2. 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
  3. 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.