bitslip6/bitfire

View on GitHub
wordpress-plugin/bitfire-plugin.php

Summary

Maintainability
A
30 mins
Test Coverage
<?php
/**
 * The BitFire Wordpress bootstrap file
 *
 * registers the activation and deactivation functions, and defines a function
 * that starts the plugin if it has not started via auto_prepend_file.
 * 
 * This WordPress plugin uses the BitFire firewall library to perform all
 * security functions.  This plugin integrates the WordPress admin and plugin
 * pages with the library API.  Source available at github, see link below
 *
 * @link              http://bitfire.co
 * @source            https://github.com/bitslip6/bitfire
 * @since             1.8.0
 * @package           BitFire
 *
 * @wordpress-plugin
 * Plugin Name:       BitFire
 * Plugin URI:        https://bitfire.co/
 * Description:       Only RASP firewall for WordPress. Stop malware, redirects, back-doors and account takeover. 100% bot blocking, backups, malware cleaner.
 * Version:           __VERSION__
 * Author:            BitFire.co
 * License:           AGPL-3.0+
 * License URI:       https://www.gnu.org/licenses/agpl-3.0.en.html
 * Text Domain:       BitFire-Security
 * Domain Path:       /bitfire
 */

namespace BitFirePlugin;

use BitFire\BitFire;
use BitFire\Config as CFG;
use BitFire\MatchType;
use BitFire\Request;
use Exception;
use RuntimeException;
use ThreadFin\CacheStorage;
use ThreadFin\Effect;
use ThreadFin\FileData;
use ThreadFin\FileMod;
use WP_User;

use const BitFire\APP;
use const BitFire\CONFIG_REQUIRE_BROWSER;
use const BitFire\CONFIG_USER_TRACK_COOKIE;
use const BitFire\FILE_RW;
use const BitFire\FILE_W;
use const BitFire\STATUS_EACCES;
use const BitFire\WAF_INI;
use const BitFire\WAF_ROOT;
use const ThreadFin\ENCODE_RAW;

use function BitFire\block_now;
use function BitFire\make_sane_path;
use function BitFire\verify_browser_effect;
use function BitFireBot\send_browser_verification;
use function BitFirePRO\wp_requirement_check;
use function BitFireSvr\bf_deactivation_effect;
use function BitFireSvr\doc_root;
use function BitFireSvr\standalone_to_wordpress;
use function BitFireSvr\update_ini_value;
use function ThreadFin\contains;
use function ThreadFin\cookie;
use function ThreadFin\dbg;
use function ThreadFin\partial as BINDL;
use function ThreadFin\partial_right as BINDR;
use function ThreadFin\trace;
use function ThreadFin\debug;
use function ThreadFin\do_for_each;
use function ThreadFin\en_json;
use function ThreadFin\file_recurse;
use function ThreadFin\find_const_arr;
use function ThreadFin\find_fn;
use function ThreadFin\HTTP\http2;
use function ThreadFin\icontains;
use function ThreadFin\ip_to_country;
use function ThreadFin\parse_ini;
use function ThreadFin\un_json;

// If this file is called directly, abort.
if ( ! defined( "WPINC" ) ) { die(); }

// upgrade from standalone firewall to plugin
if (defined("BitFire\TYPE") && \BitFire\TYPE == "STANDALONE") { 
    require_once \BitFire\WAF_SRC."server.php";
    standalone_to_wordpress();
}

/**
 * Begins BitFire firewall, respects bitfire_enabled flag in config.ini
 * We might have already run the firewall if we are auto_prepend, so
 * check if we have loaded and do not double load.  This check
 * is also done in startup.php as a failsafe
 * @since    1.8.0
 */
if (!defined("\BitFire\WAF_ROOT") && !function_exists("\BitFire\on_err")) {
    $f =  __DIR__ . "/startup.php";
    if (file_exists($f)) {
        include_once $f;
    } else {
        return;
    }
    if (function_exists("\ThreadFin\\trace")) {
        trace("wp");
    } else {
        return;
    }
}



/**
 * @OVERRIDE dashboard url
 * @since 1.9.0 
 */
function dashboard_url(string $page_name) : string {
    $url = \admin_url("admin.php?page=$page_name");
    return $url;
}

