<?php
/**
 * RestHardening — TitanCart core REST/login attack-surface hardening.
 *
 * Filed against the 2026-08-04 titancartdemo.com incident (see
 * docs/SECURITY-INCIDENT-titancartdemo-batch-bruteforce.md). Two surfaces were
 * abused in that attack and both are closed here, in CORE, so every TitanCart
 * store — Lite or Pro — inherits the defence, not just the (now-retired) demo:
 *
 *   1. The WordPress REST BATCH endpoint (/wp-json/batch/v1, ?rest_route=/batch/v1).
 *      It bundles many REST calls into ONE HTTP request, which turns it into a
 *      brute-force / enumeration AMPLIFIER: a single POST can carry dozens of
 *      attempts, multiplying throughput and slipping past per-request rate
 *      limits and most WAF request-count rules. The incident log showed ~40
 *      182 KB batch POSTs in 90s immediately before a login succeeded. An
 *      eCommerce storefront has no legitimate use for client batch calls.
 *
 *   2. wp-login.php credential brute-force. Core WordPress applies no throttle;
 *      the batch amplifier fed a login-guessing loop that eventually cracked a
 *      password (2FA, separately, blocked the interactive session — but the
 *      credential was still compromised). A light, IP-scoped lockout raises the
 *      cost of online guessing enormously without touching legitimate logins.
 *
 * Design principles:
 *   - CORE, not a module: this must run on Lite too, so it lives in Core and is
 *     booted directly from Plugin::boot() with NO Edition/entitlement gate.
 *   - Zero WordPress coupling in the decision logic where practical; the WP glue
 *     (filters, transients as the lockout store) is thin and swappable — the
 *     throttle math is portable to the standalone engine.
 *   - Safe defaults, fully filterable. Every knob has a `titancart_security_*`
 *     filter so a store with a genuine need (a headless build that really does
 *     batch, an internal tool behind a VPN) can adjust without hacking core.
 *   - Fail OPEN on our own errors: a bug in hardening must never lock a
 *     legitimate admin out of their own store. The lockout only ever adds
 *     friction to REPEATED FAILED logins, never to a correct one.
 *
 * @package TitanCart\Core
 */

namespace TitanCart\Core;

defined( 'ABSPATH' ) || exit;

class RestHardening {

    /** Transient key prefix for per-IP failed-login counters. */
    private const LOCKOUT_PREFIX = 'titancart_lgn_fail_';

    /** Transient key prefix for active per-IP lockouts. */
    private const LOCKOUT_ACTIVE_PREFIX = 'titancart_lgn_lock_';

    /**
     * Wire all hardening filters. Called once from Plugin::boot(), unconditionally
     * (Lite included). Idempotent.
     */
    public static function init(): void {
        static $booted = false;
        if ( $booted ) {
            return;
        }
        $booted = true;

        // ── 1. REST batch endpoint ────────────────────────────────────────────
        // Refuse to register the batch/v1 routes at all …
        add_filter( 'rest_endpoints', [ self::class, 'strip_batch_endpoints' ], 99 );
        // … and hard-stop any request that still reaches dispatch for that route.
        add_filter( 'rest_pre_dispatch', [ self::class, 'block_batch_dispatch' ], 0, 3 );

        // ── 2. Login brute-force throttle ─────────────────────────────────────
        // Reject the login attempt BEFORE credential checking when the IP is
        // locked out; count failures; clear the counter on success.
        add_filter( 'authenticate', [ self::class, 'gate_login' ], 30, 1 );
        add_action( 'wp_login_failed', [ self::class, 'record_failed_login' ], 10, 1 );
        add_action( 'wp_login', [ self::class, 'clear_login_failures' ], 10, 2 );
    }

    /* ══════════════════════════════════════════════════════════════════════════
     * 1) REST BATCH ENDPOINT
     * ══════════════════════════════════════════════════════════════════════════ */

    /**
     * Whether the batch endpoint should be blocked. Default TRUE.
     * A store that genuinely needs client batching can return false via
     * `titancart_security_disable_rest_batch`.
     */
    public static function batch_blocked(): bool {
        return (bool) apply_filters( 'titancart_security_disable_rest_batch', true );
    }

    /**
     * Drop every route under the batch/v1 namespace before it registers.
     *
     * @param array $endpoints Route => handlers map.
     * @return array
     */
    public static function strip_batch_endpoints( $endpoints ) {
        if ( ! is_array( $endpoints ) || ! self::batch_blocked() ) {
            return $endpoints;
        }
        foreach ( array_keys( $endpoints ) as $route ) {
            if ( is_string( $route ) && strpos( $route, '/batch/v1' ) === 0 ) {
                unset( $endpoints[ $route ] );
            }
        }
        return $endpoints;
    }

    /**
     * Belt-and-braces: 403 any request whose route is under batch/ before it
     * dispatches, covering both pretty and ?rest_route= permalink forms.
     *
     * @param mixed            $result  Short-circuit value (null = continue).
     * @param \WP_REST_Server  $server  Server instance.
     * @param \WP_REST_Request $request Current request.
     * @return mixed
     */
    public static function block_batch_dispatch( $result, $server, $request ) {
        if ( $result !== null || ! self::batch_blocked() ) {
            return $result;
        }
        $route = is_object( $request ) && method_exists( $request, 'get_route' )
            ? (string) $request->get_route()
            : '';
        if ( $route !== '' && strpos( ltrim( $route, '/' ), 'batch/' ) === 0 ) {
            return new \WP_Error(
                'tc_batch_disabled',
                __( 'The REST batch endpoint is disabled on this site.', 'titancart-lite' ),
                [ 'status' => 403 ]
            );
        }
        return $result;
    }

