<?php
/**
 * BitFire PHP based Firewall.
 * Author: BitFire (BitSlip6 company)
 * Distributed under the AGPL license: https://www.gnu.org/licenses/agpl-3.0.en.html
 * Please report issues to: https://github.com/bitslip6/bitfire/issues
 * 
 * all functions are called via api_call() from bitfire.php and all authentication 
 * is done there before calling any of these methods.
 */

namespace BitFireHeader;

use BitFire\UserAgent;
use BitFire\Config as CFG;
use ThreadFin\CacheItem;
use ThreadFin\CacheStorage;
use ThreadFin\Effect;
use ThreadFin\FileData;

use const BitFire\BLOCK_MEDIUM;
use const BitFire\CACHE_HIGH;
use const BitFire\FAIL_ENUMERATION;
use const BitFire\FAIL_SSL_UPGRADE;
use const ThreadFin\DAY;

use function BitFire\flatten;
use function ThreadFin\contains;
use function ThreadFin\cookie;
use function ThreadFin\dbg;
use function ThreadFin\ends_with;
use function ThreadFin\ƒ_id;
use function ThreadFin\ƒ_inc;
use function ThreadFin\trace;
use function ThreadFin\partial as ƒixl;

const FEATURE_POLICY = array('accelerometer' => 'self', 'ambient-light-sensor' => 'self', 'autoplay' => 'self', 'camera' => 'self', 'geolocation' => '*', 'midi' => 'self', 'notifications' => 'self', 'push' => 'self', 'sync-xhr' => 'self', 'microphone' => 'self', 'gyroscope' => 'self', 'speaker' => 'self', 'vibrate' => 'self', 'fullscreen' => 'self', 'payment' => '*');
const FEATURE_NAMES = array('geolocation', 'midi', 'notifications', 'push', 'sync-xhr', 'microphone', 'gyroscope', 'speaker', 'vibrate', 'fullscreen', 'payment');

const CSP = array('child-src', 'connect-src', 'default-src', 'font-src',
            'frame-src', 'img-src', 'manifest-src', 'media-src', 'object-src', 'prefetch-src',
            'script-src', 'style-src', 'style-src-elem', 'script-src-attr', 'style-src', 
            'style-src-elem', 'style-src-attr', 'worker-src');




// block plugin enumeration.  Do not call if user is a WP admin...
function block_plugin_enumeration(\BitFire\Request $request) : Effect {
    $pass_effect = Effect::create();

    // if we are not configured to block plugin scans, just return
    if (!CFG::enabled("wp_block_scanners")) {
        return $pass_effect;
    }
    // if the request is not for a NON-EXISTING plugin, just return - don't modify for active plugins!
    if (! ($request->classification & \BitFire\REQ_NO_PLUGIN)) {
        return $pass_effect;
    }

    $filename = basename(strtolower($request->path));
    $plugin = $filename;


    if (preg_match("/content\/(plugins|themes)\/([^\/]+)/", $request->path, $matches)) {
        $plugin = $matches[2] ?? '';
    }


    // select random response type
    $path    = trim($matches[1]??"");
    $results = [404, 403, 200];
    $result  = $results[array_rand($results)];
    $version = mt_rand(1, 5) .".". mt_rand(1, 9) ."." . mt_rand(1,9);
    $stat_id = floor(FAIL_ENUMERATION / 1000);


    // Deterministic "namespace" so output looks stable per plugin + site
    $siteKey = (function_exists('site_url') ? site_url() : ($_SERVER['HTTP_HOST'] ?? 'localhost'));
    $seed    = sha1($filename . '|' . $siteKey);
    $ns      = substr($seed, 0, 6);              // short namespace
    $class   = "$filename-$ns";
    $scope   = $class;


    // force head/post methods to return 403, nginx default behavior
    if ($request->method == "HEAD" || $request->method == "POST") {
        $result = 403;
    }

    
    // plugin version check
    if (ends_with($filename, "css")) {
        $pass_effect->header('Content-Type', 'text/css; charset=UTF-8');
        $file = FileData::create(\BitFire\WAF_ROOT."/views/fake.css")->read();
    } else if (ends_with($filename, "js")) {
        $pass_effect->header('Content-Type', 'application/javascript; charset=UTF-8');
        $file = FileData::create(\BitFire\WAF_ROOT."/views/fake.js")->read();
    } else {
        $pass_effect->header('Content-Type', 'text/html; charset=UTF-8');
        $file = FileData::create(\BitFire\WAF_ROOT."/views/nginx.$result.html")->read();
    }

    $content = $file->map(ƒixl("str_replace", 
        ['__VERSION__', '__SERVER__', '{$pluginSlug}', '{$reqPath}', '{$ns}','{$scope}'],
        [$version, $_SERVER['SERVER_SOFTWARE']??'nginx/1.28.0', $plugin, $filename, $ns, $scope]))->raw();


    // create an effect to send the response
    $pass_effect
        ->exit(true)
        ->header("Content-Length", strlen($content))
        ->header("Cache-Control", "public, max-age=86400, immutable")
        ->header("X-Content-Type-Options", "nosniff")
        ->header("Vary", "Accept-Encoding")
        ->header("X-Robots-Tag", "noindex, nofollow, noarchive")

        ->response_code($result)
        ->update(new CacheItem("STATS_$stat_id", \Threadfin\ƒ_inc(1), \Threadfin\ƒ_id(0), DAY * 14, CACHE_HIGH));

    // don't output data for the HEAD method
    if ($request->method != "HEAD") {
        $pass_effect->out($content);
    }

    return $pass_effect;
}