/**
 * @OVERRIDE the default authentication function
 * @since 1.9.0
 */ 
function is_admin() : bool {
    if (function_exists('wp_get_current_user')) {
        $user = wp_get_current_user();
        if ( in_array( 'administrator', (array) $user->roles ) ) {
            return true;
        }
    }
    // delegate to bitfire if WordPress is not yet loaded...
    $bitfire = BitFire::get_instance();
    if (isset($bitfire->cookie) && $bitfire->cookie->extract('wp')->value('int') > 1) {
        return true;
    }
    return false;
}


/**
 * when a new admin user id added, set the mfa number to the current user until is is updated
 * @param mixed $user_id 
 * @return void 
 */
function user_add($user_id) {
    if (user_has_role($user_id, "Super Admin") || user_has_role($user_id, "Administrator")) {
        $my_id = get_current_user_id();
        $tel = get_user_meta($my_id, "bitfire_mfa_tel");
        user_edit($user_id, $tel);
    }
}

function user_has_role($user_id, $role_name) {
    $user_meta = get_userdata($user_id);
    $user_roles = $user_meta->roles;
    return in_array($role_name, $user_roles);
}



/**
 * create effect with error action if user is not admin
 * @since 1.9.0
 */
function verify_admin_effect(Request $request) : Effect {
    trace("va");
    return ($request->get["BITFIRE_API"]??"" == "send_mfa") || (is_admin())  
        ? Effect::$NULL
        : Effect::new()->exit(true, STATUS_EACCES, "requires admin access");
}

/**
 * @OVERRIDE for cms_root() function
 * @since 1.9.1
 */
function find_cms_root() : ?string {
    // prefer code
    if (function_exists('get_home_path')) { trace("HOME_PATH"); $root = \get_home_path(); }
    // then constant
    if (defined("ABSPATH")) { trace("ABS_PATH"); $root = \ABSPATH; }
    // fall back to config file if we are running in front of WordPress 
    // could be dead code here...
    $cfg_path = CFG::str("cms_root");
    if (contains($cfg_path, $_SERVER["DOCUMENT_ROOT"]) && file_exists("$cfg_path/wp-config.php")) { trace("CFG_PATH"); return $cfg_path; }
    $files = file_recurse($_SERVER["DOCUMENT_ROOT"], function($path) {
        if (file_exists($path)) {
            trace("FIND_PATH");
            return dirname($path);
        }
    }, "/wp-config.php/", [], 1);
    debug("files [%s]", print_r($files, true));

    // order all found wp-config files by directory length.
    // this can happen when a user backups a old wordpress install INSIDE
    // the current wordpress install.  We want to find the most recent
    usort($files, function($a, $b) { return strlen($a) <=> strlen($b); });

    if (isset($files[0]) && file_exists($files[0])) {
        foreach ($files as $file)
        trace("UPDATE_PATH");
        update_ini_value("cms_root", $files[0])->run();
        return $files[0];
    }

    return doc_root();
}




/**
 * The code that runs during plugin activation.
 * enable the firewall enable option, and install always on protection
 * on second activation (this is by design and based on "configured" flag)
 * TODO: move this code to -admin
 */
function activate_bitfire() {
    trace("wp_act");

    $check_files = [
        __DIR__ . "/src/util.php",
        __DIR__ . "/bitfire-plugin.php",
        __DIR__ . "/bitfire-admin.php",
        __DIR__ . "/src/botfilter.php",
        __DIR__ . "/src/bitfire.php",
        __DIR__ . "/src/cms.php",
        __DIR__ . "/src/const.php",
        __DIR__ . "/src/cuckoo.php",
        __DIR__ . "/src/dashboard.php",
        __DIR__ . "/src/db.php",
        __DIR__ . "/src/headers.php",
        __DIR__ . "/src/renderer.php",
        __DIR__ . "/src/server.php",
        __DIR__ . "/src/shmop.php",
        __DIR__ . "/src/storage.php",
        __DIR__ . "/src/tar.php",
        __DIR__ . "/src/webfilter.php",
        __DIR__ . "/src/wordpress.php"
    ];

    foreach ($check_files as $file) {
        if (!file_exists($file) || filesize($file) < 1024) {
            debug("missing file [%s]", $file);
            echo "plugin install failed, missing file [$file]";
            return;
        }
    }

    // make sure we can parse the config file
    parse_ini();
    include_once \plugin_dir_path(__FILE__) . "bitfire-admin.php";

    $file_name = ini_get("auto_prepend_file");
    if (contains($file_name, "bitfire")) {
        CacheStorage::get_instance()->save_data("parse_ini", null, -86400);
        \BitFireSvr\uninstall()->run();
        usleep(10000);
    }

    ob_start(function($x) { if(!empty($x)) { debug("PHP Warnings: [%s]\n", $x); } return $x; });

    // install data can be verbose, so redirect to install log
    \BitFire\Config::set_value("debug_file", true); 
    \BitFire\Config::set_value("debug_header", false);

    \BitfireSvr\bf_activation_effect()->hide_output()->run();

    if (!function_exists("BitFirePlugin\upgrade")) {
        require_once __DIR__ . "/bitfire-admin.php";
    }
    if (function_exists("BitFirePlugin\upgrade")) {
        \BitFirePlugin\upgrade()->run();
    }

    if (defined('WP_CONTENT_DIR') && file_exists('WP_CONTENT_DIR') && !file_exists(WP_CONTENT_DIR . "/bitfire")) {
        mkdir(WP_CONTENT_DIR . "/bitfire", 0775, false);
    }
    ob_end_clean();
}

/**
 * The code that runs during plugin deactivation.
 * toggle the firewall enable option, uninstall
 * TODO: move this code to -admin
 */
function deactivate_bitfire() {
    trace("deactivate");
    // install data can be verbose, so redirect to install log
    \BitFire\Config::set_value("debug_file", true);
    \BitFire\Config::set_value("debug_header", false);
    include_once \plugin_dir_path(__FILE__) . "bitfire-admin.php";
    \BitFireSvr\bf_deactivation_effect()->hide_output()->run();
    debug(trace());
    @chmod(\BitFire\WAF_INI, FILE_W);
}





/**
 * render the MFA page and exit script execution
 * TODO: refactor with a view
 * @param string $msg 
 * @return void 
 */
function render_mfa_page($msg = "Please enter the access code just sent to the address on record.") {
    $content = '
    <form name="loginform" id="loginform" action="%s" method="post">
        <p>
            <label for="user_login">Enter the Access Code just sent to the address on record</label>
            <input type="text" name="log" id="user_login" class="input" value="%s" size="20" autocapitalize="off" autocomplete="off" disabled="disabled" />
            <input type="hidden" name="log" id="user_login" class="input" value="%s" size="20" autocapitalize="off" autocomplete="off" />
        </p>

        <div class="user-pass-wrap">
            <label for="user_pass">Password</label>
            <div class="wp-pwd">
                <input type="password" name="pwd" id="user_pass" class="input password-input" value="%s" size="20" autocomplete="off" disabled="disabled" />
                <input type="hidden" name="pwd" id="user_pass" class="input password-input" value="%s" size="20" autocomplete="off" />
            </div>
        </div>
        <hr>
<div class="user-mfa-wrap">
<label for="user_mfa">Access Code</label>
<div class="wp-mfa">
    <input type="number" name="bitfire_mfa" id="bitfire_mfa" class="input" value="" size="6" autocomplete="off" />
</div>
</div>
        <p class="forgetmenot"><input name="rememberme" type="checkbox" id="rememberme" value="forever" %s /> <label for="rememberme">Remember Me</label></p>
        <p class="submit">
            <input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large" value="Log In" />
                                <input type="hidden" name="redirect_to" value="%s" />
                                <input type="hidden" name="testcookie" value="1" />
        </p>
    </form>
    <script type="text/javascript">document.getElementById("bitfire_mfa").focus();</script>
';


    \wp_add_inline_script("bitfire-mfa-focus", "document.getElementById(\"bitfire_mfa\").focus();");
    echo \login_header("MFA Code Required", "<p class='message'>".esc_html($msg)."</p>");
    printf($content, 
        \wp_login_url(
            esc_url($_GET["redirect_to"])),
            esc_attr($_POST["log"]),
            esc_attr($_POST["log"]),
            esc_attr($_POST["pwd"]),
            esc_attr($_POST["pwd"]),
            ($_REQUEST["rememberme"]??false) ? "checked" : "",
            esc_attr($_POST["redirect_to"]));
    echo \login_footer("bitfire_mfa");
}

/**
 * make a JavaScript browser challenge.  just the inline script content.
 * Effect contains, cache updates and script contents.
 * This function will set the encrypted JWT with the answer
 * @return Effect 
 * @throws Exception 
 */
function make_js_challenge_effect() : Effect {
    $ip_data  = BitFire::get_instance()->bot_filter->ip_data;
    $request  = BitFire::get_instance()->_request;
    $block_effect = send_browser_verification($ip_data, $request, false);

    // we must send the cookie here before the headers are sent...
    $cookie_effect = Effect::new()->cookie($block_effect->read_cookie(), "wp_make_challenge");
    $cookie_effect->run();

    // don't change the response code, cookie, exit or send cache headers
    $alert_effect = $block_effect->response_code(0)->exit(false)->cookie("", "wp_clear_challenge")->clear_headers();
    $alert_effect->out(wp_get_inline_script_tag($block_effect->read_out()), ENCODE_RAW, true);

    return $alert_effect;
}



/**
 * todo: move to -admin or server.php
 */
function bitfire_plugin_check() {
    @include_once WAF_ROOT . "includes.php";
    $all_dirs = malware_scan_dirs(CFG::str("cms_content"));

    $plugins = ["slug" => $_SERVER['SERVER_NAME']];
    foreach ($all_dirs as $dir) {
        if (contains($dir, ["wp-includes", "wp-admin"])) { continue; }
        $plugin = basename($dir);
        $file = FileData::new("{$dir}/readme.txt")->read()->filter(BINDR("\ThreadFin\icontains", "Stable Tag"));
        $line = implode(" ", $file->lines);
        $parts = explode(":", $line);
        if (isset($parts[1])) {
            $plugins[$plugin] = trim($parts[1]);
        } else {
            file_recurse($dir, function ($x) use (&$plugins, $plugin) {
                $file = FileData::new($x)->read()->filter(BINDR("\ThreadFin\icontains", " Version: "));
                if (!empty($file->lines)) {
                    $line = implode(" ", $file->lines);
                    $parts = explode(":", $line);
                    $plugins[$plugin] = trim($parts[1]);
                    return true;
                }
                return false;
            }, "/.*.php/", [], 1); 
        }
    }

    $encoded = base64_encode(en_json($plugins));
    $result = http2("POST", APP."cve_check.php", $encoded, ["Content-Type: application/json"]);
    $content_dir = CFG::str("cms_content_dir");
    if (empty($content_dir) || ($content_dir == DIRECTORY_SEPARATOR)) {
        if (defined(WP_CONTENT_DIR)) {
            $content_dir = WP_CONTENT_DIR;
        } else {
            $content_dir = dirname(__DIR__, 2);
        }
    }
    $effect = Effect::new()->file(new FileMod($content_dir."/plugins/bitfire/cache/plugins.json", $result->content, FILE_W));
    $effect->run();
}

