dadajuice/zephyrus

View on GitHub
src/Zephyrus/Security/CsrfGuard.php

Summary

Maintainability
B
6 hrs
Test Coverage
A
100%
<?php namespace Zephyrus\Security;

use RuntimeException;
use Zephyrus\Application\Configuration;
use Zephyrus\Core\Session;
use Zephyrus\Exceptions\Security\InvalidCsrfException;
use Zephyrus\Exceptions\Security\MissingCsrfException;
use Zephyrus\Network\Request;

class CsrfGuard
{
    public const HEADER_TOKEN = 'HTTP_X_CSRF_TOKEN';
    public const REQUEST_TOKEN_VALUE = 'CSRFToken';
    public const TOKEN_LENGTH = 48;
    public const DEFAULT_CONFIGURATIONS = [
        'enabled' => true, // Enable the CSRF mitigation feature
        'html_integration_enabled' => true, // Automatically insert needed HTML into forms
        'guard_methods' => ['POST', 'PUT', 'DELETE', 'PATCH'], // List of guarded methods
        'exceptions' => [] // List of route exceptions (e.g. ['\/test.*'] meaning all routes beginning with /test)
    ];

    /**
     * Keeps a linked reference to the Request instance given in the constructor. Meaning that the request could evolve
     * outside the CsrfGuard instance and still be up-to-date.
     *
     * @var Request|null
     */
    private ?Request $request;

    /**
     * Determines the HTTP request methods that should be secured by the CSRF mitigation. It implies that for EVERY
     * request of these types, the CSRF token should be provided. All forms should follow a strict REST philosophy
     * meaning that all form processing should pass through POST, PUT, PATCH or DELETE only.
     *
     * @var array
     */
    private array $guardedMethods = ['POST', 'PUT', 'DELETE', 'PATCH'];

    /**
     * List of routes to ignore the CSRF mitigation no matter the HTTP method. Normally, all routes for the guarded
     * HTTP methods should pass through the CSRF mitigation but, it may happen that some routes are exempt of
     * mitigation. For such cases, the exceptions should be used. Accepts regex for the route definition.
     *
     * @var array
     */
    private array $exceptions = [];

    /**
     * Determines if the CSRF mitigation is active. Should be verified before calling the run method.
     *
     * @var bool
     */
    private bool $enabled = true;

    /**
     * Determines if the CSRF mitigation should inject the needed HTML fields automatically. Since every form will need
     * proper inclusion of specific tokens, it is best to use the automatic integration.
     *
     * @var bool
     */
    private bool $htmlIntegrationEnabled = true;

    /**
     * Loaded configurations for the CSRF mitigation.
     *
     * @var array
     */
    private array $configurations;

    public static function generate(): string
    {
        $name = self::generateFormName();
        $token = self::generateToken($name);
        return $name . '$' . $token;
    }

    public function __construct(?Request $request, array $configurations = [])
    {
        $this->request = $request;
        $this->initializeConfigurations($configurations);
        $this->initializeEnabledState();
        $this->initializeAutomaticHtmlIntegration();
        $this->initializeGuardedMethods();
        $this->initializeExceptions();
    }

    /**
     * Verifies if the CSRF mitigation is enabled based on the instance configuration. Should be use as a condition to
     * execute the run method.
     *
     * @return bool
     */
    public function isEnabled(): bool
    {
        return $this->enabled;
    }

    /**
     * Verifies if the CSRF mitigation is configured to automatically inject HTML into forms.
     *
     * @return bool
     */
    public function isHtmlIntegrationEnabled(): bool
    {
        return $this->htmlIntegrationEnabled;
    }

    /**
     * Generates and returns the corresponding HTML hidden fields for the CSRF mitigation. Should be used for a custom
     * approach to form data injection. Not needed if the html_integration_enabled configuration. In that case, the
     * injectForms() method should be used instead.
     *
     * @return string
     */
    public function generateHiddenFields(): string
    {
        $value = self::generate();
        return '<input type="hidden" name="' . self::REQUEST_TOKEN_VALUE . '" value="' . $value . '" />';
    }

    /**
     * Proceeds to filter the current request for any CSRF mismatch. Forms must provide its unique name and
     * corresponding generated csrf token. Will throw a InvalidCsrfException on failure.
     *
     * @throws MissingCsrfException
     * @throws InvalidCsrfException
     */
    public function run(): void
    {
        if (!$this->isExempt()) {
            $providedToken = $this->getProvidedCsrfToken();
            if (is_null($providedToken)) {
                throw new MissingCsrfException($this->request);
            }
            $tokenParts = explode("$", $providedToken);
            if (count($tokenParts) < 2) {
                throw new InvalidCsrfException($this->request);
            }
            list($formName, $token) = $tokenParts;
            if (!$this->validateToken($formName, $token)) {
                throw new InvalidCsrfException($this->request);
            }
        }
    }

    /**
     * Automatically adds CSRF hidden fields to any forms present in the given HTML. This method is to be used with
     * automatic injection behavior. If a form contains a "nocsrf" HTML property, the CSRF mitigation is skipped for
     * this specific form.
     *
     * @param string $html
     * @return string
     */
    public function injectForms(string $html): string
    {
        preg_match_all("/<form(.*?)>(.*?)<\\/form>/is", $html, $matches, PREG_SET_ORDER);
        if (is_array($matches)) {
            foreach ($matches as $match) {
                if (str_contains($match[1], "nocsrf")) {
                    continue;
                }
                $hiddenFields = self::generateHiddenFields();
                $html = str_replace($match[0], "<form$match[1]>$hiddenFields$match[2]</form>", $html);
            }
        }
        return $html;
    }

    /**
     * @return bool
     */
    public function isGetSecured(): bool
    {
        return in_array('GET', $this->guardedMethods);
    }

    /**
     * @return bool
     */
    public function isPostSecured(): bool
    {
        return in_array('POST', $this->guardedMethods);
    }

    /**
     * @return bool
     */
    public function isPutSecured(): bool
    {
        return in_array('PUT', $this->guardedMethods);
    }

    /**
     * @return bool
     */
    public function isPatchSecured(): bool
    {
        return in_array('PATCH', $this->guardedMethods);
    }

    /**
     * @return bool
     */
    public function isDeleteSecured(): bool
    {
        return in_array('DELETE', $this->guardedMethods);
    }

    /**
     * Validates if the current request is exempt of CSRF verification. Can happen if the HTTP request method is not
     * filtered or the route matches one of the defined exceptions.
     *
     * @return bool
     */
    private function isExempt(): bool
    {
        if (!$this->isHttpMethodFiltered($this->request->getMethod()->value)) {
            return true;
        }
        foreach ($this->exceptions as $exceptionRegex) {
            if ($exceptionRegex === $this->request->getRoute()) {
                return true;
            }
            if (preg_match('/^' . $exceptionRegex . '$/', $this->request->getRoute())) {
                return true;
            }
        }
        return false;
    }

    /**
     * Generates and stores in the current session a cryptographically random token that shall be validated during the
     * run method.
     *
     * @param string $formName
     * @return string
     */
    private static function generateToken(string $formName): string
    {
        $token = Cryptography::randomString(self::TOKEN_LENGTH);
        $csrfData = Session::get('__CSRF_TOKEN', []);
        $csrfData[$formName] = $token;
        Session::set('__CSRF_TOKEN', $csrfData);
        return $token;
    }

    /**
     * Returns a random name to be used for a form csrf token.
     *
     * @return string
     */
    private static function generateFormName(): string
    {
        return "CSRFGuard_" . mt_rand(0, mt_getrandmax());
    }

    /**
     * Validates the given token with the one stored for the specified form name. Once validated, good or not, the token
     * is removed from the session.
     *
     * @param string $formName
     * @param string $token
     * @return bool
     */
    private function validateToken(string $formName, string $token): bool
    {
        $sortedCsrf = $this->getStoredCsrfToken($formName);
        if (!is_null($sortedCsrf)) {
            $csrfData = Session::get('__CSRF_TOKEN', []);
            if (is_null($this->request->getHeader('CSRF_KEEP_ALIVE'))
                && is_null($this->request->getParameter('CSRF_KEEP_ALIVE'))) {
                $csrfData[$formName] = '';
                Session::set('__CSRF_TOKEN', $csrfData);
            }
            return hash_equals($sortedCsrf, $token);
        }
        return false;
    }

    /**
     * Obtains the CSRF token stored by the server for the corresponding client. Returns null if undefined.
     *
     * @param string $formName
     * @return null|string
     */
    private function getStoredCsrfToken(string $formName): ?string
    {
        $csrfData = Session::get('__CSRF_TOKEN');
        if (is_null($csrfData)) {
            return null;
        }
        return $csrfData[$formName] ?? null;
    }

    /**
     * Obtains the CSRF token provided by the client either by request data or by an HTTP header (e.g. Ajax based
     * requests). Returns null if undefined.
     *
     * @return null|string
     */
    private function getProvidedCsrfToken(): ?string
    {
        $token = $this->request->getParameter(self::REQUEST_TOKEN_VALUE);
        if (is_null($token)) {
            $token = $this->request->getHeader(self::HEADER_TOKEN);
        }
        return $token;
    }

    /**
     * Checks if the specified method should be filtered.
     *
     * @param string $method
     * @return bool
     */
    private function isHttpMethodFiltered(string $method): bool
    {
        if ($this->isGetSecured() && $method == "GET") {
            return true;
        } elseif ($this->isPostSecured() && $method == "POST") {
            return true;
        } elseif ($this->isPutSecured() && $method == "PUT") {
            return true;
        } elseif ($this->isPatchSecured() && $method == "PATCH") {
            return true;
        } elseif ($this->isDeleteSecured() && $method == "DELETE") {
            return true;
        }
        return false;
    }

    private function initializeConfigurations(array $configurations): void
    {
        if (empty($configurations)) {
            $configurations = Configuration::getSecurity('csrf') ?? self::DEFAULT_CONFIGURATIONS;
        }
        $this->configurations = $configurations;
    }

    private function initializeEnabledState(): void
    {
        if (isset($this->configurations['enabled'])) {
            $this->enabled = (bool) $this->configurations['enabled'];
        }
    }

    private function initializeAutomaticHtmlIntegration(): void
    {
        if (isset($this->configurations['html_integration_enabled'])) {
            $this->htmlIntegrationEnabled = $this->configurations['html_integration_enabled'];
        }
    }

    private function initializeGuardedMethods(): void
    {
        if (isset($this->configurations['guard_methods'])) {
            foreach ($this->configurations['guard_methods'] as $method) {
                if (!in_array($method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])) {
                    throw new RuntimeException("CSRF guard methods is invalid. Must be an array containing a combinaison of the following values 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'. ");
                }
            }
            $this->guardedMethods = $this->configurations['guard_methods'];
        }
    }

    private function initializeExceptions(): void
    {
        if (isset($this->configurations['exceptions']) && !empty($this->configurations['exceptions'])) {
            $this->exceptions = $this->configurations['exceptions'];
        }
    }
}