/**
 * add the security headers from config
 */
function send_security_headers(\BitFire\Request $request, UserAgent $agent) : Effect {
    // GUARD, should never be hit
	if (php_sapi_name() == "cli" || headers_sent()) { trace("HDR_SENT"); return Effect::create(); }
    trace("HDR");

    $effect = Effect::create();
    if (\Bitfire\Config::enabled("security_headers_enabled")) {
        $effect->chain(core_headers($agent));
    }

    // device permission policy
    if (CFG::enabled('permission_policy')) {
        $effect->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
    }

    // cross origin resource policy
    $policy = CFG::str("cor_policy", "");
    if (!empty($policy)) {
        if (in_array($policy, ["same-site", "same-origin", "cross-origin"])) {
            $effect->header("Cross-Origin-Resource-Policy", $policy);
        }
    }

    // set strict transport security (HSTS)
    if (\Bitfire\Config::enabled("enforce_ssl_1year")) {
        $effect->chain(force_ssl_with_sts());
    }

    // content security policy
    if (\Bitfire\Config::enabled("csp_policy_enabled") && !contains($request->path, "/wp-admin")) {
        $effect = csp_headers($request, $effect);
    }
 
    return $effect;
}

/**
 * create an effect to set http security headers
 * @param UserAgent $agent 
 * @return Effect 
 */
function core_headers(?UserAgent $agent) : Effect {
    // seems excessive to add effect support for removing headers
    header_remove('X-Powered-By');
    header_remove('Server');

    $effect = Effect::create();

    $effect->header("X-Frame-Options", "sameorigin");
    $effect->header("X-Content-Type-Options", "nosniff");
    $effect->header("Referrer-Policy", "strict-origin-when-cross-origin");

    // only turn on the XSS auditor for older browsers
    if ($agent) {
        if (($agent->browser_name == "chrome" && version_compare($agent->ver, "78.0") < 0)
            || ($agent->browser_name == "edge" && version_compare($agent->ver, "17.0") < 0)
            || ($agent->browser_name == "explorer") || ($agent->browser_name == "safari")) {

            
            $effect->header("X-XSS-Protection", ": 1; mode=block");
            trace("oldbr");
        }
    }

    // block for php-sploit framework here, exit with a server error code...
    if ($_SERVER['HTTP_ACCEPT_ENCODING']??'' == "identity") {
        foreach ($_SERVER as $key => $value) {
            if (!is_string($value)) {
                $value = flatten($value);
            }
            if (!empty($value) && stripos($value, "eval(base64_decode") !== false) {
                \BitFire\block_now(16050, $key, $value, 'eval(base64_', BLOCK_MEDIUM)->run();
            }
        }
    }

    return $effect;
}

/**
 * force redirect to https and enable STS
 * @return Effect 
 */
function force_ssl_with_sts() : Effect {
    $effect = Effect::create();
    $effect->header('Strict-Transport-Security', 'max-age=31536000; preload');
    // find the request scheme (ssl/tls?)
    $scheme = ($_SERVER['HTTP_X_FORWARDED_PROTO']??$_SERVER['HTTP_X_FORWARDED_PROTOCOL']??$_SERVER['HTTP_X_URL_SCHEME']??$_SERVER['REQUEST_SCHEME']??'http');
    // force encryption
    if ($scheme === 'http') {
        if ((empty($_SERVER['HTTPS']) || strtolower($_SERVER['HTTPS']) != 'on') &&
            strtolower($_SERVER['HTTP_X_FORWARDED_SSL']??"") != 'on') {

            // Don't double redirect HTTPS!
            if (!isset($_COOKIE['x-bf-ssl'])) {
                $cache = CacheStorage::get_instance();
                $key_id = floor(FAIL_SSL_UPGRADE / 1000);
                $cache->update_data("STATS_$key_id", ƒ_inc(1), ƒ_id(0), 86400*14, CACHE_HIGH);
                $host = filter_input(INPUT_SERVER, 'HTTP_HOST', FILTER_SANITIZE_URL);
                $uri = filter_input(INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL);
                $effect->header('Location', "https://{$host}{$uri}");
                $effect>cookie('x-bf-ssl', '1', 0);
                $effect->exit(true);
            }
        }
    }

    return $effect;
}

// content security policy
function csp_headers(\BitFire\Request $request, \ThreadFin\Effect $effect) : \ThreadFin\Effect
{

    // set a default feature policy
    if (CFG::enabled("csp_policy_enabled") && !contains($request->path, "/wp-admin")) {
        \ThreadFin\trace("csp");
        $report_only = CFG::int("csp_enable_time", 0) > time();

        $header_name = $report_only ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";

        // map the config csp to a more usable KVP format
        $policy = [];
        foreach(\BitFire\Config::arr("csp_policy") as $policy_name => $value) {
            $policy[$policy_name] = $value;
        }

        $csp = \ThreadFin\map_reduce($policy, function($key, $value, $carry) use ($request) {
            // skip empty policies
            if (empty($value)) { return $carry; }
            // concat the key and value, add nonce to script-src only
            $fixed_value = $value;
            if ($key === "default-src") {
                if (!contains($request->path, "wp-admin") && !contains($value, 'unsafe-inline')) {
                    $nonce = CFG::str("csp_nonce");
                    $fixed_value .= " 'nonce-$nonce' ";
                }
            }
            return "$carry $key $fixed_value; "; 
        }, !$report_only ? "upgrade-insecure-requests; " : " ");

        $effect->header($header_name, $csp);
    }

    return $effect;
}


 