Skip to content

PSR-15 compatible middleware implementing cross-site request forgery protection

License

Notifications You must be signed in to change notification settings

solventt/csrf-protection

Repository files navigation

Table of Contents

  1. Features
  2. Installing
  3. Usage
  4. A real use case
  5. A custom token name
  6. A custom failure handler
  7. A custom token storage
  8. A custom token generation algorithm
  9. A custom CSRF token class
  10. The CSRF token in custom request headers

This is a PSR-15 compatible middleware that implements protection against cross-site request forgery.

In this package, the CSRF protection is organized according to the Synchronizer Token pattern described on the OWASP website.

Features

This package uses token masking (randomizing by XORing with a random secret). This method is recommended for protection against BREACH attacks.

The CSRF token is generated and saved once per session (this can be changed). But thanks to the mask, the token will be unique each time it is requested.

Masking the token eliminates the problem of false CSRF triggering on the server when you click the "Back" button in the browser.

Installing

// php 7.4+
composer require solventt/csrf-protection ^0.1

// php 8.0+
composer require solventt/csrf-protection ^1.0

Usage

$csrfToken = new MaskedCsrfToken(new SessionTokenStorage(), new SecurityHelper());

$middleware = new CsrfMiddleware($csrfToken, new ResponseFactory());

// then add the middleware to the middlewares stack

To get a name and valid value of the token do:

// data for a hidden HTML form field

$name = $csrfToken->getName();

$value = $csrfToken->getValue();

Somewhere in HTML:

<input type="hidden" name="<?= $name ?>" value="<?= $value ?>">

When the getValue() method is called the first time, the CSRF token is generated and stored into a storage (usually in a user session). On subsequent method calls, a CSRF token value is taken from a storage.

By default, the getValue() method returns a masked token. If you need a raw value of the CSRF token that is stored in a session, specify false as the first argument:

$value = $csrfToken->getValue(false);

If you want to generate the token of a certain length, specify it as the second argument in the getValue() method:

$value = $csrfToken->getValue(true, 30);

The default token length is 32 characters and cannot be less than 15.

Since the CSRF token is randomly masked, there is no need to regenerate it within the same session. But if such a need occurs, do:

// a default length of the token is 32 chars
$csrfToken->regenerate();

// you can specify a different length
$csrfToken->regenerate(35);

A real use case

It is an example of using the CSRF protection in the Slim micro framework.

config/csrf.php:

// the DI container definition

// a constructor of the CsrfMiddleware class has 2 mandatory arguments: $token and $responseFactory.
// Thanks to the dependency injection container, the CsrfTokenInterface and ResponseFactoryInterface
// dependencies will be automatically resolved during CsrfMiddleware instantiation

return [
    CsrfTokenInterface::class => function (ContainerInterface $c) {
        return new MaskedCsrfToken(new SessionTokenStorage(), new SecurityHelper());
    },

    ResponseFactoryInterface::class => fn () => new ResponseFactory(),
];

routing/middleware.php:

/**
* Adding the middleware to the stack
*
* @var Slim\App $app
*/
$app->add(CsrfMiddleware::class);

config/twig.php:

// the DI container definition

Environment::class => function (ContainerInterface $c) {
    
    ...
    
    $csrf = new TwigFunction('csrf', function () use ($c): string {

        /** @var MaskedCsrfToken $csrf */
        $csrf = $c->get(CsrfTokenInterface::class);

        $name = $csrf->getName();
        $token = $csrf->getValue();

        return sprintf('<input type="hidden" name="%s" value="%s">', $name, $token);
    });
    
    $twig->addFunction($csrf);
    
    ...
}

views/template.twig:

...

{{ csrf()|raw }}

...

A custom token name

The default token name is _csrf. But you can specify your own name by adding it as the third argument to the MaskedCsrfToken constructor:

$csrfToken = new MaskedCsrfToken(
    new SessionTokenStorage(),
    new SecurityHelper(),
    'customTokenName'
);

A custom failure handler

By default, if the CSRF tokens do not match, the client receives code 400, and the 'Bad Request' message.

But you can define your own logic for handling CSRF fails. Just add an anonymous function as the third argument to the CsrfMiddleware constructor:

...

$session = $container->get(SessionInterface::class);

$logger = $container->get(LoggerInterface::class);

$responseFactory = $container->get(ResponseFactoryInterface::class);

$failureHandler = function () use ($session, $logger, $responseFactory): ResponseInterface {
    $session->destroy();
    $logger->error('CSRF check failed');
    $response = $responseFactory->createResponse(403);
    $response->getBody()->write('Forbidden');
    
    return $response;
};

$middleware = new CsrfMiddleware(
    $csrfToken, 
    new ResponseFactory(),
    $failureHandler
);

Notice: an anonymous function must return an instance that implements ResponseInterface.

A custom token storage

Out of the box, this package provides the SessionTokenStorage class that works directly with the superglobal $_SESSION. If that's not what you need, you can write your own version of the token storage. Then your class must implement TokenStorageInterface interface:

interface TokenStorageInterface
{
    public function get(string $tokenName): ?string;
    public function set(string $tokenName, string $value): void;
    public function remove(string $tokenName): void;
}

For example, your code uses an abstraction over $_SESSION to handle sessions. Then your token storage might look like this:

use Solventt\Csrf\Interfaces\TokenStorageInterface;
use Odan\Session\SessionInterface;

class CsrfSessionTokenStorage implements TokenStorageInterface
{
    public function __construct(private SessionInterface $session) {}

    public function get(string $tokenName): ?string
    {
        /** @var mixed|null $value */
        $value = $this->session->get($tokenName);

        return is_string($value) ? $value : null;
    }

    public function set(string $tokenName, string $value): void
    {
        $this->session->set($tokenName, $value);
    }

    public function remove(string $tokenName): void
    {
        $this->session->remove($tokenName);
    }

A custom token generation algorithm

You can define your own logic for generating the CSRF token and adding/removing the token mask. To do this, your class must implement SecurityInterface:

interface SecurityInterface
{
    /** 
    * Generates a cryptographically secure value  
    */
    public function generateToken(int $length): string;
    
    /** 
    * Applies a random mask to the CSRF token making it unique when its requested 
    */
    public function addMask(string $token): string;
    
    /** 
    * Removes the mask from the CSRF token previously masked with the 'addMask' method 
    */
    public function removeMask(string $token): string;
}

A custom CSRF token class

This package provides the MaskedCsrfToken class representing the CSRF token. But you can write your own implementation of the token according to the CsrfTokenInterface:

interface CsrfTokenInterface
{
    public const DEFAULT_NAME = '_csrf';

    public function getName(): string;
    public function getValue(): string;
    
    /** 
    * Compares the token from the request with the token found in a token storage
    */
    public function equals(string $requestToken): bool;
}

The CSRF token in custom request headers

If no CSRF token is found in request body, the middleware checks for the X-CSRF-Token header. You can provide your own header name using the setHeaderName method:

/**  
 * @var CsrfMiddleware $middleware 
 */
$middleware->setHeaderName('X-CUSTOM-HEADER');

It is relevant, for example, for AJAX requests.

About

PSR-15 compatible middleware implementing cross-site request forgery protection

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages