<?php
namespace Lia\Utility;
/**
* Print and validate CSRF tokens for forms.
*/
class CSRF {
/**
* Array of form_id:token pairs that have been successfully validated during this request.
*
* @key string concatenating form id & token: `form_id:token`
* @value true, always true
*/
static protected array $valid_requests = [];
/**
* Array of csrf entries that have been generated during this request. There can only be one for each form id.
*
* @key string form_id
* @value array one csrf entry. See `get_csrf_value()` body for structure
*/
static protected array $generated_csrf = [];
/**
* Get a form input to use for CSRF protection. Print this in your form. Both the input's name and value are randomly generated.
* The SESSION will be set with the CSRF Key/Value pair for the given form_id
*
* @param $form_id string indicating which form the csrf token is for.
* @return string hidden html input tag with name & value set
*/
static public function get_csrf_input(string $form_id, string $uri_path = '', int $expiry_minutes = 60): string {
$key = static::get_csrf_key($form_id);
$value = static::get_csrf_value($form_id, $uri_path, $expiry_minutes);
return '<input type="hidden" name="'.$key.'" value="'.$value.'">';
}
/**
* Check if a valid CSRF Key/Value pair is present within form_data for the given form_id
*
* @param $form_id string identifying the form for which CSRF is being validated
* @param $form_data array, generally $_POST
* @throw Exception with message `\Lia\Exception::CSRF_SESSION_NOT_STARTED` if session fails to start.
* @throw Exception with message `sprintf(\Lia\Exception::CSRF_TOKEN_MISMATCH, $form_id)` (*this error should never happen and would indicate a bug in the CSRF class*)
* @throw Exception with message `\Lia\Exception::CSRF_HTTPHOST_NOT_SET` if `$_SERVER['HTTP_HOST']` isn't set. This would likely be a server configuration issue.
*/
static public function is_request_valid(string $form_id, array $form_data): bool {
if (!isset($form_data['csrf-'.$form_id])){
error_log(sprintf(\Lia\Exception::CSRF_TOKEN_NOT_SUBMITTED, $form_id));
return false;
}
$token = $form_data['csrf-'.$form_id];
if (isset(static::$valid_requests[$form_id.':'.$token]))return true;
$session_key = 'lia-csrf:'.$form_id;
if (session_status()==PHP_SESSION_NONE)session_start();
if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception(\Lia\Exception::CSRF_SESSION_NOT_STARTED);
if (!isset($_SESSION[$session_key][$token])
||$_SESSION[$session_key][$token] == null
){
error_log(sprintf(\Lia\Exception::CSRF_TOKEN_NOT_IN_SESSION, $form_id));
return false;
}
$csrf_entry = $_SESSION[$session_key][$token];
unset($_SESSION[$session_key][$token]);
if (time() > $csrf_entry['expires_at']){
error_log(sprintf(\Lia\Exception::CSRF_TOKEN_EXPIRED, $form_id));
return false;
}
if ($csrf_entry['value'] != $token
){
throw new \Exception(sprintf(\Lia\Exception::CSRF_TOKEN_MISMATCH, $form_id));
} else if ($csrf_entry['form_id'] != $form_id
){
throw new \Exception(sprintf(\Lia\Exception::CSRF_FORMID_MISMATCH, $form_id));
}
$post_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($csrf_entry['uri_path'] != ''
&&$csrf_entry['uri_path'] != $post_path
){
error_log(sprintf(\Lia\Exception::CSRF_PATH_INCORRECT, $form_id, $csrf_entry['uri_path'], html_entity_decode($post_path??'')));
return false;
}
if (!isset($_SERVER['HTTP_REFERER'])
||empty($_SERVER['HTTP_REFERER'])
){
error_log(sprintf(\Lia\Exception::CSRF_NO_REFERER, $form_id, html_entity_decode($post_path??'')));
return false;
}
$referer_domain = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST);
if (!isset($_SERVER['HTTP_HOST'])
||empty($_SERVER['HTTP_HOST'])){
throw new \Exception(\Lia\Exception::CSRF_HTTPHOST_NOT_SET);
}
// to remove the port (mainly bc of localhost testing)
$server_host = parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST);
if ($server_host==null)$server_host = $_SERVER['HTTP_HOST'];
if ($referer_domain != $server_host){
error_log(sprintf(\Lia\Exception::CSRF_REFERER_HOST_MISMATCH, $form_id, html_entity_decode($post_path??''), html_entity_decode($referer_domain??''), html_entity_decode($server_host??'')));
return false;
}
static::$valid_requests[$form_id.':'.$token] = true;
return true;
}
/**
* Generate a CSRF entry for the given form_id and return the CSRF value. There can only be one CSRF token generated for each form id, and if it has already been generated, it will be returned.
*
* @param $form_id string identifying the form
* @param $uri_path string URL path that this CSRF token is valid for. Use empty string to disable path-check
* @param $expiry_minutes int number of minutes before the CSRF token expires (default 60 minutes) (*keep in mind, users may be slow to fill out forms, or may start filling out, walk away, and come back later to finish.*)
*
* @return string randomly generated CSRF token value
*
* @throw Exception with message `\Lia\Exception::CSRF_SESSION_NOT_STARTED` if session fails to start.
*/
static public function get_csrf_value(string $form_id, string $uri_path = '', int $expiry_minutes = 60): ?string {
if (isset(static::$generated_csrf[$form_id]))return static::$generated_csrf[$form_id]['value'];
$session_key = 'lia-csrf:'.$form_id;
$key = static::get_csrf_key($form_id);
if (session_status()==PHP_SESSION_NONE)session_start();
if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception(\Lia\Exception::CSRF_SESSION_NOT_STARTED);
$csrf_value = static::make_csrf_code();
$csrf_meta = [
'created' => time(),
'expires_at' => time() + ($expiry_minutes * 60),
'value' => $csrf_value,
'uri_path' => $uri_path,
'form_id' => $form_id,
];
if (!isset($_SESSION[$session_key])
|| !is_array($_SESSION[$session_key])
){
$_SESSION[$session_key] = [];
}
// allows the user to have multiple of the same form open and get different CSRF key/values for each
$_SESSION[$session_key][$csrf_value] = $csrf_meta;
static::$generated_csrf[$form_id] = $csrf_meta;
return $csrf_value;
}
static public function get_csrf_key(string $form_id){
return 'csrf-'.$form_id;
}
/** Generate a random CSRF value (*used both as key and value*)
*
* @return a string
*/
static protected function make_csrf_code(): string {
// this code from symfony csrf package: https://github.com/symfony/security-csrf/blob/5.4/TokenGenerator/UriSafeTokenGenerator.php
$bytes = random_bytes(64);
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
}