SpamControl.php

<?php

namespace Phad;

trait SpamControl {


    /** either POST or GET, depending which request method was used */
    public $spam_data = [];
    /** I don't know. It's used for the csrf spam controls */
    public $latest_csrf = [];
    /** I don't know. It's used for the csrf spam controls */
    public $valid_sessions = [];

    /**
     * Prepare CSRF & Honeypot spam controls & return html inputs for them
     *
     * @param $key a key to identify the form for csrf validation
     */
    public function show_spam_control(string $key){

        $csrf_key = $this->enable_csrf($key, 60, parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
        $output = $this->get_csrf_session_input($key)
                . $this->honey_form();
        // $output = $this->honey_form();

        return $output;

    }


    public function verify_spam_control(string $key, array &$row){

        $this->spam_data = $row;
        $honey = $this->check_honey();
        $csrf = $this->csrf_is_valid($key);
        $csrf_key = $this->get_csrf_post_key($key);
        $csrf = true;

        $fail = false;
        if (!$honey
            ||!$csrf
        ) {
            $fail = true;
            $url = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
            echo "<h1>Submission Error!</h1>";
            echo "<p>Our spam control system blocked your submission. Please <a href=\"$url\">Try Again</a> or contact us on Social Media if this problem continues.</p>";
        } 

        // remove honey keys fro the post
        $honey_keys = explode(',',$row['honey']);
        foreach ($honey_keys as $hkey){
            unset($row[$hkey]);
        }
        unset($row['honey']);
        unset($row['honey_answer']);
        unset($row[$csrf_key]);


        if ($fail){
            $row['spam_control'] = 'failed_validation';
            return false;
        } 

        return true;

    }
    
    public function honey_form(){
        ob_start();
        require(__DIR__.'/honey_form.php');
        return ob_get_clean();
    }


    protected function check_honey(){
        if (!isset($this->spam_data['honey']))return false;
        $honey = $this->spam_data['honey'];
        $names = explode(',', $honey);
        if (count($names)!=3)return false;
        if (!isset($this->spam_data[$names[0]])
            ||$this->spam_data[$names[0]]!==''
            )return false;
        if (!isset($this->spam_data[$names[1]])
            ||$this->spam_data[$names[1]]!==''
            )return false;

        if (!isset($this->spam_data[$names[2]])
            ||$this->spam_data[$names[2]]==''
            )return false;

        if (!isset($this->spam_data['honey_answer'])
            ||$this->spam_data['honey_answer'] == ''
            )return false;
        // echo 'no';
        // exit;
        // var_dump($this->spam_data['honey_answer']);
        // var_dump($this->spam_data[$names[2]]);
        // var_dump(password_hash($this->spam_data[$names[2]], PASSWORD_DEFAULT));

        // if (!password_verify($this->spam_data[$names[2]], $this->spam_data['honey_answer']))return false;
        if (md5($this->spam_data[$names[2]]) != $this->spam_data['honey_answer'])return false;

        return true;
    }


    //////////////////////
    // throttle
    //////////////////////
    public function throttle($key, $value, $length=5000){
        $lib = $this->lib;
        $remaining = $lib->throttle_remaining($key,$value);

        if ($remaining>0){
            $seconds = (int)(($remaining + 1000) / 1000);
            echo "<p class=\"error\">Please wait $seconds seconds before trying again.</p>";
            error_log("Throttle for '$key' with value '$value' has '$seconds' remaining");
            return new \Tlf\User\BlackHole($this);
        }

        $lib->add_throttle($key, $value, $length);

        return $this;
    }


    ////////////
    // csrf
    ////////////
    //


    public function make_csrf_code(){
        // 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), '+/', '-_'), '=');
    }

    /**
     *
     *
     * @param $key_prefix string to help identify your csrf token.
     * @param $expiry_minutes number of minutes the token should be valid for
     * @param $url the url path the token should be validated on, like '/some/url/'. If not set, it works on any path
     *
     * @return the csrf key. To load csrf data do `$_SESSION[$csrf_key]`. `$csrf_key` will be like `key_prefix-csrf-uniqid()`
     */
    public function enable_csrf(string $key_prefix='',int $expiry_minutes=60, string $url_path=''){
        $key = $key_prefix.'-csrf-'.uniqid();
        $data = [
            'code'=> $this->make_csrf_code(), 
            'expires_at' => time() + $expiry_minutes * 60,
            'uri' => $url_path,
        ];
        if (session_status()==PHP_SESSION_NONE)session_start();
        if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception("Failed to start session. Cannot do csrf without session.");
        $_SESSION[$key] = $data;
        $this->latest_csrf[$key_prefix] = $key;

        // error_log('csrf key: '.$key);
        return $key;
    }

    /** 
     * get the key of the csrf data in `$_POST` for the given key
     * @param $key_prefix see csrf_is_valid
     */
    public function get_csrf_post_key(string $key_prefix=''): string {
        $len = strlen($key_prefix) + strlen('-csrf-');
        foreach ($_POST as $key=>$value){
            if (substr($key,0,$len)!=$key_prefix.'-csrf-')continue;
            $post_key = $key;
            // $post_code = $value;
            return $post_key;
            // break;
        }
        return '';
    }

    public function get_csrf_session_key(string $key_prefix=''): string {
        if (isset($this->latest_csrf[$key_prefix]))return $this->latest_csrf[$key_prefix];
        $len = strlen($key_prefix) + strlen('-csrf-');
        foreach ($_SESSION as $key=>$value){
            if (substr($key,0,$len)!=$key_prefix.'-csrf-')continue;
            return $key;
        }
        return '';
    }

    public function get_csrf_session_input(string $key_prefix=''): string {
        $key = $this->get_csrf_session_key($key_prefix);
        $code = $_SESSION[$key]['code'];
        return '<input type="hidden" name="'.$key.'" value="'.$code.'">';
    }

    /**
     * Checks `$_POST` for the csrf token
     *
     * @param $key_prefix the same key prefix you passed to `$this->enable_csrf()`
     * @return true/false
     */
    public function csrf_is_valid(string $key_prefix=''): bool {
        // this attempts to do the checks listed on https://www.taeluf.com/blog/php/security/csrf-validation/

        $post_key = $this->get_csrf_post_key($key_prefix);
        if ($post_key=='')return false;
        $post_code = $_POST[$post_key];
        // because i unset from $_SESSION
        if (isset($this->valid_sessions[$post_key]))return true;

        if (session_status()==PHP_SESSION_NONE)session_start();
        if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception("Failed to start session. Cannot do csrf without session.");

        if (!isset($_SESSION[$post_key]))return false;

        $session_csrf = $_SESSION[$post_key];
        if ($session_csrf['code'] != $post_code) return false;
        if ($session_csrf['expires_at'] < time()) return false;
        $post_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

        if ($session_csrf['uri'] != ''
            &&$session_csrf['uri'] != $post_path
        )return false;
        if (!isset($_SERVER['HTTP_REFERER']))return false;
        $referer_domain = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST);
        // to remove the port (mainly bc of localhost testing)
        $server_host = parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST);

        if ($referer_domain != $server_host)return false;

        unset($_SESSION[$post_key]);
        $this->valid_sessions[$post_key] = true;
        return true;
    }

}