diff --git a/README.md b/README.md index b10f47f..b0fc6c3 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # Baskerville WordPress Plugin -A WordPress security plugin with GeoIP-based access control, AI-powered bot detection, Cloudflare Turnstile integration, and advanced fingerprinting. +A WordPress security plugin with GeoIP-based access control, AI-powered bot detection, CAPTCHA challenge support, and advanced fingerprinting. ## Features - πŸ›‘οΈ **AI-Powered Bot Detection** - Classification of bots vs. humans with configurable thresholds - 🌍 **GeoIP Access Control** - Block or allow traffic by country (whitelist/blacklist) - πŸ” **Browser Fingerprinting** - Advanced client-side fingerprinting with Canvas, WebGL, Audio -- ☁️ **Cloudflare Turnstile** - CAPTCHA challenge for borderline bot scores with precision analytics +- 🧩 **Baskerville Gatekeeper** - Built-in state-space puzzle CAPTCHA (no API keys, powered by captcha.openports.dev) +- ☁️ **Cloudflare Turnstile** - Alternative CAPTCHA via Cloudflare (requires API keys) - 🍯 **Honeypot Detection** - Hidden links to catch AI crawlers - πŸ“Š **Traffic Analytics** - Real-time statistics, live feed, and Turnstile precision metrics - ⚑ **Performance Optimized** - Minimal overhead (~1ms with page cache, ~30-50ms without) @@ -72,28 +73,39 @@ zip -r9 baskerville.zip baskerville/ \ - Development environments - Monitoring services -### Cloudflare Turnstile +### Challenge Provider -Turnstile provides a CAPTCHA-like challenge for visitors with borderline bot scores, allowing legitimate users to prove they're human instead of being blocked outright. +Go to **Settings β†’ Baskerville β†’ Challenge** to select and configure the challenge system shown to borderline visitors. -1. Go to **Settings β†’ Baskerville β†’ Turnstile** -2. Get your Site Key and Secret Key from [Cloudflare Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) -3. Enter the keys and enable Turnstile -4. Configure the borderline score range (default: 40-70) +**Providers**: +- **Baskerville Gatekeeper** β€” built-in state-space puzzle CAPTCHA, no API keys needed +- **Cloudflare Turnstile** β€” Cloudflare's CAPTCHA widget, requires API keys +- **Disabled** (default) β€” no challenge shown; borderline visitors are blocked outright -**Settings**: -- **Bot Score Challenge** - Show Turnstile to visitors with scores in the borderline range -- **Score Range** - Define min/max bot score for challenge (e.g., 40-70) -- **Under Attack Mode** - Emergency mode that challenges ALL visitors (use during attacks) -- **Form Protection** - Protect login, registration, and comment forms +Both providers share the same trigger settings: +- **Bot Score Challenge** - Challenge visitors with scores in the borderline range +- **Score Range** - Define min/max bot score for challenge (default: 40-70) +- **Under Attack Mode** - Emergency mode that challenges ALL visitors **Score interpretation**: - 0-39: Likely human (allowed) -- 40-70: Borderline (show Turnstile challenge) +- 40-70: Borderline (optional challenge) - 71-100: Likely bot (blocked) +#### Baskerville Gatekeeper + +A puzzle-based CAPTCHA that uses a state-space search problem as the challenge. No third-party account required. Challenges are served **inline** at the original URL (no redirect to a separate page). + +When enabled, the plugin contacts `captcha.openports.dev` (operated by eQualitie) to generate and verify challenges. + +#### Cloudflare Turnstile + +1. Get your Site Key and Secret Key from [Cloudflare Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) +2. Select **Cloudflare Turnstile** as the provider and enter your keys +3. Configure the borderline score range (default: 40-70) + **Precision Analytics**: -The Analytics tab shows Turnstile effectiveness: +The Analytics tab shows challenge effectiveness: - **Redirects** - Number of challenges shown - **Passed** - Visitors who completed the challenge - **Failed** - Visitors who failed or abandoned (likely bots) @@ -270,6 +282,7 @@ baskerville/ β”‚ β”œβ”€β”€ class-baskerville-ai-ua.php # AI bot detection & classification β”‚ β”œβ”€β”€ class-baskerville-stats.php # Analytics & database logging β”‚ β”œβ”€β”€ class-baskerville-rest.php # REST API for fingerprinting +β”‚ β”œβ”€β”€ class-baskerville-gatekeeper.php # Baskerville Gatekeeper CAPTCHA integration β”‚ β”œβ”€β”€ class-baskerville-turnstile.php # Cloudflare Turnstile integration β”‚ └── class-baskerville-honeypot.php # Honeypot for AI crawler detection β”œβ”€β”€ assets/ @@ -313,4 +326,354 @@ GPL v3 or later - Compatible with WordPress.org plugin directory requirements. ## Support -For issues and feature requests, please open an issue on GitHub. \ No newline at end of file +For issues and feature requests, please open an issue on GitHub. + + +--- + + +# Prediction Pipeline Baskerville WordPress Plugin Integration + +## Table of Contents + +- [Overview](#overview) +- [How the Two Components Work Together](#how-the-two-components-work-together) +- [Component 1: `baskerville-class-prediction-pipeline.php`](#component-1-baskerville-class-prediction-pipelinephp) + - [Purpose](#purpose) + - [What It Collects](#what-it-collects) + - [How It Sends Data to Baskerville](#how-it-sends-data-to-baskerville) + - [Why This Exists Separately from the Gatekeeper](#why-this-exists-separately-from-the-gatekeeper) +- [Component 2: `baskerville-class-gatekeeper.php`](#component-2-baskerville-class-gatekeeperphp) + - [Purpose](#purpose-1) + - [What Requests Pass Through This Path](#what-requests-pass-through-this-path) + - [High-Level Decision Flow](#high-level-decision-flow) + - [Challenge Issuance](#challenge-issuance) + - [Challenge Refresh](#challenge-refresh) + - [Challenge Verification](#challenge-verification) + - [Pass-Token Introspection](#pass-token-introspection) + - [Why Asset and Secondary Requests Are Skipped](#why-asset-and-secondary-requests-are-skipped) +- [End-to-End Flow Summary](#end-to-end-flow-summary) + +--- + +# Overview + +The WordPress integration is split into two distinct responsibilities: + +1. **Prediction pipeline integration** β€” responsible for collecting request data at the WordPress origin and sending it to the Baskerville machine learning pipeline. +2. **Gatekeeper enforcement** β€” responsible for enforcing challenge decisions on subsequent requests once the WordPress side has been told that a requester should be challenged. + +These two pieces solve different problems: + +- The **prediction pipeline** is about **observability and classification**. +- The **gatekeeper** is about **policy enforcement and challenge handling**. + +This separation is intentional. It keeps the data collection path independent from the challenge enforcement path, which makes the plugin easier to reason about and easier to evolve over time. The main plugin file initializes logging, loads the gatekeeper, defines the clearinghouse endpoint, gathers request metadata, and sends that metadata asynchronously to the Baskerville service on each request. + +--- + +# How the Two Components Work Together + +At a high level, the flow works like this: + +1. A request arrives at the WordPress origin. +2. The **prediction pipeline** collects request metadata and sends it to Baskerville for ML processing. +3. Server-side Baskerville systems process those logs and eventually determine whether that requester should be challenged. +4. That prediction or enforcement state is then pushed and stored on the WordPress side. +5. On later requests, the **gatekeeper** checks that stored state and decides whether to: + - allow the request through normally, + - issue a challenge, + - refresh an active challenge, + - verify a submitted challenge solution, + - or validate a previously issued pass token. + +So in aggregate: + +- `baskerville-class-prediction-pipeline.php` answers: + **β€œWhat data do we need to gather from this request so Baskerville can classify it?”** +- Note that I refer to `baskerville-class-prediction-pipeline.php` as `prediction pipeline` + +- `baskerville-class-gatekeeper.php` answers: + **β€œGiven what we already know about this requester, what should happen to this request right now?”** +- Note that I refer to `baskerville-class-gatekeeper.php` as `gatekeeper` + +The gatekeeper runs during `template_redirect` and acts as the enforcement entrypoint for normal frontend requests, while the prediction pipeline runs during `init` and handles the data collection path for ML ingestion. + +--- + +# Component 1: `baskerville-class-prediction-pipeline.php` + +## Purpose + +This file is the **main entrypoint for the Baskerville ML pipeline integration on the WordPress side**. + +Its responsibility is to gather all of the request metadata the Baskerville backend expects, package that data into the server-side schema, and send it asynchronously to the Baskerville clearinghouse endpoint for downstream ML processing. It is not responsible for issuing or verifying CAPTCHA challenges. Instead, it is the origin-side logging and telemetry component that feeds the prediction system. + +## What It Collects + +The prediction pipeline gathers request-level fields from `$_SERVER`, request headers, cookies, and other request metadata to construct the payload expected by the Baskerville backend. + +Examples include: + +- request method +- URL and query string +- host and original host +- content type +- user agent +- client IP +- accepted encodings +- language +- direct-traffic/referrer information +- conditional GET information +- Cloudflare-related fields when present +- placeholder user-agent and geo structures +- a cookie structure for fields the server-side schema expects + +These are assembled by helper functions such as: + +- `wpsec_get_all_headers()` +- `wpsec_build_worker_request()` + +which normalize incoming request data into the schema expected by the server-side ML pipeline. + +## How It Sends Data to Baskerville + +Once the request payload is built, the plugin sends it to the clearinghouse endpoint using `wp_remote_post()` with: + +- `Content-Type: application/json` +- the site API key +- the site URL + +The call is intentionally made with `blocking => false`, which means WordPress does not wait for the ML system to finish processing before continuing the page request. This keeps the origin-side request lightweight while still feeding the Baskerville backend with the data needed for classification. + +## Why This Exists Separately from the Gatekeeper + +The prediction pipeline and the gatekeeper solve different problems and operate on different timelines. + +The prediction pipeline: +- gathers raw request features, +- sends them to Baskerville, +- and enables later classification. + +The gatekeeper: +- consumes stored policy state on later requests, +- and decides whether to enforce a challenge. + +Keeping these concerns separate makes the plugin easier to understand: + +- one path is **classification input** +- the other is **classification enforcement** + +--- + +# Component 2: `baskerville-class-gatekeeper.php` + +## Purpose + +This file is the **enforcement engine**. + +It runs on ordinary frontend requests and decides whether the requester should: + +- be allowed through, +- be challenged, +- have an active challenge refreshed, +- have a submitted challenge solution verified, +- or be allowed through because they already hold a valid challenge-pass token. + +Its main enforcement function is `wpsec_enforce_captcha_policy()`, which acts as the gatekeeper decision tree for nearly every normal frontend request. + +## What Requests Pass Through This Path + +Conceptually, almost every normal frontend page request will pass through this function. + +However, it intentionally bypasses certain classes of requests because challenging them would break WordPress behavior or create inconsistent challenge state. These bypasses include: + +- admin requests +- REST requests +- AJAX requests +- privileged logged-in users +- asset-like requests +- favicon requests + +This is especially important because secondary asset requests must not trigger fresh challenge issuance. If they did, the browser could receive new challenge cookies after the original challenge HTML had already embedded a different puzzle state, which would break puzzle verification. + +## High-Level Decision Flow + +Once the request has passed the early bypass checks, the gatekeeper evaluates it in this order: + +1. **Does the requester already carry a CAPTCHA pass token?** + - If yes, introspect it using the token verification endpoint. + - If valid, allow the request. + - If invalid, expired, replayed, or malformed, clear it and continue. + +2. **Should this requester be challenged at all?** + - If not, allow the request normally. + - If yes, continue into challenge-handling logic. + +3. **Is this request asking to refresh the current challenge state?** + - If yes, clear challenge cookies and fetch a fresh puzzle state. + +4. **Does this request carry a CAPTCHA solution submission?** + - If yes, send the challenge cookies upstream for verification and relay the result. + +5. **Otherwise** + - issue a fresh challenge. + +This makes the gatekeeper the central policy router for all challenge-related behavior. + +## Challenge Issuance + +If the requester should be challenged and has not already submitted a valid solution, the gatekeeper issues a fresh challenge by calling the upstream challenge generation endpoint. + +That issuance flow: + +- fetches the full challenge HTML from the CAPTCHA service, +- forwards upstream cookies back to the browser, +- and returns the challenge HTML instead of allowing the original page to render. + +This is how the origin serves a challenge page in place of the requested content while still keeping the browser talking only to the WordPress origin. + +## Challenge Refresh + +The challenge UI supports refreshing the puzzle state without replacing the full page. + +The gatekeeper detects refresh requests via a request header and, when asked to refresh: + +- clears the current challenge cookies, +- calls the upstream refresh endpoint, +- forwards the new cookies, +- and returns fresh puzzle state JSON for the client-side puzzle UI to apply in place. + +This keeps refresh behavior entirely within the same origin-mediated design. + +## Challenge Verification + +When the browser submits a solution, the gatekeeper detects the expected verification cookies and sends them upstream to the CAPTCHA verification endpoint. + +The upstream service verifies: + +- the original challenge cookie +- the solution hash +- the click-chain cookies +- any rate-limit, integrity, or replay logic + +The gatekeeper then relays the result back to the browser: + +- `403 invalid solution` β†’ plain error message to the challenge UI +- `429` β†’ rate-limit response with `Retry-After` and JSON body +- `400` β†’ instruct client to refresh/reissue challenge +- `200` β†’ solution passed, forward the challenge-pass cookie, clear temporary challenge cookies, then allow the request + +This is the main β€œsubmit puzzle and prove you solved it” path. + +## Pass-Token Introspection + +Once a user has successfully solved the challenge, they receive a pass-token cookie. + +On every later request, the gatekeeper checks whether that cookie is present and, if so, introspects it against the upstream token verification endpoint. + +This is important because the token must not be trusted merely by its presence. The introspection step ensures the token is: + +- valid +- unexpired +- untampered with +- and still bound to the requester properties it was issued for + +If token introspection returns success, the request is allowed. Otherwise, the token is cleared and the request falls back into ordinary challenge policy evaluation. + +## Why Asset and Secondary Requests Are Skipped + +The gatekeeper intentionally skips asset-like requests and favicon requests because they can occur immediately after the challenge page is served. + +If those secondary requests also triggered challenge issuance, the browser could receive new challenge cookies that no longer match the puzzle state embedded in the already-rendered challenge HTML. That would cause later verification failures because: + +- the click-chain genesis in the rendered puzzle would belong to one challenge, +- while the browser cookies would belong to another. + +Skipping these request types is therefore necessary to preserve challenge-state consistency. + +--- + +# End-to-End Flow Summary + +Putting it all together: + +1. A request hits the WordPress origin. +2. The **prediction pipeline** collects request metadata and sends it to Baskerville. +3. Baskerville classifies the requester and pushes/stores the result on the WordPress side. +4. On later requests, the **gatekeeper** checks that stored enforcement state. +5. If the requester should not be challenged, WordPress renders normally. +6. If the requester should be challenged: + - the gatekeeper may issue a challenge, + - refresh an active challenge, + - verify a challenge submission, + - or validate a previously issued pass token. +7. Once the requester successfully solves the challenge, they receive a pass token. +8. On every later request, that pass token is introspected to confirm that the requester is still legitimate. + +So the plugin integration as a whole can be understood as: + +- **prediction pipeline** = data collection and ML input +- **gatekeeper** = request-time enforcement and challenge lifecycle management + +--- + +# CAPTCHA Puzzle + +## Table of Contents + +
+ Introduction - Overview of the type of puzzle and our goals. + +- [Introduction](#introduction) + - [State-Space Search Problem](#state-space-search-problem) + - [Why State-Space Search?](#objective) + - [The High Level Objective](#the-high-level-objective) + - [What We Have Achieved & What Comes Next](#what-we-have-achieved--what-comes-next) +
+ +--- + + +# Introduction + +## State-Space Search Problem + +- A state-space search problem is a computer science task that involves finding a solution by navigating through a set of states + +#### Components of a state-space search problem + +- States: A set of possible configurations of a problem +- Start state: The initial configuration of the problem +- Goal state: The desired configuration of the problem +- Actions: The actions that can be taken to move from one state to another +- Goal test: A specification of what constitutes a solution + +- Examples of state-space search: + + - Solving puzzles like the 8-puzzle or Rubik's cube + - A robot navigating through a maze + +[For more on State Space Search problems see wiki/State_space_search](https://en.wikipedia.org/wiki/State_space_search) + +### Why State-Space Search? + +- This puzzle was designed as an experimentβ€”it is intentionally built as a state-space search problem. +- The motivation behind this is that bots, LLMs, and automated solvers are not particularly strong at this class of problem, but humans also struggle with itβ€”just in different ways. +- The hypothesis is that humans and bots will approach the puzzle in fundamentally different ways, and by analyzing how they play, we may uncover meaningful differences. + +#### The High Level Objective + +- This is not a reverse Turing testβ€”the objective isn’t just to prove whether someone is a bot or not. Instead, the goal is to study how people play compared to automated systems. +- In the future, we may develop an API for major LLMs to play, allowing us to collect gameplay data and run comparative analyses. +- The ultimate aim is to train an in-house model that uses gameplay behavior as a distinguishing factor, rather than relying solely on conventional CAPTCHA mechanisms. + +#### What We Have Achieved & What Comes Next + +- The puzzle itself is complete: we can cryptographically verify whether a submitted solution is correct or incorrect, with each challenge being unique to the user. +- However, correctness alone is only half the solutionβ€”the real challenge is distinguishing how the game is played and whether that behavior indicates a human or a bot. +- In theory, this could mean that getting the exact right solution may not even be necessary. If we weight behavioral analysis more heavily than correctness, we could allow slightly incorrect solutions as long as the player's interactions strongly indicate human behavior. +- The really neat part of the project will be in collecting and analyzing gameplay data, identifying patterns that separate human problem-solving strategies from automated solvers. + +#### Cool fact about the puzzle: +- Finding a solution for n-puzzle is easy. However, finding a shortest solution is NP-hard. diff --git a/admin/class-baskerville-admin.php b/admin/class-baskerville-admin.php index 85dacab..15064e1 100644 --- a/admin/class-baskerville-admin.php +++ b/admin/class-baskerville-admin.php @@ -25,6 +25,8 @@ public function __construct($stats, $aiua) { add_action('wp_ajax_baskerville_get_live_stats', array($this, 'ajax_get_live_stats')); add_action('wp_ajax_baskerville_import_logs', array($this, 'ajax_import_logs')); add_action('wp_ajax_baskerville_ip_lookup', array($this, 'ajax_ip_lookup')); + add_action('wp_ajax_baskerville_gk_test_status', array($this, 'ajax_gk_test_status')); + add_action('wp_ajax_baskerville_clear_bans', array($this, 'ajax_clear_bans')); add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_scripts')); } @@ -52,10 +54,14 @@ public function enqueue_admin_scripts($hook) { // Pass nonces and i18n strings to admin.js wp_localize_script('baskerville-admin', 'baskervilleAdmin', array( 'importLogsNonce' => wp_create_nonce('baskerville_import_logs'), + 'gkTestStartNonce' => wp_create_nonce('baskerville_gk_test_start'), + 'gkTestStopNonce' => wp_create_nonce('baskerville_gk_test_stop'), + 'gkTestStatusNonce' => wp_create_nonce('baskerville_gk_test_status'), 'installMaxmindNonce' => wp_create_nonce('baskerville_install_maxmind'), 'updateDeflectNonce' => wp_create_nonce('baskerville_update_deflect_geoip'), 'clearGeoipCacheNonce' => wp_create_nonce('baskerville_clear_geoip_cache'), 'ipLookupNonce' => wp_create_nonce('baskerville_ip_lookup'), + 'clearBansNonce' => wp_create_nonce('baskerville_clear_bans'), 'benchmarkNonce' => wp_create_nonce('baskerville_benchmark'), 'i18n' => array( // Import logs @@ -74,17 +80,21 @@ public function enqueue_admin_scripts($hook) { 'bansByCountryLast' => __( '403 Bans by Country β€” last', 'baskerville-ai-security' ), 'blockedRequests' => __( 'Blocked Requests', 'baskerville-ai-security' ), // Live Feed + 'clearBans' => __( 'Clear All Bans', 'baskerville-ai-security' ), + 'clearingBans' => __( 'Clearing…', 'baskerville-ai-security' ), + 'bansCleared' => __( 'bans cleared', 'baskerville-ai-security' ), + 'clearBansFailed' => __( 'Failed to clear bans', 'baskerville-ai-security' ), 'noRecentEvents' => __( 'No recent events', 'baskerville-ai-security' ), - 'turnstileFailed' => __( 'TURNSTILE FAILED', 'baskerville-ai-security' ), + 'turnstileFailed' => __( 'CHALLENGE FAILED', 'baskerville-ai-security' ), 'challengeFailed' => __( 'CHALLENGE FAILED', 'baskerville-ai-security' ), 'banned' => __( 'BANNED', 'baskerville-ai-security' ), 'detected' => __( 'DETECTED', 'baskerville-ai-security' ), 'unknownBot' => __( 'Unknown Bot', 'baskerville-ai-security' ), - 'turnstile' => __( 'TURNSTILE', 'baskerville-ai-security' ), + 'turnstile' => __( 'CHALLENGE', 'baskerville-ai-security' ), 'ua' => __( 'UA:', 'baskerville-ai-security' ), 'honeypot' => __( 'HONEYPOT', 'baskerville-ai-security' ), 'userAgent' => __( 'USER-AGENT', 'baskerville-ai-security' ), - 'failedTurnstile' => __( 'Failed Cloudflare Turnstile challenge', 'baskerville-ai-security' ), + 'failedTurnstile' => __( 'Failed challenge', 'baskerville-ai-security' ), 'noReason' => __( 'No reason', 'baskerville-ai-security' ), 'score' => __( 'score', 'baskerville-ai-security' ), 'banReason' => __( 'Ban reason', 'baskerville-ai-security' ), @@ -136,13 +146,13 @@ public function enqueue_admin_scripts($hook) { 'passedHumans' => __( 'Passed (Humans)', 'baskerville-ai-security' ), 'failedBots' => __( 'Failed (Bots)', 'baskerville-ai-security' ), 'challenges' => __( 'Challenges', 'baskerville-ai-security' ), - 'turnstileChallenges' => __( 'Turnstile Challenges', 'baskerville-ai-security' ), + 'turnstileChallenges' => __( 'Challenges', 'baskerville-ai-security' ), 'redirects' => __( 'Redirects:', 'baskerville-ai-security' ), 'precision' => __( 'Precision:', 'baskerville-ai-security' ), 'challenged' => __( 'Challenged:', 'baskerville-ai-security' ), 'passed' => __( 'Passed:', 'baskerville-ai-security' ), 'failed' => __( 'Failed:', 'baskerville-ai-security' ), - 'noTurnstileData' => __( 'No Turnstile data available. Enable Turnstile challenge for borderline scores to see data here.', 'baskerville-ai-security' ), + 'noTurnstileData' => __( 'No challenge data available. Enable a challenge provider for borderline scores to see data here.', 'baskerville-ai-security' ), 'noChallengesRecorded' => __( 'No challenges recorded', 'baskerville-ai-security' ), 'noDataPeriod' => __( 'No data available for the selected period', 'baskerville-ai-security' ), 'noDataAvailable' => __( 'No data available', 'baskerville-ai-security' ), @@ -278,8 +288,8 @@ public function add_admin_menu() { add_submenu_page( 'baskerville-settings', - esc_html__('Turnstile', 'baskerville-ai-security'), - esc_html__('Turnstile', 'baskerville-ai-security'), + esc_html__('Challenge', 'baskerville-ai-security'), + esc_html__('Challenge', 'baskerville-ai-security'), 'manage_options', 'baskerville-turnstile', array($this, 'admin_page_turnstile') @@ -731,10 +741,14 @@ public function sanitize_settings($input) { $sanitized['api_rate_limit_window'] = max(10, min(3600, (int) $input['api_rate_limit_window'])); } - // Turnstile settings - $sanitized['turnstile_enabled'] = isset($input['turnstile_enabled']) - ? (bool) $input['turnstile_enabled'] - : (isset($existing['turnstile_enabled']) ? $existing['turnstile_enabled'] : false); + // Challenge provider + $allowed_providers = array('gatekeeper', 'turnstile', 'none'); + $sanitized['captcha_provider'] = (isset($input['captcha_provider']) && in_array($input['captcha_provider'], $allowed_providers, true)) + ? $input['captcha_provider'] + : (isset($existing['captcha_provider']) ? $existing['captcha_provider'] : 'none'); + + // Turnstile settings (kept for back-compat; active only when captcha_provider = turnstile) + $sanitized['turnstile_enabled'] = ($sanitized['captcha_provider'] === 'turnstile'); if (isset($input['turnstile_site_key'])) { $sanitized['turnstile_site_key'] = sanitize_text_field($input['turnstile_site_key']); @@ -769,6 +783,20 @@ public function sanitize_settings($input) { $sanitized['turnstile_borderline_max'] = isset($existing['turnstile_borderline_max']) ? $existing['turnstile_borderline_max'] : 70; } + // Gatekeeper challenge-fail ban settings + if (isset($input['gk_fail_max'])) { + $sanitized['gk_fail_max'] = max(1, min(20, (int) $input['gk_fail_max'])); + } else { + $sanitized['gk_fail_max'] = isset($existing['gk_fail_max']) ? $existing['gk_fail_max'] : 3; + } + + if (isset($input['gk_ban_ttl_sec'])) { + // Input is in minutes; store as seconds internally + $sanitized['gk_ban_ttl_sec'] = max(60, min(86400, (int) $input['gk_ban_ttl_sec'] * 60)); + } else { + $sanitized['gk_ban_ttl_sec'] = isset($existing['gk_ban_ttl_sec']) ? $existing['gk_ban_ttl_sec'] : 3600; + } + // Flush rewrite rules when settings are saved (for honeypot route) flush_rewrite_rules(); @@ -1789,7 +1817,7 @@ private function get_timeseries_data($hours = 24) { * @param int $hours Number of hours to look back * @return array Timeseries data with pass/fail counts and precision */ - private function get_turnstile_timeseries_data($hours = 24) { + private function get_challenge_timeseries_data($hours = 24) { global $wpdb; $table_name = $wpdb->prefix . 'baskerville_stats'; @@ -1813,11 +1841,11 @@ private function get_turnstile_timeseries_data($hours = 24) { FROM_UNIXTIME( FLOOR(UNIX_TIMESTAMP(timestamp_utc) / %d) * %d ) AS time_slot, - SUM(CASE WHEN event_type='ts_redir' THEN 1 ELSE 0 END) AS redirect_count, - SUM(CASE WHEN event_type='ts_pass' THEN 1 ELSE 0 END) AS pass_count, - SUM(CASE WHEN event_type='ts_fail' THEN 1 ELSE 0 END) AS fail_count + SUM(CASE WHEN event_type IN ('ts_redir','gk_redir') THEN 1 ELSE 0 END) AS redirect_count, + SUM(CASE WHEN event_type IN ('ts_pass','gk_pass') THEN 1 ELSE 0 END) AS pass_count, + SUM(CASE WHEN event_type IN ('ts_fail','gk_fail') THEN 1 ELSE 0 END) AS fail_count FROM %i - WHERE event_type IN ('ts_redir', 'ts_pass', 'ts_fail') + WHERE event_type IN ('ts_redir', 'ts_pass', 'ts_fail', 'gk_redir', 'gk_pass', 'gk_fail') AND timestamp_utc >= %s GROUP BY time_slot ORDER BY time_slot ASC", @@ -1889,18 +1917,18 @@ private function get_key_metrics($hours = 24) { $cutoff )); - // Challenged unique IPs (ts_redir events) + // Challenged unique IPs (ts_redir or gk_redir events) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $challenged_ips = (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(DISTINCT ip) FROM %i WHERE timestamp_utc >= %s AND event_type = 'ts_redir'", + "SELECT COUNT(DISTINCT ip) FROM %i WHERE timestamp_utc >= %s AND event_type IN ('ts_redir', 'gk_redir')", $table, $cutoff )); - // Passed unique IPs (ts_pass events) + // Passed unique IPs (ts_pass or gk_pass events) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $passed_ips = (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(DISTINCT ip) FROM %i WHERE timestamp_utc >= %s AND event_type = 'ts_pass'", + "SELECT COUNT(DISTINCT ip) FROM %i WHERE timestamp_utc >= %s AND event_type IN ('ts_pass', 'gk_pass')", $table, $cutoff )); @@ -2569,6 +2597,21 @@ public function ajax_clear_geoip_cache() { )); } + public function ajax_clear_bans() { + check_ajax_referer('baskerville_clear_bans', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_send_json_error(array('message' => esc_html__('Insufficient permissions.', 'baskerville-ai-security'))); + } + + $core = new Baskerville_Core(); + $cleared = $core->fc_clear_bans(); + + wp_send_json_success(array( + 'cleared' => $cleared, + )); + } + private function render_geoip_test_tab() { // Get current visitor IP $visitor_ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')); @@ -3105,15 +3148,20 @@ public function admin_page() { // Get master switch status $options = get_option('baskerville_settings', array()); - $master_enabled = !isset($options['master_protection_enabled']) || $options['master_protection_enabled']; + $master_enabled = !isset($options['master_protection_enabled']) || $options['master_protection_enabled']; + $under_attack_top = isset($options['turnstile_under_attack']) ? (bool) $options['turnstile_under_attack'] : false; + $captcha_provider_top = isset($options['captcha_provider']) ? $options['captcha_provider'] : 'none'; ?>

- -
+ +
+ + +

@@ -3123,7 +3171,6 @@ public function admin_page() {

-
+ + +
+
+ + + + +
+
+ + +
+ + +
+ + + + +
+
+ +
+
+
+ +
+
+ +
+ + + +
+
+
- +
@@ -5125,10 +5458,10 @@ public function ajax_get_live_feed() { INNER JOIN ( SELECT ip, MAX(created_at) as max_created FROM " . esc_sql($table) . " - WHERE classification IN ('bad_bot', 'ai_bot', 'bot') OR score >= 50 OR (block_reason IS NOT NULL AND block_reason != '') OR event_type = 'ts_fail' + WHERE classification IN ('bad_bot', 'ai_bot', 'bot') OR score >= 50 OR (block_reason IS NOT NULL AND block_reason != '') OR event_type IN ('ts_fail', 'gk_fail') GROUP BY ip ) t2 ON t1.ip = t2.ip AND t1.created_at = t2.max_created - WHERE (t1.classification IN ('bad_bot', 'ai_bot', 'bot') OR t1.score >= 50 OR (t1.block_reason IS NOT NULL AND t1.block_reason != '') OR t1.event_type = 'ts_fail') + WHERE (t1.classification IN ('bad_bot', 'ai_bot', 'bot') OR t1.score >= 50 OR (t1.block_reason IS NOT NULL AND t1.block_reason != '') OR t1.event_type IN ('ts_fail', 'gk_fail')) ORDER BY t1.created_at DESC LIMIT %d", 30 @@ -5254,6 +5587,21 @@ public function ajax_import_logs() { * * @phpcs:disable WordPress.DB.DirectDatabaseQuery */ + + /** Return the current Gatekeeper test mode status for the logged-in admin. */ + public function ajax_gk_test_status() { + check_ajax_referer('baskerville_gk_test_status', 'nonce'); + if (!current_user_can('manage_options')) { + wp_send_json_error('Unauthorized', 403); + } + $expiry = (int) get_user_meta(get_current_user_id(), 'baskerville_gk_test', true); + if ($expiry > 0 && time() < $expiry) { + wp_send_json_success(array('active' => true, 'expiry' => $expiry)); + } else { + wp_send_json_success(array('active' => false)); + } + } + public function ajax_ip_lookup() { // Verify nonce if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'] ?? '')), 'baskerville_ip_lookup')) { diff --git a/assets/css/admin.css b/assets/css/admin.css index 2c01840..fb954c8 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -584,6 +584,9 @@ h3.baskerville-section-title { .baskerville-ml-10 { margin-left: 10px; } +.baskerville-ml-20 { + margin-left: 20px; +} .baskerville-my-20 { margin: 20px 0; } @@ -960,10 +963,16 @@ h3.baskerville-section-title { border: 2px solid var(--bsk-color-warning-yellow); background: var(--bsk-color-warning-bg); } +/* Under Attack Mode active β€” override border/bg */ +.baskerville-master-switch-attack { + border-color: var(--bsk-color-danger) !important; + background: var(--bsk-color-danger-bg) !important; +} .baskerville-master-switch-header { display: flex; align-items: center; gap: 30px; + flex-wrap: wrap; } .baskerville-master-switch-title { margin: 0; @@ -975,6 +984,104 @@ h3.baskerville-section-title { color: var(--bsk-color-warning-dark); } +/* Under Attack quick-toggle in master switch bar */ +.baskerville-under-attack-quick { + margin-left: auto; + padding-left: 20px; + border-left: 2px solid rgba(0,0,0,.08); +} + +/* Red toggle slider β€” used for Under Attack Mode */ +.baskerville-toggle-slider-danger { + background-color: var(--bsk-color-danger) !important; +} +/* Under Attack slider turns red immediately on check (no JS needed) */ +.baskerville-under-attack-quick input:checked + .baskerville-toggle-slider { + background-color: var(--bsk-color-danger); +} + +/* Clear All Bans quick button */ +.baskerville-clear-bans-quick { + display: flex; + align-items: center; + gap: 10px; +} +.baskerville-btn-clear-bans { + display: inline-flex; + align-items: center; + gap: 4px; + border-color: var(--bsk-color-danger-alt) !important; + color: var(--bsk-color-danger-alt) !important; +} +.baskerville-btn-clear-bans:hover { + background: var(--bsk-color-danger-bg) !important; +} +.baskerville-clear-bans-msg { + font-size: 13px; + font-weight: 500; +} + +/* Disabled toggle label β€” grayed out, no pointer */ +.baskerville-toggle-disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +/* No-challenge warning banner */ +.baskerville-no-challenge-banner { + display: flex; + align-items: flex-start; + gap: 20px; + margin: 0 0 24px 0; + padding: 24px 28px; + border-radius: 8px; + border: 3px solid var(--bsk-color-danger); + background: #fff; +} +.baskerville-no-challenge-banner-icon .dashicons { + font-size: 48px; + width: 48px; + height: 48px; + color: var(--bsk-color-danger); + flex-shrink: 0; + margin-top: 4px; +} +.baskerville-no-challenge-banner-body { + display: flex; + flex-direction: column; + gap: 8px; +} +.baskerville-no-challenge-banner-title { + font-size: 22px; + font-weight: 700; + color: var(--bsk-color-danger); + line-height: 1.2; +} +.baskerville-no-challenge-banner-text { + font-size: 15px; + color: #333; + line-height: 1.6; + max-width: 680px; +} +.baskerville-no-challenge-banner-cta { + display: inline-block; + margin-top: 6px; + padding: 10px 22px; + border-radius: 5px; + background: var(--bsk-color-danger); + color: #fff !important; + font-size: 15px; + font-weight: 600; + text-decoration: none !important; + transition: background .15s; + align-self: flex-start; +} +.baskerville-no-challenge-banner-cta:hover { + background: var(--bsk-color-danger-dark) !important; + color: #fff !important; +} + /* Simple table for diagnostics */ .baskerville-simple-table { margin: 10px 0; diff --git a/assets/js/live-feed.js b/assets/js/live-feed.js index 0512220..4c70ef9 100644 --- a/assets/js/live-feed.js +++ b/assets/js/live-feed.js @@ -51,8 +51,8 @@ jQuery(document).ready(function($) { var icon = getEventIcon(event.classification, event.event_type); var color = getEventColor(event.classification, event.event_type); var timeAgo = getTimeAgo(event.created_at); - var isTurnstileFail = event.event_type === 'ts_fail'; - var displayLabel = isTurnstileFail ? i18n.turnstileFailed : event.classification.toUpperCase().replace('_', ' '); + var isTurnstileFail = event.event_type === 'ts_fail' || event.event_type === 'gk_fail'; + var displayLabel = isTurnstileFail ? i18n.challengeFailed : event.classification.toUpperCase().replace('_', ' '); var banBadge = ''; if (isTurnstileFail) { @@ -121,6 +121,9 @@ jQuery(document).ready(function($) { var item = $('
'); var countryName = event.country_code ? getCountryName(event.country_code) : ''; var reasonText = isTurnstileFail ? i18n.failedTurnstile : (event.reason || i18n.noReason); + if (event.block_reason === 'gk-challenge-fail') { + reasonText = i18n.failedTurnstile; + } item.html( '' + icon + ' ' + '' + displayLabel + '' + @@ -163,7 +166,7 @@ jQuery(document).ready(function($) { } function getEventIcon(classification, eventType) { - if (eventType === 'ts_fail') return '\u{1f6e1}\ufe0f'; + if (eventType === 'ts_fail' || eventType === 'gk_fail') return '\u{1f6e1}\ufe0f'; if (eventType === 'honeypot') return '\u{1f36f}'; if (classification === 'ai_bot') return '\u{1f916}'; if (classification === 'bad_bot') return '\u{1f534}'; @@ -172,7 +175,7 @@ jQuery(document).ready(function($) { } function getEventColor(classification, eventType) { - if (eventType === 'ts_fail') return '#dc2626'; + if (eventType === 'ts_fail' || eventType === 'gk_fail') return '#dc2626'; if (classification === 'ai_bot') return '#9333ea'; if (classification === 'bad_bot') return '#dc2626'; if (classification === 'bot') return '#f59e0b'; diff --git a/baskerville-ai-security.php b/baskerville-ai-security.php index bafdbd9..c65573a 100644 --- a/baskerville-ai-security.php +++ b/baskerville-ai-security.php @@ -30,7 +30,6 @@ require_once BASKERVILLE_PLUGIN_PATH . 'includes/class-baskerville-honeypot.php'; require_once BASKERVILLE_PLUGIN_PATH . 'includes/class-baskerville-installer.php'; require_once BASKERVILLE_PLUGIN_PATH . 'includes/class-baskerville-maxmind-installer.php'; -require_once BASKERVILLE_PLUGIN_PATH . 'includes/class-baskerville-turnstile.php'; require_once BASKERVILLE_PLUGIN_PATH . 'admin/class-baskerville-admin.php'; // Add custom cron intervals @@ -52,9 +51,23 @@ $aiua = new Baskerville_AI_UA($core); // AI_UA should receive $core in constructor $stats = new Baskerville_Stats($core, $aiua); // Stats receives Core and AI_UA - // Cloudflare Turnstile - must be created BEFORE firewall for borderline challenge - $turnstile = new Baskerville_Turnstile($core, $stats); - $GLOBALS['baskerville_turnstile'] = $turnstile; + // Challenge provider β€” must be created BEFORE firewall for borderline challenge decisions + $options_early = get_option('baskerville_settings', array()); + $captcha_provider = isset($options_early['captcha_provider']) ? $options_early['captcha_provider'] : 'none'; + + if ($captcha_provider === 'gatekeeper') { + require_once BASKERVILLE_PLUGIN_PATH . 'includes/class-baskerville-gatekeeper.php'; + $challenge_obj = new Baskerville_Gatekeeper($core, $stats); + } elseif ($captcha_provider === 'turnstile') { + require_once BASKERVILLE_PLUGIN_PATH . 'includes/class-baskerville-turnstile.php'; + $challenge_obj = new Baskerville_Turnstile($core, $stats); + } else { + $challenge_obj = null; + } + + if ($challenge_obj !== null) { + $GLOBALS['baskerville_challenge'] = $challenge_obj; + } // pre-DB firewall (MUST run IMMEDIATELY, before any other hooks) // This runs directly in plugins_loaded to catch requests as early as possible @@ -79,8 +92,10 @@ $honeypot = new Baskerville_Honeypot($core, $stats, $aiua); $honeypot->init(); - // Initialize Turnstile hooks (object already created before firewall) - $turnstile->init(); + // Initialize challenge provider hooks (object already created before firewall) + if ($challenge_obj !== null) { + $challenge_obj->init(); + } // periodic statistics cleanup add_action('baskerville_cleanup_stats', [$stats, 'cleanup_old_stats']); diff --git a/includes/class-baskerville-core.php b/includes/class-baskerville-core.php index 68aa081..aeb011f 100644 --- a/includes/class-baskerville-core.php +++ b/includes/class-baskerville-core.php @@ -39,11 +39,13 @@ public function enqueue_scripts() { } public function enqueue_admin_scripts() { + $css_file = BASKERVILLE_PLUGIN_PATH . 'assets/css/admin.css'; + $css_ver = BASKERVILLE_DEBUG ? filemtime($css_file) : BASKERVILLE_VERSION; wp_enqueue_style( 'baskerville-admin-style', BASKERVILLE_PLUGIN_URL . 'assets/css/admin.css', array(), - BASKERVILLE_VERSION + $css_ver ); } @@ -387,6 +389,58 @@ public function fc_clear_geoip_cache() { return $cleared; } + /** + * Clear all IP ban entries AND challenge-fail counters from the cache. + * Called by the "Clear All Bans" admin button to give a full clean slate. + * @return int Number of entries cleared + */ + public function fc_clear_bans(): int { + $cleared = 0; + + if ($this->fc_has_apcu()) { + // Clear ban entries (ban:{ip}) + $iterator = new \APCUIterator('/^baskerville:ban:/'); + foreach ($iterator as $entry) { + if (apcu_delete($entry['key'])) { + $cleared++; + } + } + // Also clear challenge-fail counters (gk_fail:{ip}) so the + // threshold resets alongside the ban β€” prevents leftover counters + // from triggering an instant ban on the next failure. + $iterator = new \APCUIterator('/^baskerville:gk_fail:/'); + foreach ($iterator as $entry) { + if (apcu_delete($entry['key'])) { + $cleared++; + } + } + } else { + $dir = $this->fc_dir(); + if (!is_dir($dir)) return 0; + + $files = @glob($dir . '/*.cache'); + if (!$files) return 0; + + foreach ($files as $file) { + $raw = @file_get_contents($file); + if ($raw === false) continue; + $data = @unserialize($raw); + if (!is_array($data)) continue; + $v = $data['v'] ?? null; + // Ban entries: array with 'reason' and 'until' keys + $is_ban = is_array($v) && isset($v['reason'], $v['until']); + // Fail counters: plain integers stored by fc_inc_in_window + $is_fail_counter = is_int($v); + if ($is_ban || $is_fail_counter) { + wp_delete_file($file); + $cleared++; + } + } + } + + return $cleared; + } + public function fc_has_apcu(): bool { return function_exists('apcu_store') && (function_exists('apcu_enabled') ? apcu_enabled() : true); } diff --git a/includes/class-baskerville-firewall.php b/includes/class-baskerville-firewall.php index 4d33634..3c30923 100644 --- a/includes/class-baskerville-firewall.php +++ b/includes/class-baskerville-firewall.php @@ -103,33 +103,14 @@ private function send_403_and_exit(array $meta): void { status_header(403); nocache_headers(); header('Content-Type: text/plain; charset=UTF-8'); - if (!empty($meta['reason'])) header('X-Baskerville-Reason: ' . $meta['reason']); - if (isset($meta['score'])) header('X-Baskerville-Score: ' . (int)$meta['score']); - if (!empty($meta['cls'])) header('X-Baskerville-Class: ' . $meta['cls']); if (!empty($meta['until'])) { $until = (int)$meta['until']; - header('X-Baskerville-Until: ' . gmdate('c', $until)); $retry = max(1, $until - time()); header('Retry-After: ' . $retry); } } - // Show specific message based on ban reason (no translations - runs before init) - $reason = $meta['reason'] ?? ''; - if (strpos($reason, 'no-cookie-burst') === 0) { - esc_html_e( 'Forbidden - Too many requests without session cookie', 'baskerville-ai-security' ); - } elseif (strpos($reason, 'nojs-burst') === 0) { - esc_html_e( 'Forbidden - Too many requests without JavaScript', 'baskerville-ai-security' ); - } elseif (strpos($reason, 'nojs-burst') === 0) { - esc_html_e( 'Forbidden - Non-browser client rate limit exceeded', 'baskerville-ai-security' ); - } elseif (strpos($reason, 'ai-bot') === 0) { - esc_html_e( 'Forbidden - AI bot detected', 'baskerville-ai-security' ); - } elseif (strpos($reason, 'cached-ban') === 0) { - esc_html_e( 'Forbidden - IP temporarily banned', 'baskerville-ai-security' ); - } else { - esc_html_e( 'Forbidden - Bot detected', 'baskerville-ai-security' ); - } - echo "\n"; + echo "Forbidden\n"; exit; } @@ -139,18 +120,13 @@ private function send_403_geo_and_exit(array $meta): void { status_header(403); nocache_headers(); header('Content-Type: text/plain; charset=UTF-8'); - if (!empty($meta['reason'])) header('X-Baskerville-Reason: ' . $meta['reason']); - if (isset($meta['score'])) header('X-Baskerville-Score: ' . (int)$meta['score']); - if (!empty($meta['cls'])) header('X-Baskerville-Class: ' . $meta['cls']); if (!empty($meta['until'])) { $until = (int)$meta['until']; - header('X-Baskerville-Until: ' . gmdate('c', $until)); $retry = max(1, $until - time()); header('Retry-After: ' . $retry); } } - esc_html_e( 'Forbidden - Access from this country is restricted', 'baskerville-ai-security' ); - echo "\n"; + echo "Forbidden - Access restricted in your region\n"; exit; } @@ -470,14 +446,15 @@ public function pre_db_firewall(): void { $classification = $this->aiua->classify_client(['fingerprint' => []], ['headers' => $headers]); $risk = (int)($evaluation['score'] ?? 0); - // Turnstile challenge for borderline bot scores (BEFORE burst protection) + // Challenge provider (Gatekeeper or Turnstile) for borderline bot scores (BEFORE burst protection) // This gives borderline visitors a chance to prove they're human instead of getting 403 - if (isset($GLOBALS['baskerville_turnstile'])) { - $turnstile = $GLOBALS['baskerville_turnstile']; + if (isset($GLOBALS['baskerville_challenge'])) { + $challenge_provider = $GLOBALS['baskerville_challenge']; $baskerville_id = $this->core->get_cookie_id(); - if ($turnstile->should_challenge($risk, $baskerville_id)) { - $turnstile->redirect_to_challenge(); + if ($challenge_provider->should_challenge($risk, $baskerville_id)) { + $challenge_provider->redirect_to_challenge(); + return; // Turnstile exits via wp_redirect; Gatekeeper sets a flag and returns } } @@ -492,19 +469,20 @@ public function pre_db_firewall(): void { $classification = $this->aiua->classify_client(['fingerprint' => []], ['headers' => $headers]); $cls = $classification['classification'] ?? 'bot'; - // If classified as human, try Turnstile challenge instead of banning - if ($cls === 'human' && isset($GLOBALS['baskerville_turnstile'])) { - $turnstile = $GLOBALS['baskerville_turnstile']; - if ($turnstile->is_enabled()) { - if ($turnstile->has_valid_pass()) { - // Already passed Turnstile - allow through, don't ban + // If classified as human, try challenge provider instead of banning + if ($cls === 'human' && isset($GLOBALS['baskerville_challenge'])) { + $challenge_provider = $GLOBALS['baskerville_challenge']; + if ($challenge_provider->is_enabled()) { + if ($challenge_provider->has_valid_pass()) { + // Already passed challenge - allow through, don't ban return; } - $turnstile->redirect_to_challenge(); + $challenge_provider->redirect_to_challenge(); + return; // Turnstile exits via wp_redirect; Gatekeeper sets a flag and returns } } - // Not human or Turnstile not available - ban + // Not human or challenge provider not available - ban $reason = "no-cookie-burst>{$threshold}/{$window_sec}s"; $ttl = (int) get_option('baskerville_ban_ttl_sec', 600); @@ -535,19 +513,20 @@ public function pre_db_firewall(): void { $classification = $this->aiua->classify_client(['fingerprint' => []], ['headers' => $headers]); $cls = $classification['classification'] ?? 'unknown'; - // If classified as human, try Turnstile challenge instead of banning - if ($cls === 'human' && isset($GLOBALS['baskerville_turnstile'])) { - $turnstile = $GLOBALS['baskerville_turnstile']; - if ($turnstile->is_enabled()) { - if ($turnstile->has_valid_pass()) { - // Already passed Turnstile - allow through, don't ban + // If classified as human, try challenge provider instead of banning + if ($cls === 'human' && isset($GLOBALS['baskerville_challenge'])) { + $challenge_provider = $GLOBALS['baskerville_challenge']; + if ($challenge_provider->is_enabled()) { + if ($challenge_provider->has_valid_pass()) { + // Already passed challenge - allow through, don't ban return; } - $turnstile->redirect_to_challenge(); + $challenge_provider->redirect_to_challenge(); + return; // Turnstile exits via wp_redirect; Gatekeeper sets a flag and returns } } - // Not human or Turnstile not available - ban + // Not human or challenge provider not available - ban $reason = "nojs-burst>{$threshold}/{$window_sec}s"; $ttl = (int) get_option('baskerville_ban_ttl_sec', 600); @@ -609,15 +588,16 @@ public function pre_db_firewall(): void { $classification = $this->aiua->classify_client(['fingerprint' => []], ['headers' => $headers]); $cls = $classification['classification'] ?? 'bot'; - // If classified as human, try Turnstile challenge instead of banning - if ($cls === 'human' && isset($GLOBALS['baskerville_turnstile'])) { - $turnstile = $GLOBALS['baskerville_turnstile']; - if ($turnstile->is_enabled()) { - if ($turnstile->has_valid_pass()) { - // Already passed Turnstile - allow through, don't ban + // If classified as human, try challenge provider instead of banning + if ($cls === 'human' && isset($GLOBALS['baskerville_challenge'])) { + $challenge_provider = $GLOBALS['baskerville_challenge']; + if ($challenge_provider->is_enabled()) { + if ($challenge_provider->has_valid_pass()) { + // Already passed challenge - allow through, don't ban return; } - $turnstile->redirect_to_challenge(); + $challenge_provider->redirect_to_challenge(); + return; // Turnstile exits via wp_redirect; Gatekeeper sets a flag and returns } } diff --git a/includes/class-baskerville-gatekeeper.php b/includes/class-baskerville-gatekeeper.php new file mode 100644 index 0000000..8492d17 --- /dev/null +++ b/includes/class-baskerville-gatekeeper.php @@ -0,0 +1,1720 @@ + $value) { + if ( + $name === USER_SOLUTION_HASH_COOKIE_NAME || + strpos($name, USER_SOLUTION_CLICK_CHAIN_COOKIE_PREFIX) === 0 + ) { + setcookie($name, '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'domain' => '', + 'secure' => is_ssl(), + 'httponly' => false, + 'samesite' => 'Lax', + ]); + unset($_COOKIE[$name]); + } + } +} + +/* + helper to remove cookies that are relevant only to check + that the users submission is correct. After that part of the flow + we need to get rid of them to avoid confusion +*/ +function wpsec_clear_challenge_cookies() { + wpsec_log('[gatekeeper] clearing challenge cookies'); + + foreach ($_COOKIE as $name => $value) { + if ( + $name === USER_SOLUTION_HASH_COOKIE_NAME || + $name === USER_CAPTCHA_CHALLENGE_COOKIE_NAME || + strpos($name, USER_SOLUTION_CLICK_CHAIN_COOKIE_PREFIX) === 0 + ) { + wpsec_log('[gatekeeper] clearing cookie ' . $name); + + setcookie($name, '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'domain' => '', + 'secure' => is_ssl(), + 'httponly' => false, + 'samesite' => 'Lax', + ]); + + unset($_COOKIE[$name]); + } + } +} + +/* + helper to clear specific cookies. + if the user previously passed a challenge and was provided + a challenge passed token, that token will eventually expire + (or if its replayed, forged, tampered with, we may invalidate it + server side, etc..) ultimately we can have many reasons for why + we would need to remove this cookie. +*/ +function wpsec_clear_pass_token_cookie() { + wpsec_log('[gatekeeper] clearing pass token cookie'); + + if (isset($_COOKIE[CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME])) { + setcookie(CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME, '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'domain' => '', + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + + unset($_COOKIE[CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME]); + } +} + +/* + since we communicate with the puzzle via headers, action are + checked via this helper function. + + this helper also serves as an extension point actions the + puzzle wants to be able to take can. To extend you would need + to update the client side, fetching from this function and making + the call as needed to the appripriate server side endpoint + + right now since we need only support refresh, this is the headrer + thats added anytime we need to refresh and we check it in the request path + its presence tells us that the user isnt submitting a solution or anything but rather + wishes to request a refreshed puzzle state +*/ +function wpsec_get_requested_action() { + $header = $_SERVER['HTTP_X_ACTION'] ?? ''; + return is_string($header) ? strtolower(trim($header)) : ''; +} + +/* + The way the captcha works is that we do NOT expose our own paths + since this could interfere with the users endpoints. So, since the captcha + is may be injected on any given path, we rely on GET requests on that path + without refreshing the page to submit the users solution to the puzzle. In + particular we communicate solutions through cookies. + + So we need to check for the existence of the solution cookies + to determine that this was a request that submitted a solution +*/ +function wpsec_has_verification_cookies() { + $has_solution = !empty($_COOKIE[USER_SOLUTION_HASH_COOKIE_NAME]); + $has_cc = false; + + foreach ($_COOKIE as $name => $value) { + if (strpos($name, USER_SOLUTION_CLICK_CHAIN_COOKIE_PREFIX) === 0) { + $has_cc = true; + break; + } + } + + wpsec_log('[gatekeeper] verification cookies present? sol=' . ($has_solution ? 'yes' : 'no') . ' cc=' . ($has_cc ? 'yes' : 'no')); + + return $has_solution && $has_cc; +} + +/* + The way the captcha works is that we do NOT expose our own paths + since this could interfere with the users endpoints. So, since the captcha + is may be injected on any given path, we rely on GET requests on that path + without refreshing the page to submit the users solution to the puzzle. In + particular we communicate solutions through cookies. + + So here we check to see that the cookie we return when the previously submitted + a correct solution proving they completed the challenge, is present +*/ +function wpsec_captcha_pass_token_cookie_is_present() { + + wpsec_log('[gatekeeper] starting pass token cookie verification flow'); + + $pass_cookie = $_COOKIE[CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME] ?? ''; + + if ($pass_cookie === '') { + wpsec_log('[gatekeeper] no pass token cookie present'); + return false; + } + + wpsec_log('[gatekeeper] pass token cookie present'); + return true; +} + + + + +//-------------------------------------------------- +//-------------------------------------------------- +//request helpers +//-------------------------------------------------- +//-------------------------------------------------- + +/* + helper used to forward cookies we need upstream since the wordpress origin + sits between the requester and our verification server +*/ +function wpsec_forward_upstream_cookies($response) { + $cookies = wp_remote_retrieve_cookies($response); + + wpsec_log('[gatekeeper] upstream cookie count=' . count($cookies)); + + foreach ($cookies as $cookie) { + if (!is_object($cookie)) { + wpsec_log('[gatekeeper] skipping non-object upstream cookie'); + continue; + } + + $name = $cookie->name ?? ''; + $value = $cookie->value ?? ''; + $path = $cookie->path ?? '/'; + $secure = isset($cookie->secure) ? (bool) $cookie->secure : is_ssl(); + $httponly = isset($cookie->httponly) ? (bool) $cookie->httponly : false; + $expires = isset($cookie->expires) ? (int) $cookie->expires : 0; + + if ($name === '') { + wpsec_log('[gatekeeper] skipping upstream cookie with empty name'); + continue; + } + + wpsec_log('[gatekeeper] forwarding upstream cookie ' . $name); + + setcookie($name, $value, [ + 'expires' => $expires, + 'path' => $path ?: '/', + 'domain' => '', + 'secure' => $secure, + 'httponly' => $httponly, + 'samesite' => 'Lax', + ]); + + $_COOKIE[$name] = $value; + } +} + +/* + The server side was original designed with us as a reverse proxy in mind. So + since we fronted the user and did not want to add our own endpoints, all interactions + with the captcha involving the server side happen via cookies. + + So here, we build the headers needed to verify the that the solution cookie + that we found is legitamate (not tampered with or being replayed). + sent along with request to /token/verify endpoint +*/ +function wpsec_build_cookie_header_for_token_verification() { + $pairs = []; + + /* + NOTE: currently the only relevant cookie is the solution pass since IP address is collected + at the level of the request itself, however if we want to extend beyond only IP address + enforcement, this is where we would also need to collect the name of the cookie we use + to follow the requester + */ + + if (isset($_COOKIE[CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME])) { + $pairs[] = CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME . '=' . $_COOKIE[CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME]; + } + + $cookie_header = implode('; ', $pairs); + return $cookie_header; +} + +/* + The server side was original designed with us as a reverse proxy in mind. So + since we fronted the user and did not want to add our own endpoints, all interactions + with the captcha involving the server side happen via cookies. + + So here we build the headers needed to verify the solution to the captcha itself + ie this include the __wpsec_sol_hash_, original __wpsec_challenge_ and all __wpsec_cc__x_x cookies + sent along with request to /captcha/verify endpoint +*/ + +function wpsec_build_cookie_header_for_captcha_challenge_verification() { + $pairs = []; + + if (isset($_COOKIE[USER_SOLUTION_HASH_COOKIE_NAME])) { + $pairs[] = USER_SOLUTION_HASH_COOKIE_NAME . '=' . $_COOKIE[USER_SOLUTION_HASH_COOKIE_NAME]; + } + + wpsec_log('raw solution cookie from $_COOKIE=' . ($_COOKIE[USER_SOLUTION_HASH_COOKIE_NAME] ?? 'missing')); + + if (isset($_COOKIE[USER_CAPTCHA_CHALLENGE_COOKIE_NAME])) { + $pairs[] = USER_CAPTCHA_CHALLENGE_COOKIE_NAME . '=' . $_COOKIE[USER_CAPTCHA_CHALLENGE_COOKIE_NAME]; + } + wpsec_log('raw challenge cookie from $_COOKIE=' . ($_COOKIE[USER_CAPTCHA_CHALLENGE_COOKIE_NAME] ?? 'missing')); + + foreach ($_COOKIE as $name => $value) { + if (strpos($name, USER_SOLUTION_CLICK_CHAIN_COOKIE_PREFIX) === 0) { + $pairs[] = $name . '=' . $value; + wpsec_log('wpsec click chain cookie from $_COOKIE ' . $name . '=' . $value); + } + } + + $cookie_header = implode('; ', $pairs); + wpsec_log('verification Cookie header=' . $cookie_header); + + return $cookie_header; +} + + + +//-------------------------------------------------- +//-------------------------------------------------- +//requests +//-------------------------------------------------- +//-------------------------------------------------- + +/* + When a user gets the captcha, they will solve the puzzle and click + verify. On verify, that sends a GET to the endpoint they happen to be + on but includes in the headers some cookies (namely the solution hash and + click chain cookies). We need to relay those from the wordpress origin + to the server to verify that this is indeed correct. + + issues request to the /captcha/verify endpoint +*/ +function wpsec_verify_captcha_solution_via_upstream_cookies() { + wpsec_log('[gatekeeper] starting verification flow'); + + // Per-IP rate limit: protect upstream /captcha/verify from brute-force. + // Fail with 429 β€” the caller already handles and relays this status. + $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); + if ( + $ip !== '' && + isset( $GLOBALS['baskerville_challenge'] ) && + $GLOBALS['baskerville_challenge'] instanceof Baskerville_Gatekeeper && + $GLOBALS['baskerville_challenge']->upstream_rate_limited( $ip, 'verify' ) + ) { + wpsec_log( '[gatekeeper] upstream verify rate limit exceeded, returning 429' ); + return [ 'status_code' => 429, 'message' => 'rate limited', 'error' => 'rate_limited', 'retry_after' => 60 ]; + } + + $original_url = (is_ssl() ? 'https://' : 'http://') + . ($_SERVER['HTTP_HOST'] ?? '') + . ($_SERVER['REQUEST_URI'] ?? ''); + + $cookie_header = wpsec_build_cookie_header_for_captcha_challenge_verification(); + + wpsec_log('[gatekeeper] forwarding verification cookie header=' . $cookie_header); + + $response = wp_remote_get(CAPTCHA_SOLUTION_VERIFICATION_ENDPOINT, [ + 'timeout' => 10, + 'redirection' => 3, + 'headers' => [ + 'Cookie' => $cookie_header, + 'Accept' => 'application/json, text/plain, */*', + 'X-Client-IP' => $_SERVER['REMOTE_ADDR'] ?? '', + 'X-Original-Host' => $_SERVER['HTTP_HOST'] ?? '', + 'X-Original-URI' => $_SERVER['REQUEST_URI'] ?? '', + 'X-Original-URL' => $original_url, + 'X-Client-User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'WordPress Gatekeeper', + 'User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'WordPress Gatekeeper', + ], + ]); + + if (is_wp_error($response)) { + wpsec_log('[gatekeeper] verification request failed: ' . $response->get_error_message()); + error_log('[Baskerville GK] verify upstream FAILED: ' . $response->get_error_message() . ' | IP=' . ($_SERVER['REMOTE_ADDR'] ?? '')); + + return [ + 'message' => $response->get_error_message(), + 'status_code' => 0, + 'retry_after' => '', + 'error' => 'upstream_error', + ]; + } + + $status_code = (int) wp_remote_retrieve_response_code($response); + $body = (string) wp_remote_retrieve_body($response); + $retry_after = (string) wp_remote_retrieve_header($response, 'retry-after'); + + wpsec_log('[gatekeeper] verification status=' . $status_code); + wpsec_log('[gatekeeper] verification retry-after=' . $retry_after); + wpsec_log('[gatekeeper] verification body=' . substr($body, 0, 300)); + + wpsec_forward_upstream_cookies($response); + + $json = json_decode($body, true); + + if (is_array($json)) { + return [ + 'message' => (string) ($json['message'] ?? ''), + 'status_code' => $status_code, + 'retry_after' => $retry_after, + 'error' => (string) ($json['error'] ?? ''), + ]; + } + + //if the status is 403 && message is "invalid solution", they failed the challenge + //if its 400, then its just an error with the submitted payload itself + + //when its 400, we will follow the flow of re-issueing a new challenge, ie nothing changes BUT + //when its 403 && message is "invalid solution" we need to relay that back to the user so our response + //becomes different. In other words, here, rather than just returning a bool, we ought to return the + //message and status + + return [ + 'message' => trim(wp_strip_all_tags($body)), + 'status_code' => $status_code, + 'retry_after' => $retry_after, + 'error' => '', + ]; +} + + +/* + After the user submitted their solution and it was relayed to the server + if that solution was correct, the server would have responded back with a + challenge_passed cookie. This challenge passed cookie contains everything we + need in order to be able to cryptographically prove that this unique puzzle + was issued to them and that it was indeed solved (ie proof of work). This cookie + is attached to every subsequent request the requester makes such that we can + contact the server side to confirm that this is valid for example that its + still within the time limit we assigned (not expired), not replayed, not forged, + not copied etc + + issues request to the /token/verify endpoint +*/ +function wpsec_verify_captcha_pass_token_cookie_is_valid() { + // Per-IP rate limit: protect upstream /token/verify. + // Fail open (return 200) so a rate-limited visitor is not permanently blocked. + $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); + if ( + $ip !== '' && + isset( $GLOBALS['baskerville_challenge'] ) && + $GLOBALS['baskerville_challenge'] instanceof Baskerville_Gatekeeper && + $GLOBALS['baskerville_challenge']->upstream_rate_limited( $ip, 'token' ) + ) { + wpsec_log( '[gatekeeper] upstream token-verify rate limit exceeded, failing open' ); + return [ 'status_code' => 200, 'message' => 'rate limited, failing open' ]; + } + + $original_url = (is_ssl() ? 'https://' : 'http://') + . ($_SERVER['HTTP_HOST'] ?? '') + . ($_SERVER['REQUEST_URI'] ?? ''); + + $cookie_header = wpsec_build_cookie_header_for_token_verification(); + + wpsec_log('[gatekeeper] forwarding token verification cookie header=' . $cookie_header); + + $response = wp_remote_get(CAPTCHA_PREVIOUSLY_PASSED_TOKEN_VERIFICATION_ENDPOINT, [ + 'timeout' => 10, + 'redirection' => 3, + 'headers' => [ + 'Cookie' => $cookie_header, + 'Accept' => '*/*', + 'X-Client-IP' => $_SERVER['REMOTE_ADDR'] ?? '', + 'X-Original-Host' => $_SERVER['HTTP_HOST'] ?? '', + 'X-Original-URI' => $_SERVER['REQUEST_URI'] ?? '', + 'X-Original-URL' => $original_url, + 'X-Client-User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'WordPress Gatekeeper', + 'User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'WordPress Gatekeeper', + ], + ]); + + if (is_wp_error($response)) { + wpsec_log('[gatekeeper] verification request failed: ' . $response->get_error_message()); + return [ + 'message' => $response->get_error_message(), + 'status_code' => 0, + ]; + } + + $status_code = (int) wp_remote_retrieve_response_code($response); + $body = (string) wp_remote_retrieve_body($response); + + wpsec_log('[gatekeeper] verification status=' . $status_code); + + return [ + 'message' => trim(wp_strip_all_tags($body)), + 'status_code' => $status_code, + ]; +} + + +/* + anytime we wish to issue a challenge to the user, we need to gather + their fingerprint and contact the /captcha/generate endpoint in order + to be able to generate a captcha that is unique to them. We would take + whatever the captcha generation server sends, and push that to the requester + who is requesting something from the wp origin server +*/ +function wpsec_issue_challenge() { + wpsec_log('[gatekeeper] starting challenge issuance flow'); + + // Per-IP rate limit: protect upstream /captcha/generate from flood. + // Fail OPEN on limit exceeded β€” let the visitor through rather than blocking + // a potentially legitimate user whose IP hit the ceiling (e.g. shared NAT). + $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); + if ( + $ip !== '' && + isset( $GLOBALS['baskerville_challenge'] ) && + $GLOBALS['baskerville_challenge'] instanceof Baskerville_Gatekeeper && + $GLOBALS['baskerville_challenge']->upstream_rate_limited( $ip, 'generate' ) + ) { + wpsec_log( '[gatekeeper] upstream generate rate limit exceeded, failing open' ); + return; // let WordPress render the real page + } + + wpsec_log('[gatekeeper] issuing challenge for uri=' . ($_SERVER['REQUEST_URI'] ?? '')); + + // Record gk_redir stat for the analytics precision chart. + if (isset($GLOBALS['baskerville_challenge']) && $GLOBALS['baskerville_challenge'] instanceof Baskerville_Gatekeeper) { + $GLOBALS['baskerville_challenge']->log_challenge_event('redir', '', $ip); + } + + $original_url = (is_ssl() ? 'https://' : 'http://') + . ($_SERVER['HTTP_HOST'] ?? '') + . ($_SERVER['REQUEST_URI'] ?? ''); + + wpsec_log('[gatekeeper] original URL=' . $original_url); + + $response = wp_remote_get(CAPTCHA_GENERATION_ENDPOINT, [ + 'timeout' => 10, + 'redirection' => 3, + 'headers' => [ + 'Accept' => 'text/html', + 'X-Original-Host' => $_SERVER['HTTP_HOST'] ?? '', + 'X-Original-URI' => $_SERVER['REQUEST_URI'] ?? '', + 'X-Original-URL' => $original_url, + 'X-Client-IP' => $_SERVER['REMOTE_ADDR'] ?? '', + 'User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'WordPress Gatekeeper', + ], + ]); + + if (is_wp_error($response)) { + wpsec_log('[gatekeeper] challenge fetch failed: ' . $response->get_error_message() . ' β€” failing open'); + // Upstream unreachable β€” let the visitor through rather than blocking them with a broken page. + wpsec_clear_challenge_cookies(); + return; + } + + $status_code = wp_remote_retrieve_response_code($response); + $content_type = wp_remote_retrieve_header($response, 'content-type') ?: 'text/html; charset=utf-8'; + $body = wp_remote_retrieve_body($response); + + wpsec_log('[gatekeeper] challenge fetch succeeded, status=' . $status_code); + wpsec_log('[gatekeeper] challenge content-type=' . $content_type); + wpsec_log('[gatekeeper] challenge body length=' . strlen($body)); + + wpsec_forward_upstream_cookies($response); + + // Prevent any page-cache plugin from storing the challenge HTML. + // WP Super Cache (both PHP and Expert/mod_rewrite modes), W3TC, WP Rocket etc. + // all check DONOTCACHEPAGE before writing to their cache stores. + if (!defined('DONOTCACHEPAGE')) { + define('DONOTCACHEPAGE', true); + } + // Some plugins also check the global (W3TC, LiteSpeed Cache). + $GLOBALS['DONOTCACHEPAGE'] = 1; + + nocache_headers(); // Sets Cache-Control, Pragma, Expires (browser). + // Explicitly tell CDN/reverse-proxy layers not to store this response. + // nocache_headers() covers the browser; these cover Varnish, Nginx, CDNs. + header('Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'); + header('Surrogate-Control: no-store'); // Varnish / Fastly + header('CDN-Cache-Control: no-store'); // Cloudflare and others + header('Vary: Cookie'); // treat each cookie variation as different cache key + status_header(200); + header('Content-Type: ' . $content_type); + echo $body; + exit; +} + +/* + during the puzzle, if the user clicks on the puzzle refresh button + the wordpress origin will relay a call to the captcha service server + requesting a change of puzzle state via the /captcha/refresh endpoint. + The wp origin need only relay the new state to the user. +*/ +function wpsec_refresh_challenge_state() { + wpsec_log('[gatekeeper] starting challenge refresh flow'); + + $original_url = (is_ssl() ? 'https://' : 'http://') + . ($_SERVER['HTTP_HOST'] ?? '') + . ($_SERVER['REQUEST_URI'] ?? ''); + + $response = wp_remote_get(CAPTCHA_REFRESH_ENDPOINT, [ + 'timeout' => 10, + 'redirection' => 3, + 'headers' => [ + 'Accept' => 'application/json', + 'X-Original-Host' => $_SERVER['HTTP_HOST'] ?? '', + 'X-Original-URI' => $_SERVER['REQUEST_URI'] ?? '', + 'X-Original-URL' => $original_url, + 'X-Client-IP' => $_SERVER['REMOTE_ADDR'] ?? '', + 'User-Agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'WordPress Gatekeeper', + ], + ]); + + if (is_wp_error($response)) { + wpsec_log('[gatekeeper] challenge refresh failed: ' . $response->get_error_message()); + status_header(503); + header('Content-Type: application/json; charset=utf-8'); + echo wp_json_encode([ + 'error' => 'refresh_failed', + 'message' => 'Unable to refresh puzzle.', + ]); + exit; + } + + $status_code = (int) wp_remote_retrieve_response_code($response); + $content_type = wp_remote_retrieve_header($response, 'content-type') ?: 'application/json; charset=utf-8'; + $body = wp_remote_retrieve_body($response); + + wpsec_log('[gatekeeper] challenge refresh succeeded, status=' . $status_code); + wpsec_log('[gatekeeper] challenge refresh body length=' . strlen($body)); + + wpsec_forward_upstream_cookies($response); + + status_header($status_code ?: 200); + header('Content-Type: ' . $content_type); + header('Cache-Control: no-store, no-cache'); + echo $body; + exit; +} + + + + +/** + * Enforces the full CAPTCHA / challenge policy for every frontend request handled by WordPress. + * + * High-level purpose + * ------------------ + * This function is the main gatekeeper for the CAPTCHA flow. It is attached to + * WordPress' `template_redirect` hook, which means it runs on normal frontend + * page requests before WordPress renders the theme/template. + * + * In practice, almost every ordinary browser request to the site will pass through + * this function first. Its job is to decide which of the following should happen: + * + * 1. Allow the request through untouched. + * 2. Verify an existing "passed CAPTCHA" token and allow the request if valid. + * 3. Refresh the currently displayed CAPTCHA puzzle state. + * 4. Verify a submitted CAPTCHA solution. + * 5. Relay rate-limit or verification errors back to the challenge page. + * 6. Issue a brand new challenge. + * + * This function is therefore the main request router for all CAPTCHA-related + * behavior at the WordPress origin. + * + * + * Core idea + * --------- + * The origin server is acting as the policy enforcement point. + * + * The browser never talks directly to the CAPTCHA service in the normal flow. + * Instead: + * + * - The browser sends requests to the WordPress origin. + * - This function inspects the request and the cookies the requester sent. + * - Depending on the state of the requester, the origin either: + * - lets the request continue normally, + * - calls an upstream CAPTCHA endpoint, + * - relays an upstream verification result, + * - or serves/refreshes the challenge. + * + * Request categories handled here + * ------------------------------- + * This function evaluates requests in the following broad categories: + * + * A. Requests that should never be challenged + * These are allowed immediately because challenging them would break WordPress + * behavior or cause unwanted side effects. + * + * B. Requests from users who already carry a CAPTCHA pass token + * These requests are introspected against the upstream token verification + * endpoint. If the token is valid, the request is allowed. + * + * C. Requests from users who should not currently be challenged + * The request is allowed and WordPress continues normal rendering. + * + * D. Requests asking to refresh the puzzle state + * The existing challenge cookies are cleared and the origin fetches a fresh + * challenge state from the upstream refresh endpoint. + * + * E. Requests that contain a CAPTCHA solution submission + * The origin forwards the submitted cookies to the upstream `/captcha/verify` + * endpoint and relays the result back to the browser. + * + * F. Requests from users who are still challenged but have not submitted a valid + * solution yet + * The origin issues a fresh challenge page. + * + * + * Detailed control flow + * --------------------- + * + * Step 1: Log the incoming request + * -------------------------------- + * The function begins by logging that the gatekeeper fired, along with the request + * URI and request method. This is purely for observability and debugging. + * + * + * Step 2: Early allowlist / bypass checks + * --------------------------------------- + * The function immediately allows certain request types through because challenging + * them would either break WordPress or create inconsistent CAPTCHA state. + * + * The following are allowed immediately: + * + * - WordPress admin requests (`is_admin()`) + * - WordPress REST API requests (`REST_REQUEST`) + * - WordPress AJAX requests (`wp_doing_ajax()`) + * - Logged-in privileged/admin users (`current_user_can('manage_options')`) + * - Asset-like requests such as CSS, JS, images, fonts, etc. + * - Favicon requests + * + * + * Step 3: Check for an existing "passed CAPTCHA" token + * ---------------------------------------------------- + * If the requester carries the pass-token cookie, this function treats that as: + * "This requester claims they already solved a CAPTCHA previously." + * + * However, presence alone is not trusted. + * The function therefore calls the upstream token introspection endpoint via: + * wpsec_verify_captcha_pass_token_cookie_is_valid() + * + * That endpoint validates the token against requester properties such as IP and any + * other properties baked into the token. + * + * Outcomes: + * + * - If the upstream returns 204: + * The token is valid and the request is allowed immediately. + * + * - Otherwise: + * The token is treated as invalid, expired, replayed, malformed, or otherwise + * untrustworthy. The local token cookie is cleared, and execution continues. + * The request will then fall through to the normal challenge decision logic. + * + * Important note: + * + * This means that every subsequent request carrying the pass token is actively + * re-validated. The token is not trusted merely because it exists. + * + * + * Step 4: Decide whether this requester should be challenged at all + * ----------------------------------------------------------------- + * The function calls: + * wpsec_should_challenge() + * + * This is the policy decision point. It answers: + * "Does this requester currently belong to a class of users we want to challenge?" + * If the answer is no, the request is allowed through and WordPress renders normally. + * If the answer is yes, the function continues into the challenge-handling paths. + * + * + * Step 5: Check whether the request is asking to refresh the puzzle state + * ----------------------------------------------------------------------- + * The challenge UI can ask for a new puzzle state without performing a full page + * navigation. That intent is communicated via a request header and extracted by: + * wpsec_get_requested_action() + * + * If the requested action is `refresh`: + * - Existing challenge cookies are cleared. + * - A fresh puzzle state is fetched from the upstream refresh endpoint. + * - The new state is returned to the client as JSON. + * + * This path is specifically for refreshing the currently displayed CAPTCHA without + * serving the entire full challenge page again. + * + * + * Step 6: Check whether the request contains a CAPTCHA solution submission + * ------------------------------------------------------------------------ + * If the request includes the expected CAPTCHA submission cookies, the function + * treats it as an attempt to solve the currently active challenge. + * + * It then calls: + * wpsec_verify_captcha_solution_via_upstream_cookies() + * + * That helper forwards the relevant cookies to the upstream `/captcha/verify` + * endpoint. The upstream service verifies: + * + * - the original challenge cookie, + * - the solution hash, + * - the click-chain cookies, + * - and any replay / integrity / timing / rate-limiting checks. + * + * The origin then examines the upstream response and branches as follows: + * + * a) 403 + "invalid solution" + * The user solved the puzzle incorrectly. + * The origin relays a plain 403 back to the browser so the client-side CAPTCHA + * UI can show the appropriate message. + * + * b) 429 + * The requester is being rate limited. + * The origin relays: + * - HTTP 429 + * - Retry-After header if provided + * - a JSON body containing machine-readable and human-readable fields + * The client-side UI can then show the rate-limit message and countdown. + * + * c) 400 + * The submission payload itself was malformed or incomplete. + * The origin clears challenge cookies and sends back a 404-ish refresh signal + * (`refresh challenge`) so the client can request a fresh puzzle state. + * + * d) 200 + * The CAPTCHA solution was correct. + * The upstream service has already set the pass-token cookie in its response, + * and that cookie has already been forwarded back to the browser by the origin. + * The origin then clears the temporary challenge/solution cookies and allows + * the request through. + * + * e) Any other status + * The result is treated as unexpected. The origin clears challenge cookies and + * falls back to issuing a new challenge. + * + * + * Error handling + * -------------- + * The entire function is wrapped in a try/catch. + * If an exception is thrown during policy enforcement, the error is logged. + * This is intended to prevent silent failures during enforcement and make + * troubleshooting easier. + * + * + * Important invariants / design assumptions + * ----------------------------------------- + * 1. This function is the single entry point for CAPTCHA enforcement on ordinary + * frontend requests. + * + * 2. The pass-token cookie is never trusted by presence alone. It must always be + * introspected via the upstream token verification endpoint. + * + * 3. Challenge issuance and challenge verification are both mediated by the + * WordPress origin, not by direct browser-to-CAPTCHA-service traffic. + * + * 4. Asset-like and favicon requests are intentionally bypassed to prevent a + * second challenge from being issued after the original challenge page has + * already embedded its own puzzle state. + * + * 5. A successful CAPTCHA verification results in: + * - the pass-token cookie being forwarded to the browser, + * - challenge/solution cookies being cleared, + * - and the original request being allowed to continue. + * + * + * Summary of possible outcomes + * ---------------------------- + * Every frontend request entering this function ends in one of these outcomes: + * + * - Allowed immediately due to admin/API/asset bypass + * - Allowed because the pass token introspected successfully + * - Allowed because policy says requester should not be challenged + * - Handled as a refresh request and given fresh puzzle state JSON + * - Handled as a CAPTCHA verification attempt and given a verification result + * - Given a brand new challenge page + * + * tldr + * + * This function is the authoritative decision engine for whether the requester + * sees the real page, sees a challenge, refreshes a challenge, verifies a + * challenge, or is blocked/rate-limited during challenge verification. + */ +function wpsec_enforce_captcha_policy() { + try { + + wpsec_log('[gatekeeper] template_redirect fired'); + wpsec_log('[gatekeeper] request uri=' . ($_SERVER['REQUEST_URI'] ?? '')); + wpsec_log('[gatekeeper] request method=' . ($_SERVER['REQUEST_METHOD'] ?? '')); + + // Unconditional diagnostic β€” remove after bug is confirmed fixed. + $diag_cookies = []; + foreach ($_COOKIE as $n => $v) { + if (strpos($n, '__wpsec_') === 0 || strpos($n, 'baskerville') === 0) { + $diag_cookies[] = $n; + } + } + error_log('[Baskerville GK] POLICY fired uri=' . ($_SERVER['REQUEST_URI'] ?? '') . ' challenge_global=' . (!empty($GLOBALS['baskerville_gatekeeper_challenge']) ? 'SET' : 'unset') . ' relevant_cookies=' . implode(',', $diag_cookies)); + + // maybe_activate_test_mode() (priority 0) may have set this before us (priority 1) + $is_test_mode = !empty($GLOBALS['baskerville_gatekeeper_test_mode']); + + // Respect master switch β€” if protection is off, clear any stale challenge cookies and allow + $bsk_options = get_option('baskerville_settings', array()); + $master_enabled = !isset($bsk_options['master_protection_enabled']) || $bsk_options['master_protection_enabled']; + if (!$master_enabled && !$is_test_mode) { + wpsec_log('[gatekeeper] master switch OFF, clearing stale cookies and allowing'); + wpsec_clear_challenge_cookies(); + wpsec_clear_pass_token_cookie(); + return; + } + + // Also bail immediately if the provider is no longer 'gatekeeper' (settings changed mid-session) + $provider = isset($bsk_options['captcha_provider']) ? $bsk_options['captcha_provider'] : 'none'; + if ($provider !== 'gatekeeper' && !$is_test_mode) { + wpsec_log('[gatekeeper] provider changed, clearing stale cookies and allowing'); + wpsec_clear_challenge_cookies(); + wpsec_clear_pass_token_cookie(); + return; + } + + /* + we start by checking you're not admin, hitting the admin page or anything + relevant for wordpress correct functionality + */ + if (is_admin()) { + wpsec_log('[gatekeeper] admin request, allowing'); + return; + } + + if (defined('REST_REQUEST') && REST_REQUEST) { + wpsec_log('[gatekeeper] REST request, allowing'); + return; + } + + if (wp_doing_ajax()) { + wpsec_log('[gatekeeper] AJAX request, allowing'); + return; + } + + if (is_user_logged_in() && current_user_can('manage_options')) { + if (!$is_test_mode) { + wpsec_log('[gatekeeper] privileged logged-in user, allowing'); + return; + } + wpsec_log('[gatekeeper] admin test mode active, proceeding with challenge flow'); + } + + /* + at this point we need to + make sure not a follow up secondary favicon request or something that could + trigger us sending additional state bringing the state embedded in the + puzzle itself out of sync with the headers/cookies & clickchain genesis hash + */ + if (wpsec_is_asset_like_request()) { + wpsec_log('[gatekeeper] asset-like request, allowing'); + return; + } + + if (($_SERVER['REQUEST_URI'] ?? '') === '/favicon.ico') { + wpsec_log('[gatekeeper] favicon request, allowing'); + return; + } + + /* + at this point we can continue with regular flow challenge flow + since this is a normal user, so we need to check if they're being challenged + or have submitted a solution or have already passed etc + */ + + // Fast path: check our own local HMAC pass cookie. + // This is set by set_local_pass() after successful upstream verification. + // If valid, skip the upstream /token/verify round-trip entirely. + if (isset($GLOBALS['baskerville_challenge']) && $GLOBALS['baskerville_challenge'] instanceof Baskerville_Gatekeeper) { + $has_local = $GLOBALS['baskerville_challenge']->validate_local_pass(); + error_log('[Baskerville GK] local pass check: ' . ($has_local ? 'VALID' : 'absent') . ' cookie=' . (isset($_COOKIE[Baskerville_Gatekeeper::GK_PASS_COOKIE]) ? 'present' : 'missing')); + if ($has_local) { + wpsec_log('[gatekeeper] local pass cookie valid, allowing'); + return; + } + } + + //we check to see if the captcha challenge has previously been passed + //by looking for the challenge passed cookie. If so, we go through validating it + //to make sure its not forged, expired, replayed etc. If not we continue + //to check whether this current requester ought to be challenged + if (wpsec_captcha_pass_token_cookie_is_present()) { + + $token_validation_result = wpsec_verify_captcha_pass_token_cookie_is_valid(); + + $message = $token_validation_result['message'] ?? ''; + $status_code = (int) ($token_validation_result['status_code'] ?? 0); + + wpsec_log('[gatekeeper] token verify status=' . $status_code . ' message=' . $message); + + // Accept both 200 and 204 β€” the endpoint may return either depending on server version. + if ($status_code === 204 || $status_code === 200) { + wpsec_log('[gatekeeper] valid pass cookie found, allowing'); + return; + + } else { + + //either the token is a fake, or replayed from someone else or + //it expired. It doesnt matter, their solution cookie is no longer valid + //consequently, they arent allowed through anymore. NOTE: what this actually + //means is that we need to check again if action is required (ie should_challenge()) + //will do its thing. if they are on that list, then they should get challenged again + //otherwise its ok. That being said we need to remove that cookie from them + wpsec_clear_pass_token_cookie(); + + if ($status_code === 400) { + //most likely a 400, bad formatting, missing properties etc + + } else if ($status_code === 500) { + //something went wrong at the level of the captcha server + + } else if ($status_code === 403 && $message === 'token invalid') { + //detected tampering, do you as see fit here (ban, temporary block, rate limit etc) + + } else if ($status_code === 403 && $message === 'token tampering') { + //IP or other attribute doesnt line up with requester properties, could be replay etc + //do as you see fit (ban, temporary block, rate limit etc) + + } else if ($status_code === 403 && $message === 'token expired') { + //token expired, so decide what to do here. Most likely just fall through to + //checking if wpsec_should_challenge() + + } else { + //catchall, log since this is an unexpected error code + //TODO: list out all properties and stuff useful for logging + //then fallthrough to wpsec_should_challenge check + wpsec_log('[gatekeeper] unexpected error occured!'); + } + } + } + + // Handle puzzle refresh (X-Action: refresh header sent by the challenge JS). + // Must run BEFORE the should_challenge() gate because the JS's fetch() uses + // Accept:*/* which causes the firewall to bail at is_public_html_request() + // and leave baskerville_gatekeeper_challenge unset. + $requested_action = wpsec_get_requested_action(); + wpsec_log('[gatekeeper] requested action=' . $requested_action); + if ($requested_action === 'refresh') { + wpsec_log('[gatekeeper] refresh action detected'); + wpsec_clear_challenge_cookies(); + wpsec_refresh_challenge_state(); + } + + // Process solution submission BEFORE the should_challenge() gate. + // + // Root cause: the firewall (plugins_loaded) contains an early-return guard + // `if (!is_public_html_request()) return;` that fires on the challenge JS's + // fetch() call because fetch() sends Accept:*/* rather than text/html. + // This leaves baskerville_gatekeeper_challenge unset, making + // wpsec_should_challenge() return false even while solution cookies are in + // the request β€” so the user would be silently allowed through without any + // verification taking place. + // + // Fix: check for solution cookies here, unconditionally. If the visitor has + // submitted a solution it must always be verified, regardless of whether the + // firewall challenge-flag was set. + if (wpsec_has_verification_cookies()) { + $verification_result = wpsec_verify_captcha_solution_via_upstream_cookies(); + $message = $verification_result['message'] ?? ''; + $status_code = (int) ($verification_result['status_code'] ?? 0); + + // Unconditional log β€” visible in error_log regardless of WP_DEBUG. + // Remove after the verification loop is fixed. + error_log('[Baskerville GK] verify upstream status=' . $status_code . ' message=' . substr($message, 0, 80)); + + wpsec_log('[gatekeeper] verification result status=' . $status_code . ' message=' . $message); + + if ($status_code === 403 && $message === 'invalid solution') { + // Wrong solution. Clear all challenge/solution cookies and serve a + // fresh challenge instead of sending a plain-text 403 "invalid solution" + // response. + // + // Why not send wpsec_send_plain_response(403, …) here: + // When a CDN (e.g. eQpress) intercepts the verification fetch() and + // serves a cached 200 response, the browser never receives our 403 and + // the solution cookies are never cleared. On the next regular page + // navigation PHP sees stale solution cookies, re-runs verification, + // gets 403 again, and sends plain text "invalid solution" β€” the browser + // renders a blank white page with that text. + // + // By clearing cookies + re-issuing the challenge: + // - The challenge JS's fetch() gets 200 HTML β†’ handleRedirect() β†’ reload. + // On the reload the solution cookies are gone β†’ fresh challenge is shown. + // - Any later regular navigation with stale cookies also ends up at a + // clean challenge page rather than a blank error page. + // - Security is unaffected: the user must still solve the puzzle correctly. + // Count this failure and ban the IP if the threshold is reached. + $fail_ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')); + $just_banned = ( + $fail_ip !== '' && + isset($GLOBALS['baskerville_challenge']) && + $GLOBALS['baskerville_challenge'] instanceof Baskerville_Gatekeeper && + $GLOBALS['baskerville_challenge']->record_failed_attempt($fail_ip) + ); + + if ($just_banned) { + wpsec_log('[gatekeeper] IP banned after repeated challenge failures: ' . $fail_ip); + wpsec_clear_challenge_cookies(); + wpsec_send_plain_response(403, 'Forbidden'); + return; + } + + wpsec_log('[gatekeeper] invalid solution β€” clearing cookies and re-issuing fresh challenge'); + wpsec_clear_challenge_cookies(); + wpsec_issue_challenge(); + return; + + } else if ($status_code === 429) { + //relay back 429 + message (which would be "x seconds") and is handled by + //client side to display that they are being rate limited + wpsec_log('[gatekeeper] relaying rate limit back to client'); + + if (!empty($verification_result['retry_after'])) { + header('Retry-After: ' . $verification_result['retry_after']); + } + + header('Content-Type: application/json; charset=utf-8'); + status_header(429); + echo wp_json_encode([ + 'error' => $verification_result['error'] ?: 'rate_limited', + 'message' => $message !== '' ? $message : 'Too many requests.', + 'retry_after_seconds' => is_numeric($verification_result['retry_after']) ? (int) $verification_result['retry_after'] : null, + ]); + exit; + + } else if ($status_code === 400) { + // Upstream could not parse the solution cookies (bad format / tampering). + // Send HTTP 400 back so the JS shows "Incorrect solution. Please try again" + // (case 6 in the client state machine). + // + // IMPORTANT: do NOT send 404 here. The challenge JS treats 404 as a + // success signal (case 5 β†’ handleRedirect) which causes an infinite + // reload loop when there is no pass cookie yet. + // + // Keep __wpsec_challenge_ so the user can retry the same puzzle; + // only clear the per-attempt solution cookies. + wpsec_log('[gatekeeper] bad verification payload (400), clearing solution cookies'); + wpsec_clear_solution_cookies(); + wpsec_send_plain_response(400, 'invalid solution'); + + } else if ($status_code === 200 || $status_code === 204) { + //their solution was correct, the wordpress origin will receive + //a "challenge passed" solution cookie in the header. Here we need + //to remove them from the list to challenge and subsequently we + //need to delete all other cookies they might have AND attach this + //new cookie that the captcha service server sends back to the wordpress + //origin server + wpsec_log('[gatekeeper] verification succeeded (status=' . $status_code . '), setting local pass and clearing challenge cookies'); + + // Set our own HMAC-signed local pass cookie so the firewall will + // recognise this visitor on the next request regardless of whether + // the upstream also forwarded __wpsec_solved_. + if (isset($GLOBALS['baskerville_challenge']) && $GLOBALS['baskerville_challenge'] instanceof Baskerville_Gatekeeper) { + $GLOBALS['baskerville_challenge']->set_local_pass(); + $GLOBALS['baskerville_challenge']->log_challenge_event('pass'); + wpsec_log('[gatekeeper] local pass cookie set (baskerville_gk_pass)'); + } + + // Clear leftover challenge/solution cookies. + wpsec_clear_challenge_cookies(); + + // Log whether __wpsec_solved_ was also forwarded by upstream (informational only). + if (isset($_COOKIE[CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME])) { + wpsec_log('[gatekeeper] upstream pass cookie also forwarded'); + } else { + wpsec_log('[gatekeeper] note: upstream pass cookie absent; local pass handles access'); + } + + // If this was an admin test session, end it now + if (!empty($GLOBALS['baskerville_gatekeeper_test_mode'])) { + $user_id = get_current_user_id(); + if ($user_id) { + delete_user_meta($user_id, 'baskerville_gk_test'); + wpsec_log('[gatekeeper] test mode ended for user ' . $user_id); + } + } + + // Send a compact, explicitly non-cacheable 200 response and exit. + // + // Why NOT return here (letting WordPress render the full page): + // The challenge JS calls fetch() with credentials:"include". If we + // return, WordPress sends a full HTML page with Set-Cookie: baskerville_gk_pass. + // A CDN (e.g. eQpress/Deflect) may cache this response INCLUDING the + // Set-Cookie header. The next visitor whose verification fetch() is served + // from that cache would receive the previous user's pass cookie, store it, + // see HTTP 200, call handleRedirect() β†’ reload β†’ validate_local_pass() passes + // (HMAC not IP-bound) β†’ visitor gets through WITHOUT solving the puzzle. + // + // By sending a tiny JSON body + no-store headers and exiting: + // - CDNs are explicitly told not to cache (no-store headers on a small response) + // - The JS only needs status 200 to call handleRedirect() β†’ page reload + // - On the reload, baskerville_gk_pass is present in the browser cookie jar + // β†’ has_valid_pass() = true β†’ no challenge β†’ user sees the real page + // - The Set-Cookie header is still sent (cookies are queued before exit) + wpsec_log('[gatekeeper] sending no-store 200 ack β€” pass cookie in Set-Cookie, JS will reload'); + nocache_headers(); + header('Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'); + header('Surrogate-Control: no-store'); + header('CDN-Cache-Control: no-store'); + header('Vary: Cookie'); + status_header(200); + header('Content-Type: application/json; charset=utf-8'); + echo '{"ok":true}'; + exit; + + } else if ($status_code === 0) { + // Upstream unreachable β€” fail open to prevent infinite loop. + // Clear stale challenge cookies and let the visitor through. + wpsec_log('[gatekeeper] upstream unreachable (status=0), clearing cookies and allowing'); + wpsec_clear_challenge_cookies(); + return; + + } else { + // Unexpected status from /captcha/verify. + // Do NOT call wpsec_issue_challenge() here β€” that sends HTTP 200, which + // the challenge JS treats as success β†’ handleRedirect β†’ reload β†’ no pass + // cookie β†’ challenge again β†’ infinite loop. + // Instead, send HTTP 500 so the JS shows "Incorrect solution. Please try again" + // and lets the user retry without a reload loop. + wpsec_log('[gatekeeper] unexpected verification result (status=' . $status_code . '), sending 500 to avoid reload loop'); + error_log('[Baskerville GK] unexpected /captcha/verify status=' . $status_code . ' message=' . substr($message, 0, 80)); + wpsec_clear_solution_cookies(); + wpsec_send_plain_response(500, 'verification error'); + } + } + + + // No solution cookies. Only issue a fresh challenge if the firewall flagged + // this visitor (under_attack mode, borderline score, etc.). Visitors who are + // not meant to be challenged should see the normal page. + if (!wpsec_should_challenge()) { + wpsec_log('[gatekeeper] should not challenge, allowing normal render'); + return; + } + + wpsec_log('[gatekeeper] no valid verification cookies, issuing challenge'); + wpsec_issue_challenge(); + + return; + + + } catch (\Exception $e) { //try/catch exception + wpsec_log('[gatekeeper] Unexpected exception: ' . $e->getMessage()); + } catch (\Error $e) { //fatal php error + wpsec_log('[gatekeeper] Unexpected error: ' . $e->getMessage()); + } +} + + +/** + * Baskerville Gatekeeper β€” challenge provider class. + * + * Implements the same firewall interface as Baskerville_Turnstile so that the + * firewall can use either provider transparently via $GLOBALS['baskerville_challenge']. + * + * Key difference from Turnstile: redirect_to_challenge() does NOT perform an + * HTTP redirect. It sets $GLOBALS['baskerville_gatekeeper_challenge'] = true and + * returns, allowing wpsec_enforce_captcha_policy() on template_redirect to serve + * the challenge inline at the original URL. + */ +class Baskerville_Gatekeeper { + + /** Local pass cookie set by WordPress after successful upstream verification. */ + const GK_PASS_COOKIE = 'baskerville_gk_pass'; + /** TTL for local pass in seconds (24 h). */ + const GK_PASS_TTL = 86400; + + /** Max wrong solutions before an IP is banned (default; overridden by gk_fail_max option). */ + const GK_FAIL_MAX = 3; + /** Window in seconds over which failures are counted (1 h). */ + const GK_FAIL_WINDOW = 3600; + /** How long a challenge-failure ban lasts in seconds (default 1 h; overridden by gk_ban_ttl_sec option). */ + const GK_BAN_TTL = 3600; + + private $enabled; + private $challenge_borderline; + private $borderline_min; + private $borderline_max; + private $under_attack; + + /** @var Baskerville_Core */ + private $core; + + /** @var Baskerville_Stats */ + private $stats; + + public function __construct($core = null, $stats = null) { + $options = get_option('baskerville_settings', array()); + $provider = isset($options['captcha_provider']) ? $options['captcha_provider'] : 'none'; + $this->enabled = ($provider === 'gatekeeper'); + $this->challenge_borderline = isset($options['turnstile_challenge_borderline']) ? (bool) $options['turnstile_challenge_borderline'] : false; + $this->borderline_min = isset($options['turnstile_borderline_min']) ? (int) $options['turnstile_borderline_min'] : 40; + $this->borderline_max = isset($options['turnstile_borderline_max']) ? (int) $options['turnstile_borderline_max'] : 70; + $this->under_attack = isset($options['turnstile_under_attack']) ? (bool) $options['turnstile_under_attack'] : false; + $this->core = $core; + $this->stats = $stats; + } + + public function is_enabled() { + return $this->enabled; + } + + /** + * Per-IP rate limiter for upstream calls. + * + * Limits how often a single visitor IP can trigger calls to captcha.openports.dev. + * Uses the same file-cache window counter as the firewall burst protection. + * + * Limits (per 60-second window): + * generate β€” 6 (challenge page loads / refreshes) + * verify β€” 10 (solution submission attempts) + * token β€” 6 (pass-token re-verification) + * + * Returns true when the IP has exceeded the limit (caller should fail open or 429). + */ + public function upstream_rate_limited( string $ip, string $action ): bool { + if ( ! $this->core || $ip === '' ) { + return false; + } + + $limits = [ + 'generate' => 6, + 'verify' => 10, + 'token' => 6, + ]; + + $max = $limits[ $action ] ?? 6; + $cnt = $this->core->fc_inc_in_window( "gk_{$action}:{$ip}", 60 ); + + if ( $cnt > $max ) { + wpsec_log( "[gatekeeper] rate limit hit action={$action} ip={$ip} cnt={$cnt}" ); + return true; + } + + return false; + } + + /** + * Record a failed challenge attempt for an IP. + * + * Increments a sliding-window counter (GK_FAIL_WINDOW seconds). + * When the count reaches GK_FAIL_MAX the IP is written into the shared + * ban cache (same key/format used by Baskerville_Firewall::set_ban) so + * the firewall will block it on every subsequent request. + * + * Returns true if the IP was just banned (caller should respond with 403). + */ + public function record_failed_attempt(string $ip): bool { + if (!$this->core || $ip === '') { + return false; + } + + // Read configurable thresholds from baskerville_settings (fall back to constants). + $opts = get_option('baskerville_settings', array()); + $fail_max = isset($opts['gk_fail_max']) ? (int) $opts['gk_fail_max'] : self::GK_FAIL_MAX; + $ban_ttl = isset($opts['gk_ban_ttl_sec']) ? (int) $opts['gk_ban_ttl_sec'] : self::GK_BAN_TTL; + + $cnt = $this->core->fc_inc_in_window("gk_fail:{$ip}", self::GK_FAIL_WINDOW); + wpsec_log("[gatekeeper] challenge fail count ip={$ip} cnt={$cnt}/{$fail_max}"); + + // Write gk_fail stat to DB for analytics. + $this->log_challenge_event('fail', 'gk-challenge-fail', $ip); + + if ($cnt >= $fail_max) { + $payload = [ + 'reason' => 'gk-challenge-fail', + 'until' => time() + $ban_ttl, + 'score' => 100, + 'cls' => 'bot', + ]; + $this->core->fc_set("ban:{$ip}", $payload, $ban_ttl); + wpsec_log("[gatekeeper] IP BANNED after {$cnt} failed challenges: ip={$ip}"); + return true; + } + + return false; + } + + /** + * Write a gk_redir / gk_pass / gk_fail event to the stats table. + * Mirrors Baskerville_Turnstile::log_challenge_event() but uses gk_ prefix. + * + * @param string $result 'redir' | 'pass' | 'fail' + * @param string $reason Optional reason string (for fail events) + * @param string $ip Visitor IP (defaults to REMOTE_ADDR) + */ + public function log_challenge_event(string $result, string $reason = '', string $ip = '') { + global $wpdb; + + if ($ip === '') { + $ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')); + } + $baskerville_id = isset($_COOKIE['baskerville_id']) ? sanitize_text_field(wp_unslash($_COOKIE['baskerville_id'])) : ''; + $baskerville_id = substr($baskerville_id, 0, 100); + $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; + + $event_type = 'gk_' . $result; // gk_redir, gk_pass, gk_fail + + $table_name = $wpdb->prefix . 'baskerville_stats'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $table_name, + array( + 'visit_key' => wp_generate_uuid4(), + 'ip' => $ip, + 'baskerville_id' => $baskerville_id, + 'timestamp_utc' => current_time('mysql', true), + 'event_type' => $event_type, + 'block_reason' => $reason, + 'user_agent' => $user_agent, + 'score' => 0, + 'classification' => 'gatekeeper', + 'had_fp' => !empty($baskerville_id) ? 1 : 0, + 'evaluation_json' => '{}', + 'score_reasons' => '', + 'classification_reason' => 'gk_challenge', + ), + array('%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%s', '%s', '%s') + ); + } + + public function should_challenge($score, $baskerville_id) { + if (!$this->enabled) { + return false; + } + if ($this->has_valid_pass()) { + return false; + } + if ($this->under_attack) { + return true; + } + if (!$this->challenge_borderline) { + return false; + } + return $score >= $this->borderline_min && $score <= $this->borderline_max; + } + + /** + * Set a local HMAC-signed pass cookie so the firewall recognises a verified visitor. + * Called after the upstream /captcha/verify returns 200. + * This is the primary pass mechanism β€” it does not depend on the upstream setting + * __wpsec_solved_ (which can silently fail due to proxy/cookie-forwarding issues). + * + * The cookie is paired with a server-side file-cache entry keyed by IP+timestamp. + * validate_local_pass() requires BOTH to be present. This prevents a CDN from + * replaying a cached Set-Cookie header (from a previous user's successful solve) to + * a different visitor: the cache entry only exists for the IP that actually solved. + */ + public function set_local_pass() { + $timestamp = time(); + $ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')); + $secret = wp_salt('auth'); + $hmac = hash_hmac('sha256', (string) $timestamp, $secret); + $value = $timestamp . '.' . $hmac; + + setcookie(self::GK_PASS_COOKIE, $value, [ + 'expires' => $timestamp + self::GK_PASS_TTL, + 'path' => '/', + 'domain' => '', + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + $_COOKIE[self::GK_PASS_COOKIE] = $value; + + // Write IP-bound server-side record. validate_local_pass() checks this + // in addition to the HMAC so CDN-replayed cookies from other IPs are rejected. + if ($this->core && $ip !== '') { + $this->core->fc_set("gk_lp:{$ip}:{$timestamp}", 1, self::GK_PASS_TTL); + } + } + + /** + * Validate the local HMAC pass cookie set by set_local_pass(). + * + * Requires two things: + * 1. A valid HMAC on the cookie value (proves it was issued by this server). + * 2. A matching server-side cache entry keyed by IP + timestamp (proves the + * cookie was originally issued for *this* IP, not replayed from another + * visitor via a CDN-cached Set-Cookie header). + */ + public function validate_local_pass() { + $cookie = isset($_COOKIE[self::GK_PASS_COOKIE]) ? (string) $_COOKIE[self::GK_PASS_COOKIE] : ''; + if ($cookie === '') { + return false; + } + $dot = strpos($cookie, '.'); + if ($dot === false) { + return false; + } + $timestamp = (int) substr($cookie, 0, $dot); + $hmac = (string) substr($cookie, $dot + 1); + + if ($timestamp <= 0 || time() > $timestamp + self::GK_PASS_TTL) { + return false; + } + $secret = wp_salt('auth'); + $expected = hash_hmac('sha256', (string) $timestamp, $secret); + if (!hash_equals($expected, $hmac)) { + return false; + } + + // Require the server-side IP-bound record to exist. + // Without this check a CDN could replay a cached baskerville_gk_pass Set-Cookie + // header (from another visitor's successful solve) and bypass the challenge. + if ($this->core) { + $ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')); + if ($ip !== '' && !$this->core->fc_get("gk_lp:{$ip}:{$timestamp}")) { + error_log('[Baskerville GK] validate_local_pass: HMAC ok but NO server record β€” ip=' . $ip . ' ts=' . $timestamp . ' (CDN replay or expired cache)'); + return false; + } + error_log('[Baskerville GK] validate_local_pass: HMAC + server record OK β€” ip=' . $ip . ' ts=' . $timestamp); + } + + return true; + } + + /** + * Lightweight pass check used by the firewall at plugins_loaded. + * Checks the local pass first (no upstream call), then falls back to + * the upstream-issued __wpsec_solved_ token and the server-side cache. + */ + public function has_valid_pass() { + // Local HMAC pass β€” set by set_local_pass() after successful upstream verification. + // validate_local_pass() now also checks a server-side IP-bound cache entry so + // CDN-replayed cookies from other visitors are rejected. + if ($this->validate_local_pass()) { + error_log('[Baskerville GK] has_valid_pass: LOCAL PASS valid (baskerville_gk_pass)'); + return true; + } + // Upstream pass token forwarded from captcha.openports.dev. + if ($this->has_pass_cookie()) { + error_log('[Baskerville GK] has_valid_pass: UPSTREAM COOKIE present (__wpsec_solved_=' . substr((string)($_COOKIE[CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME] ?? ''), 0, 30) . ')'); + return true; + } + error_log('[Baskerville GK] has_valid_pass: FALSE (no valid pass)'); + return false; + } + + public function has_pass_cookie() { + return !empty($_COOKIE[CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME]); + } + + /** + * Sets a flag for wpsec_enforce_captcha_policy() to pick up at template_redirect. + * Does NOT perform an HTTP redirect β€” challenge is served inline by the gatekeeper. + */ + public function redirect_to_challenge() { + $GLOBALS['baskerville_gatekeeper_challenge'] = true; + + // Tell page-cache plugins not to store this response. + // Defined here (plugins_loaded) so it is set before any caching plugin + // hooks into shutdown or ob_start to write its cache file. + if (!defined('DONOTCACHEPAGE')) { + define('DONOTCACHEPAGE', true); + } + $GLOBALS['DONOTCACHEPAGE'] = 1; + } + + /** + * Register the template_redirect hook that serves the challenge inline. + * Called from plugins_loaded after the firewall is initialised. + */ + public function init() { + add_action('template_redirect', 'wpsec_enforce_captcha_policy', 1); + add_action('template_redirect', array($this, 'maybe_activate_test_mode'), 0); + add_action('wp_ajax_baskerville_gk_test_start', array($this, 'ajax_test_start')); + add_action('wp_ajax_baskerville_gk_test_stop', array($this, 'ajax_test_stop')); + + // Tell WP Super Cache (PHP mode) to bypass its cache for visitors who + // carry any of our cookies. Super Cache stores a list of "no-cache" + // cookie names; if any of them is present in the request the cached + // file is not served and WordPress runs normally. + $this->register_supercache_bypass_cookies(); + } + + /** + * Register Baskerville cookies with WP Super Cache so that users who have + * already solved the challenge (or are mid-challenge) are never served a + * stale cached version of the challenge page. + * + * This works for WP Super Cache PHP/Simple mode. Expert (mod_rewrite) mode + * is handled by defining DONOTCACHEPAGE before writing the challenge response, + * which prevents the challenge HTML from ever entering the file cache. + */ + private function register_supercache_bypass_cookies() { + // WP Super Cache: filter to append cookie names. + add_filter('wpsc_cookie_names', function( $cookies ) { + $ours = array( + self::GK_PASS_COOKIE, // baskerville_gk_pass + CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME, // __wpsec_solved_ + USER_CAPTCHA_CHALLENGE_COOKIE_NAME, // __wpsec_challenge_ + 'baskerville_id', + ); + return array_unique( array_merge( (array) $cookies, $ours ) ); + }); + + // WP Super Cache also checks the global $cache_rejected_cookies array. + add_action('wp', function() { + global $cache_rejected_cookies; + if (!is_array($cache_rejected_cookies)) { + $cache_rejected_cookies = array(); + } + $ours = array( + self::GK_PASS_COOKIE, + CAPTCHA_PREVIOUSLY_PASSED_COOKIE_NAME, + USER_CAPTCHA_CHALLENGE_COOKIE_NAME, + 'baskerville_id', + ); + $cache_rejected_cookies = array_unique( array_merge( $cache_rejected_cookies, $ours ) ); + }); + } + + /** + * Runs at template_redirect priority 0 (before wpsec_enforce_captcha_policy). + * If the current logged-in admin has an active test session, sets the challenge + * globals so the admin bypass in wpsec_enforce_captcha_policy() is skipped. + */ + public function maybe_activate_test_mode() { + if (!is_user_logged_in() || !current_user_can('manage_options')) { + return; + } + $user_id = get_current_user_id(); + $expiry = (int) get_user_meta($user_id, 'baskerville_gk_test', true); + if ($expiry <= 0 || time() > $expiry) { + return; + } + $GLOBALS['baskerville_gatekeeper_challenge'] = true; + $GLOBALS['baskerville_gatekeeper_test_mode'] = true; + wpsec_log('[gatekeeper] test mode active for user ' . $user_id . ', expires ' . date('H:i:s', $expiry)); + } + + /** AJAX: start test mode for the current admin user. */ + public function ajax_test_start() { + check_ajax_referer('baskerville_gk_test_start', 'nonce'); + if (!current_user_can('manage_options')) { + wp_send_json_error('Unauthorized', 403); + } + $user_id = get_current_user_id(); + $expiry = time() + 10 * MINUTE_IN_SECONDS; + update_user_meta($user_id, 'baskerville_gk_test', $expiry); + wp_send_json_success(array( + 'url' => home_url('/'), + 'expiry' => $expiry, + )); + } + + /** AJAX: stop test mode for the current admin user. */ + public function ajax_test_stop() { + check_ajax_referer('baskerville_gk_test_stop', 'nonce'); + if (!current_user_can('manage_options')) { + wp_send_json_error('Unauthorized', 403); + } + delete_user_meta(get_current_user_id(), 'baskerville_gk_test'); + wp_send_json_success(); + } +} \ No newline at end of file diff --git a/includes/class-baskerville-honeypot.php b/includes/class-baskerville-honeypot.php index 5bf4cae..a2d2f3d 100644 --- a/includes/class-baskerville-honeypot.php +++ b/includes/class-baskerville-honeypot.php @@ -166,10 +166,8 @@ public function handle_honeypot_visit() { // Send 403 response status_header(403); nocache_headers(); - echo "\n\n\n" . esc_html__( '403 Forbidden', 'baskerville-ai-security' ) . "\n\n\n"; - echo "

" . esc_html__( '403 Forbidden', 'baskerville-ai-security' ) . "

\n"; - echo "

" . esc_html__( 'Access denied. Automated bot detected.', 'baskerville-ai-security' ) . "

\n"; - echo "\n"; + header('Content-Type: text/plain; charset=UTF-8'); + echo "Forbidden\n"; exit; } else { // error_log("Baskerville Honeypot: NOT banning IP $ip (ban_enabled={$ban_enabled}, honeypot_ban_enabled={$honeypot_ban_enabled})"); diff --git a/readme.txt b/readme.txt index 883181a..87a9156 100644 --- a/readme.txt +++ b/readme.txt @@ -7,7 +7,7 @@ Stable tag: 1.0.3 Requires PHP: 7.4 License: GPL v3 -Advanced WordPress security plugin with AI bot detection, GeoIP access control, and Cloudflare Turnstile integration. +Advanced WordPress security plugin with AI bot detection, GeoIP access control, and CAPTCHA challenge support (Baskerville Gatekeeper or Cloudflare Turnstile). == Description == @@ -17,18 +17,19 @@ Baskerville is a comprehensive WordPress security plugin that protects your site * **AI Bot Detection** - Intelligent classification of bots vs. humans with configurable score thresholds * **GeoIP Access Control** - Block or allow traffic by country (whitelist/blacklist modes) -* **Cloudflare Turnstile** - CAPTCHA challenge for borderline bot scores with precision analytics +* **Baskerville Gatekeeper** - Our own state-space puzzle CAPTCHA (optional, requires no API keys) +* **Cloudflare Turnstile** - Cloudflare's CAPTCHA as an alternative challenge option * **Browser Fingerprinting** - Advanced client-side fingerprinting (Canvas, WebGL, Audio) * **Honeypot Detection** - Hidden links to catch AI crawlers -* **Real-Time Analytics** - Live feed, traffic statistics, and Turnstile precision metrics +* **Real-Time Analytics** - Live feed, traffic statistics, and challenge precision metrics * **Under Attack Mode** - Emergency mode to challenge all visitors during attacks * **IP Whitelist** - Bypass firewall for trusted IPs -* **Form Protection** - Protect login, registration, and comment forms with Turnstile +* **Form Protection** - Protect login, registration, and comment forms **Bot Score System:** * 0-39: Likely human (allowed) -* 40-70: Borderline (optional Turnstile challenge) +* 40-70: Borderline (optional challenge β€” Gatekeeper or Turnstile) * 71-100: Likely bot (blocked) **Performance:** @@ -43,7 +44,9 @@ Baskerville is a comprehensive WordPress security plugin that protects your site 2. Activate the plugin through the 'Plugins' menu 3. Go to Settings > Baskerville to configure 4. Install MaxMind GeoLite2 database for GeoIP features (one-click installer in Settings) -5. (Optional) Configure Cloudflare Turnstile keys for CAPTCHA challenges +5. (Optional) Go to Settings > Baskerville > Challenge to enable a CAPTCHA provider: + * **Baskerville Gatekeeper** β€” no API keys needed, uses captcha.openports.dev + * **Cloudflare Turnstile** β€” requires a free Cloudflare account and API keys == Frequently Asked Questions == @@ -51,13 +54,21 @@ Baskerville is a comprehensive WordPress security plugin that protects your site Go to Settings > Baskerville > GeoIP, install the MaxMind database, then configure your country whitelist or blacklist. -= How does Turnstile work? = += What is Baskerville Gatekeeper? = -Visitors with borderline bot scores (default 40-70) are shown a Cloudflare Turnstile challenge. If they pass, they're allowed through. This catches bots while minimizing friction for real users. +Baskerville Gatekeeper is our own CAPTCHA system based on a state-space puzzle. It requires no API keys or third-party account. When enabled, it contacts captcha.openports.dev to generate and verify challenges. The challenge is shown inline on the original page β€” no redirect required. + += How does Cloudflare Turnstile work? = + +Visitors with borderline bot scores (default 40-70) are shown a Cloudflare Turnstile challenge. If they pass, they're allowed through. Requires a free Cloudflare account and API keys configured in Settings > Baskerville > Challenge. + += Which challenge provider should I choose? = + +Both providers catch borderline bots effectively. Choose Baskerville Gatekeeper if you do not want to create a Cloudflare account. Choose Cloudflare Turnstile if you prefer a well-known provider or already use Cloudflare. Both are disabled by default β€” no external services are contacted until you enable a provider. = What is Under Attack Mode? = -Emergency mode that shows Turnstile challenge to ALL visitors. Use this when your site is under active attack. +Emergency mode that shows a challenge to ALL visitors. Use this when your site is under active attack. = Will this slow down my site? = @@ -65,7 +76,20 @@ With page caching enabled, overhead is near zero. Without caching, expect ~30-50 == External Services == -This plugin connects to the following third-party services: +This plugin does **not** contact any external services by default. External connections are only made when you explicitly enable a challenge provider in Settings > Baskerville > Challenge. + += Baskerville Gatekeeper = + +When Baskerville Gatekeeper is selected as the challenge provider, the plugin communicates with the Gatekeeper service to generate and verify puzzle challenges: + +* Service URL: https://captcha.openports.dev +* Endpoints used: /captcha/generate, /captcha/verify, /captcha/refresh, /token/verify +* Data sent: visitor IP address, user agent, request URI, challenge solution cookies +* Purpose: Generate unique state-space puzzle challenges and verify visitor solutions +* Privacy Policy: https://openports.dev/privacy +* Terms of Service: https://openports.dev/terms + +This service is operated by eQualitie (https://equalitie.org). It is contacted only when a visitor is determined to require a challenge (borderline bot score or Under Attack Mode). The service is never contacted during normal browsing by visitors who are not being challenged. = Cloudflare Turnstile = @@ -78,7 +102,7 @@ When Turnstile is enabled, the plugin loads JavaScript from Cloudflare's servers * Privacy Policy: https://www.cloudflare.com/privacypolicy/ * Terms of Service: https://www.cloudflare.com/website-terms/ -Turnstile is only loaded when you enable it in plugin settings and provide your Cloudflare API keys. +Turnstile is only loaded when you select it as the challenge provider in Settings > Baskerville > Challenge and provide your Cloudflare API keys. = MaxMind GeoIP Database = @@ -120,12 +144,23 @@ Statistics are automatically deleted after the retention period you configure (d = GDPR Compliance = * All data is stored locally on your server -* No visitor data is shared with third parties (except Cloudflare when Turnstile verification occurs) +* No visitor data is shared with third parties unless you enable a challenge provider: + * Baskerville Gatekeeper shares IP, user agent, and request URI with captcha.openports.dev (eQualitie) + * Cloudflare Turnstile shares IP and challenge token with Cloudflare * Data retention is configurable -* Consider adding disclosure to your site's privacy policy +* Consider adding disclosure to your site's privacy policy if you enable a challenge provider == Changelog == += 1.0.4 = +* Added Baskerville Gatekeeper as a built-in CAPTCHA option (state-space puzzle, no API keys required, powered by captcha.openports.dev). +* Added Challenge provider selector in Settings > Baskerville > Challenge (Gatekeeper, Cloudflare Turnstile, or Disabled). +* Challenge provider is disabled by default β€” no external services contacted unless explicitly enabled. +* Documented Baskerville Gatekeeper external service in readme per WordPress.org guidelines. + += 1.0.3 = +* See previous changelog entries. + = 1.0.2 = * Replaced hardcoded Ajax/REST paths with wp_doing_ajax(), REST_REQUEST and rest_get_url_prefix(). * Replaced direct require_once of class-pclzip.php with WordPress unzip_file() API.