// we must do this here because by the time bitfire-admin.php loads, content has already been
// rendered.  Don't want to introduce dependency on WordPress with admin-ajax.php calls
function bitfire_init() {
    trace("init");

    add_action("bitfire_plugin_check", "BitFirePlugin\bitfire_plugin_check");
    if (! wp_next_scheduled( 'bitfire_plugin_check' ) ) {
        wp_schedule_event( time(), 'hourly', 'bitfire_plugin_check' );
    }

    if (is_user_logged_in()) {
        trace("lgnin");
        include_once \plugin_dir_path(__FILE__) . "bitfire-admin.php";

        // make sure we have authentication correct
        bf_auth_effect()->run();
    }

    // TODO: move this to -admin
    if (CFG::enabled("pro_mfa") && function_exists("\BitFirePRO\sms")) {
        function mfa_field($user) { echo \BitFirePRO\wp_render_user_form($user); }
        // mfa field display
        add_action('show_user_profile', '\BitFirePlugin\mfa_field');
        add_action('edit_user_profile', '\BitFirePlugin\mfa_field');
        // mfa field update
        add_action("edit_user_profile_update", "\BitFirePlugin\user_edit");
        add_action("personal_options_update", "\BitFirePlugin\user_edit");
        add_action("user_register", "\BitFirePlugin\user_add");
    }

    // we want to run API function calls here AFTER loading.
    // this ensures that all overrides are loaded before we run the API
    if (isset($_REQUEST[\BitFire\BITFIRE_COMMAND])) {
        trace("wp_api");
        require_once \BitFire\WAF_SRC."server.php";
        require_once \BitFire\WAF_SRC."api.php";
        $request = \BitFire\BitFire::get_instance()->_request;
        \BitFire\api_call($request)->exit(true)->run();
    }

    // MFA authentication, but only on the login page
    $path = BitFire::get_instance()->_request->path;
    if (contains($path, "wp-login.php")) {
        // show the MFA form if we are on the login page and we are configured to use MFA
        if (CFG::enabled("pro_mfa") && function_exists("\BitFirePRO\wp_render_mfa_page")) {
            trace("wp_login");
            add_action("wp_login", "\BitFirePRO\wp_user_login", 60, 1);
        }
        add_action("check_password", "\BitFirePlugin\user_login", 100, 4);
    }

}

/**
 * update last login time info
 * @param mixed $check 
 * @param mixed $password 
 * @param mixed $hash 
 * @param mixed $user_id 
 * @return void 
 * @throws RuntimeException 
 */
function user_login($check, $password, $hash, $user_id) {
    if ($check) {
        $country_info = un_json(file_get_contents(\BitFire\WAF_ROOT . "cache/country.json"));
        $ip = $_SERVER[strtoupper(CFG::str("ip_header"))];
        $code = ip_to_country($ip);
        $country = $country_info[$code]??'N/A';
        $browser = BitFire::get_instance()->bot_filter->browser;
        if ($browser->ver == "x" || strlen($browser->browser) > 12) {
            $browser->ver = "unknown";
            $browser->browser = "unknown";
        }
        update_user_meta($user_id, "bitfire_last_login", time() . ":" . $browser->os . ":" . $browser->browser . ":" . $browser->ver . ":" . $country . ":" . $ip);
    }
    return $check;
}

/**
 * Add CSP Policy nonce if enabled
 * @param string $nonce 
 * @param null|string $script_tag 
 * @return string 
 */
function add_nonce(string $nonce, ?string $script_tag) : string {
    assert(!empty($script_tag), "cant add nonce to empty script tag");
    // only add the nonce if we don't have one
    if (!contains($script_tag, "nonce=")) {
        return preg_replace("/(id\s*=\s*[\"'].*?[\"'])/", "$1 nonce='$nonce'", $script_tag);
    }
    return $script_tag;
}
/**
 * wp_script_attribute filter for adding nonces.
 * TODO: add integrity check.  Needs an API callback to automatically store the hash integrity
 * @param string $nonce - the per page generated nonce
 * @param array $attributes the script attributes
 * @return array 
 */
function add_nonce_attr(string $nonce, array $attributes) : array {
    $attributes["nonce"] = $nonce;
    return $attributes;
}

/**
 * @param array<bool> $all_caps 
 * @param array<string> $caps 
 * @param array $args 0 - string requested cap, 1 - int user id, 2 - int object id
 * @param WP_User $user  the user being checked
 * @return array 
 */
function check_user_cap(?array $all_caps = null, ?array $caps = null, ?array $args = null, $user = null) : array {
    static $checks = [];

    if ($caps && count($caps) > 0) {
        $checks = array_merge($checks, $caps);
    }

    return ($all_caps != null) ? $all_caps : $checks;
}


/**
 * BEGIN MAIN PLUGIN CODE
 */