    /* ══════════════════════════════════════════════════════════════════════════
     * 2) LOGIN BRUTE-FORCE THROTTLE
     * ══════════════════════════════════════════════════════════════════════════ */

    /**
     * Failed attempts from one IP within the window before a lockout triggers.
     * Default 8 — comfortably above fat-finger territory, far below what a brute
     * needs. Filter: `titancart_security_login_max_attempts`.
     */
    public static function max_attempts(): int {
        return max( 3, (int) apply_filters( 'titancart_security_login_max_attempts', 8 ) );
    }

    /**
     * Sliding window (seconds) over which failures are counted. Default 15 min.
     * Filter: `titancart_security_login_window`.
     */
    public static function window_seconds(): int {
        return max( 60, (int) apply_filters( 'titancart_security_login_window', 15 * MINUTE_IN_SECONDS ) );
    }

    /**
     * Lockout duration (seconds) once the threshold is hit. Default 20 min.
     * Filter: `titancart_security_login_lockout`.
     */
    public static function lockout_seconds(): int {
        return max( 60, (int) apply_filters( 'titancart_security_login_lockout', 20 * MINUTE_IN_SECONDS ) );
    }

    /**
     * Whether the login throttle is active at all. Default TRUE. A store fronted
     * by a WAF/Cloudflare rule that already throttles can disable it via
     * `titancart_security_login_throttle`.
     */
    public static function throttle_enabled(): bool {
        return (bool) apply_filters( 'titancart_security_login_throttle', true );
    }

    /**
     * Best-effort client IP. Behind a trusted proxy (Cloudflare/Plesk) the real
     * client is in a forwarded header, but those are spoofable, so we only trust
     * a forwarded header when the store explicitly opts in via
     * `titancart_security_trust_proxy` — otherwise REMOTE_ADDR (the proxy) is
     * used, which throttles per-edge and is still safe (never blocks a good
     * login, only slows a flood). Returns '' if unknowable → caller no-ops.
     */
    public static function client_ip(): string {
        $trust_proxy = (bool) apply_filters( 'titancart_security_trust_proxy', false );
        if ( $trust_proxy && ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
            $ip = sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_CF_CONNECTING_IP'] ) ); // WP.org R1 🅱2: unslash+sanitize before the FILTER_VALIDATE_IP below.
        } elseif ( $trust_proxy && ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
            // First hop in the list is the origin client.
            $parts = explode( ',', sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ); // WP.org R1 🅱2: unslash+sanitize before the FILTER_VALIDATE_IP below.
            $ip    = trim( $parts[0] );
        } else {
            $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : ''; // WP.org R1 🅱2: unslash+sanitize before the FILTER_VALIDATE_IP below.
        }
        $ip = filter_var( $ip, FILTER_VALIDATE_IP );
        return $ip ? $ip : '';
    }

    /**
     * Gate a login attempt. Runs on `authenticate` (pri 30, after WP's own
     * username/password checks are wired but this returns EARLY when locked so
     * the credential is never even evaluated). Returns a WP_Error to block, or
     * passes $user through untouched otherwise.
     *
     * @param null|\WP_User|\WP_Error $user Auth pipeline value.
     * @return null|\WP_User|\WP_Error
     */
    public static function gate_login( $user ) {
        if ( ! self::throttle_enabled() ) {
            return $user;
        }
        $ip = self::client_ip();
        if ( $ip === '' ) {
            return $user; // Can't identify the client → never block.
        }
        if ( get_transient( self::LOCKOUT_ACTIVE_PREFIX . md5( $ip ) ) ) {
            return new \WP_Error(
                'tc_login_locked',
                __( 'Too many failed attempts. Please wait a few minutes and try again.', 'titancart-lite' )
            );
        }
        return $user;
    }

    /**
     * Increment the failure counter for the client IP; trip the lockout at the
     * threshold. Hooked on `wp_login_failed`.
     *
     * @param string $username Attempted username (unused; we key on IP).
     */
    public static function record_failed_login( $username ): void {
        if ( ! self::throttle_enabled() ) {
            return;
        }
        $ip = self::client_ip();
        if ( $ip === '' ) {
            return;
        }
        $hash    = md5( $ip );
        $key     = self::LOCKOUT_PREFIX . $hash;
        $count   = (int) get_transient( $key );
        $count++;
        set_transient( $key, $count, self::window_seconds() );

        if ( $count >= self::max_attempts() ) {
            set_transient( self::LOCKOUT_ACTIVE_PREFIX . $hash, 1, self::lockout_seconds() );
            delete_transient( $key );
            /**
             * Fires when an IP is locked out for repeated login failures. Lets a
             * store log/alert/permanently-ban at its discretion.
             *
             * @param string $ip The locked-out client IP.
             */
            do_action( 'titancart_security_login_locked', $ip );
        }
    }

    /**
     * Clear the failure counter + lockout on a successful login. Hooked on
     * `wp_login`.
     *
     * @param string    $user_login Username.
     * @param \WP_User   $user       The user (unused).
     */
    public static function clear_login_failures( $user_login, $user = null ): void {
        $ip = self::client_ip();
        if ( $ip === '' ) {
            return;
        }
        $hash = md5( $ip );
        delete_transient( self::LOCKOUT_PREFIX . $hash );
        delete_transient( self::LOCKOUT_ACTIVE_PREFIX . $hash );
    }
}