$i = BitFire::get_instance();
if (!$i->inspected) {
    $i->inspect();
}
if ($i->bot_filter) {
    $r = $i->_request;
    $br = $i->bot_filter->browser;
    /**
     * if browser verification is in reporting mode, we need to append the JavaScript
     * and NOT block the request.
     */
    if (isset($br) && !$br->bot && CFG::is_report(CONFIG_REQUIRE_BROWSER) && (CFG::enabled('cookies_enabled') || CFG::str("cache_type") != 'nop')) {
        if ($br->valid < 2) {
            debug("Bot Checking: [%s/%s] (%d)", $br->browser, $br->ver, $br->valid);
            // we may have to check this here if we are not running in always on mode
            if (isset($r->post['_bfxa']) || (strlen($r->post_raw) > 20 && contains($r->post_raw, '_bfxa'))) {
                $effect = verify_browser_effect($r, $i->bot_filter->ip_data, $i->cookie)->exit(false);
                $effect->run();
                // the request is an xmlhttp request, so we need to exit here
                if ($r->post['_fn'] === 'bfxa') {
                    debug("FN = BFXA");
                    exit();
                }
            } else {

            // this is ugly.  maybe a helper function to make this easier?
            // we need to send the cookie here, before the headers are sent
            // make_js_challenge will send the correct auth cookie here
            $js_challenge = make_js_challenge_effect();

            // now we print the <script> contents later inside the wp_footer action
            // effect contains the inline javascript and cache entry updates
            // run(print) the challenge effect at the bottom of the <body> tag
            add_action("wp_footer", function() use ($js_challenge) { 
                // make the challenge and send the cookie here (before headers are sent)
                // this function will split up the normal blocking effect into two parts
                // the first cookie effect will be run here, the second will be run in the action
                echo "<!-- BitFire wp_footer -->\n";
                $js_challenge->run();
            });
            }   
        }
        // don't challenge if the browser is already valid
        else {
            add_action("wp_footer", function() {
                echo "<!-- BitFire browser verification passed -->\n";
            });
        }
    }
}

// plugin run once wordpress is loaded
\add_action("wp_loaded", "BitFirePlugin\bitfire_init");
// update logout function to remove our cookie as well
\add_action("wp_logout", function() { \ThreadFin\cookie(CFG::str(CONFIG_USER_TRACK_COOKIE), null, -1); });
// keep the db transaction log up to date for differential database backups
if (CFG::enabled("rasp_db") && function_exists("\BitFirePRO\query_filter")) {
    \add_filter("query", "BitFirePRO\query_filter");
}
// disable xmlrpc
if (CFG::enabled("block_xmlrpc")) {
    \add_filter("xmlrpc_enabled", function(){ return false; });
}
// make sure users have the correct capabilities
//add_filter("user_has_cap", "BitFirePlugin\check_user_cap", 10, 4);
 
// todo: move this to -admin
\register_activation_hook(__FILE__, 'BitFirePlugin\activate_bitfire');
\register_deactivation_hook(__FILE__, 'BitFirePlugin\deactivate_bitfire');




if (function_exists('BitFirePRO\\verify_user_id') && CFG::enabled("rasp_auth")) {
    add_action("auth_cookie_valid", "BitFirePRO\\verify_user_id", 9, 2);
    add_filter("determine_current_user", "BitFirePRO\\verify_user_id", 65535, 1);
    add_action("application_password_did_authenticate", "BitFirePRO\\verify_user_id", 65535, 1);
}



if (CFG::enabled("csp_policy_enabled")) {
    $nonce = CFG::str("csp_nonce", \ThreadFin\random_str(16));
    // script nonces. Prefer new attribute style for >= 5.7
    if (version_compare($GLOBALS["wp_version"]??"4.0", "5.7") >= 0) {
        add_filter("wp_script_attributes", BINDL("\BitFirePlugin\add_nonce_attr", $nonce));
        add_filter("wp_inline_script_attributes", BINDL("\BitFirePlugin\add_nonce_attr", $nonce));
    }
}

// make sure important WordPress calls are legitimate 
// TODO: improve this by integrating to the main inspection engine
if (function_exists('BitFirePRO\wp_requirement_check') && !wp_requirement_check()) {
    $i = BitFire::get_instance();
    $request = $i->_request;

    block_now(31001, "HTTP Referer", $request->headers->referer, $request->host, 0, null, "You may only add new users from the add new user page")->run();
}