From 38fddfab21a628792d39ad15d5594b7ea77b78e0 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 13 Apr 2026 12:53:55 -0400 Subject: [PATCH 1/4] captcha and docs --- README.md | 965 ++++++++++++++++++- includes/class-baskerville-gatekeeper.php | 1068 +++++++++++++++++++++ 2 files changed, 2032 insertions(+), 1 deletion(-) create mode 100644 includes/class-baskerville-gatekeeper.php diff --git a/README.md b/README.md index b10f47f..afb9a7a 100644 --- a/README.md +++ b/README.md @@ -313,4 +313,967 @@ 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) +
+ +
+ User Interaction and Client Side System Design - How users engage with the puzzle & High-level design. + +- [How the client side works](#how-the-client-side-works) +- [User Interaction Flow](#how-the-client-side-works) + - [Receiving a Challenge](#receiving-a-challenge) + - [Solving & Submitting](#solving-and-submitting) + - [Accesssibility Considerations](#accessability-considerations) +
+ +
+ Security - How we prevent tampering & automated solvers. + +- [Security Principles](#security-principles) + - [Preventing Automated Solvers](#preventing-automated-solvers) + - [Rate Limiting](#rate-limiting) + - [Client Side Rate Limiting](#client-side-rate-limiting) + - [Server Side Rate Limiting](#server-side-rate-limiting) + - [Click-Chain Validation](#client-side-integrity-checking) + - [Client-Side Integrity Checks](#client-side-integrity-checking) + - [Trust Boundaries: Client vs. Server](#trust-boundaries) +
+ + +
+ Developer Guide - Understanding the filesystem & Instructions for setting up, deploying, and contributing to the project. + +- [Developer Guide](#developer-guide) + - [Languages & Tools](#languages--tools) + - [Languages](#languages) + - [Tools](#tools) + - [Project Structure](#project-structure) + - [Deployment Guide](#deployment-guide) + - [Serving In Production](#serving-in-production) + - [Contributing](#contributing) + - [Setting up the development environment](#setting-up-the-development-environment) + - [Package.json Commands](#package.json-commands) + - [Typical Development Workflow](#typical-development-workflow) + - [Typical Production Workflow](#typical-production-workflow) +
+ + +--- + + +# 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. + +--- + + + +# User Interaction and Client Side System Design + +## How the Client Side Works + +- The Deflect CAPTCHA client operates as a self-contained, pre-bundled system delivered to the user's browser in a single request. This ensures a seamless experience without requiring additional external dependencies or network requests beyond the initial page load. + +## User Interaction Flow + +### Receiving a Challenge + +1) The client receives an `index.html` file containing: + - Prebundled CSS, JavaScript, dependencies, and polyfills. + - The initial game state, injected at the time of delivery. + - If no initial state is found, the puzzle phones home to request a challenge. +2) The puzzle immediately starts, prompting the user to solve it. +3) The challenge issued to the user has the following structure: + + ``` + type CAPTCHAChallenge struct { + GameBoard [][]*Tile `json:"gameBoard"` + ThumbnailBase64 string `json:"thumbnail_base64"` + MaxAllowedMoves int `json:"maxNumberOfMovesAllowed"` + TimeToSolveMS int `json:"timeToSolve_ms"` + CollectDataEnabled bool `json:"collect_data"` + ClickChain []ClickChainEntry `json:"click_chain"` + } + ``` + +### Solving & Submitting + +1) The puzzle consists of an nxn grid, where one tile is missing. +2) The user can only move tiles adjacent to the missing space by clicking on them. Clicking a tile swaps its position with the missing tile. +3) The objective is to rearrange the tiles until they recreate the original reference image. + +4) Each tile contains: + ``` + type Tile struct { + Base64Image string `json:"base64_image"` + TileGridID string `json:"tile_grid_id"` + } + ``` +- Where: + - Base64Image: Encoded PNG of the puzzle segment. + - TileGridID: A hashed identifier derived from: + - Hmac(The tile's base64 image + The user's challenge cookie + A server-side secret) + - The TileGridID ensures that each puzzle instance is unique and prevents replay attacks. + +- When the user clicks Solve, the system: + 1) Extracts the TileGridID of each tile in order. + 2) Concatenates them into a single string. + 3) Computes an HMAC hash of the string using the challenge cookie as a key. + 4) Submits this computed hash as the solution. + +### Accessibility Considerations + +- These are as of yet not addressed and remain and important TODO +- Perhaps an auditory challenge for the visually impaired? + + +--- + + + +# Security + +## Security Principles + +- The following outlines the client-side security mechanisms implemented in Deflect CAPTCHA to prevent spam, mitigate automated solvers, and ensure the integrity of submitted solutions. + +### Preventing Automated Solvers + +- State-Space Search Problem + + - Deflect CAPTCHA is designed as a state-space search problem—a well-studied class of problems in computer science where solving involves transitioning through valid states. + +- As mentioned in the introduction: + + - Bots and LLMs struggle with this type of problem due to combinatorial complexity. + - Humans also find it difficult, but they approach it differently, which allows us to analyze behavioral patterns. + - We can study interactions over time to differentiate bots from real users based on how they play rather than solely correctness. + +- Configurable Difficulty to Deter Bots: + - Configurations dynamically adjust puzzle difficulty based on detected behavior. + - If a bot is suspected, we have the capability of making the puzzle exponentially harder: + + ``` + profiles: + + default: + nPartitions: 9 #3x3 + nShuffles: [1, 2] #only one shuffle as per Antons suggestion + maxNumberOfMovesAllowed: 10 + removeTileIndex: 4 #set to -1 if you want to instead enable randomly choosing from the board + timeToSolve_ms: 895_000 #14.91 mins (because cookie itself is valid for 15 mins by default) + + easy: + nPartitions: 9 # 3x3 grid + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 160 + timeToSolve_ms: 1_200_000 # 20 minutes + + medium: + nPartitions: 16 # 4x4 grid + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 200 + timeToSolve_ms: 900_000 # 15 minutes + + painful: + nPartitions: 49 # 7x7 grid + nShuffles: [30, 50] + maxNumberOfMovesAllowed: 300 + timeToSolve_ms: 420_000 + + nightmare_fuel: + nPartitions: 100 # 10x10 grid + nShuffles: [1000, 2000] + maxNumberOfMovesAllowed: 6000 + timeToSolve_ms: 360_000 + ``` + +## How This Prevents Bots: + +- The most important reason has to do with **several components working together**: + + 1) Click-Chain Validation & Uniqueness + + - A click-chain blockchain cryptographically proves that the user performed a valid sequence of moves leading to the final board state. + - Each puzzle board is unique per user, preventing replay attacks. + - Rate limiting ensures only a few submissions within the allotted time, making brute-force infeasible—getting rate-limited 3-4 times can drain the available time, forcing a restart. + + 2) Dynamic Puzzle Adjustments + + - The system dynamically modifies puzzles by: + - Changing the missing tile position. + - Altering the image, time limit, and max moves. + - Adjusting the grid size (number of partitions) and shuffle complexity. + + These parameters scale difficulty based on behavior, making automated solving exponentially harder. + + 3) Built-in Anti-Cheat Mechanisms + + - Noise is deliberately added to the thumbnail image to prevent trivial reconstruction. + - Even if an attacker partitions the thumbnail, the Base64-encoded pieces won’t match due to injected noise, preventing automated board reconstruction. + - Each tile is also encoded with its own noise using different entropy from that which was applied to the thumbnail ensuring there is no correlation between the two + - Each puzzle tile base64 encoded PNG is guarenteed to be unique per challenge per user + - Each thumbnail is guarenteed to be unique per challenge per user + - Each thumbnail is guarenteed to be different from the puzzle grid tile base64 images even when partitioned + - This ensures even replay attacks are not possible as even if you record the base64 images you put into order in one puzzle, the next time you receive it, + the base64 PNG will not be the same as they have different noise applied to them. Since the TileID's are derived from the base64 PNGs which as mentioned are unique + we can guarentee that the solution that is derived from placing them in order is guarenteed to be unique as well. + - Finally, for any given challenge, if there are tiles on the board that are the same, for example if there are blank tiles which have the same b64 data, then after adding noise + they will continue to be the same ensuring they remain interchangeable! Between different challenges, these blank tiles, as with all other tiles are guarenteed to be different from one another! + - Integrity checking and click-chain solution guarentee that no replay or forgery attacks can occur. + - ClickChain also ensures that the solution itself is correct in that each step the user took while solving does indeed lead to the final result being what they submitted + + For more on the ClickChain or how integrity, noise or uniqueness are guarenteed, see [Server-Side Documentation](../internal/puzzle-util/README.md). + + 4) Machine Learning (behaviour analysis) - NOT YET DONE + + - Once the data collection is complete, we wiil be able to collect data about gameplay and use it to make predictions about whether a human or bot was playing the game + - A combination of the correct solution and human-like behaviour while playing will be used to produce the final decision + + This **layered approach** ensures that bots cannot brute-force, replay, or reconstruct the puzzle *while keeping it solvable for real users*. + +### Rate Limiting + +#### Client-Side Rate Limiting + +- Client-side protections prevent spamming by users pressing submit repeatedly (especially useful under heavy load). This is enforced via delays and UI locking mechanisms to slow down consecutive attempts + +#### Server-Side Rate Limiting + +- Server-side rate limiting is designed to: + + 1) Throttle requests to prevent brute-force guessing. + 2) Enforce a max of 4 solution submissions per unit time. + 3) Ensure users run out of time before brute-forcing a solution. + +### Click-Chain Validation + +- Each puzzle challenge is unique per user and validated via a cryptographic click-chain (similar to how a block chain works) + + - A unique puzzle board is generated for each user, where each tile has a hashed ID derived from the tile’s image and the user’s challenge cookie. + - A Genesis Block (initial entry) is created, linked to the user’s challenge cookie and an internal secret. + - Each valid move is appended to the click-chain, referencing the previous move's hash, ensuring an immutable sequence. + - Solution validation: The final board state is verified against the expected target solution. + +- Security Benefits: + + - Ensures puzzle integrity – every move is logged and cryptographically linked. + - Prevents tampering – since the chain is HMAC-signed, users cannot forge solutions. + - Stops replay attacks – the secret key ensures that click-chains are tied to individual challenges. + + +### Client-Side Integrity Checks + +- To prevent tampering or bypassing, the server side will perform integrity checks: + + 1) Ensuring the click-chain hash is valid. + 2) Confirming that move sequences are logically possible. + 3) Detecting unnatural solving patterns indicative of automation. + +For more on how this works, see [Server-Side Documentation](../internal/puzzle-util/README.md). + + +### Trust Boundaries: Client vs. Server + +- The client only knows its cookie and board state. +- The server holds the entropy for challenge verification (a secret only we know concatenated with the users challenge cookie) + - Even with the entire click-chain, users cannot forge a solution because the secret key remains unknown to them. + +--- + + +# Developer Guide + +## Languages & Tools + +### Languages + +- The Client-Side Puzzle UI is written in TypeScript (v4.0+ recommended). +- No frameworks (e.g., React, Vue) are used—event listeners are attached directly to ensure maximum compatibility with legacy browsers as these frameworks depend a lot on ES6+ features which break older environments even when using transpilation. + +### Tools + +#### Bundler: Rollup + +- Rollup is used to bundle, optimize, and minify the client-side JavaScript, CSS, and dependencies into a single deliverable. + +##### What is Being included by Rollup + +- The following assets are bundled and optimized: + + 1) JavaScript - All client-side logic and dependencies (when using production environment variables the JS will be obfuscated) + 2) CSS - Embedded directly into `index.html`. + 3) Polyfills - Ensures compatibility with older browsers. (The required polyfills are imported in the entrypoint-deflect-captcha.ts file such that rollup knows what is needed when bundling) + 4) utility functions required for compatibility with legacy environments. + +###### How Rollup Works in This Project + +- Entry Point: entrypoint-deflect-captcha.ts + - Specified in the `input` field of the rollup + +- Bundling Process: + - The script is compiled and minified. + - Polyfills are included for older browsers. + - The final bundle is injected into index.html. + +- Rollup Configuration Breakdown: + + - JavaScript and TypeScript: + + - The entrypoint-deflect-captcha.ts script serves as the entry point. + - Babel is used to transpile the code, ensuring compatibility with older browsers. + - TypeScript is processed using @rollup/plugin-typescript. + + - CSS Handling: + + - By default, CSS is embedded directly into index.html. + - If you want to bundle CSS inside bundle.js, uncomment the PostCSS plugin in rollup.config.js. + + - Legacy Browser Support: + + - Babel targets Internet Explorer 11+. + - Ensures compatibility by using core-js for polyfills. + + - Security & Performance Enhancements: + + - The bundle is obfuscated in production (rollup-plugin-obfuscator). + - Minification is done using terser. + +##### Compatibility with Legacy Browsers + +- One of the **most important requirements** for this project is ensuring that it runs on **legacy browsers**. +- Some older browsers do not support modern cryptographic APIs and other tooling we take for granted today, so we must fallback to pre-bundled dependencies when necessary. + +- These tools are all included in the `src/client/scripts/utils` directory and are imported by the event listener attachment functions that need them + - Since these are being imported into the entrypoint (via these event listener attachment functions), rollup knows to include them in the `bundle.js` + + Example: Modern browsers support crypto.subtle, but legacy browsers do not. For this reason we have a util function: + + ``` + import {HmacSHA256, enc} from 'crypto-js' + + export async function generateHmacWithFallback(key: string, message: string): Promise { + if (window.crypto && window.crypto.subtle) { + const encKey = new TextEncoder().encode(key) + const encMessage = new TextEncoder().encode(message) + const cryptoKey = await crypto.subtle.importKey('raw', encKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + const signature = await crypto.subtle.sign('HMAC', cryptoKey, encMessage) + return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, '0')).join('') + } else { + return HmacSHA256(message, key).toString(enc.Hex) + } + } + ``` + + For browsers that support crypto.subtle, they will use the standard API provided by the browser. However, for those that do not, we have bundled `crypto-js` + such that their browsers can still invoke the `generateHmacWithFallback()` function + + +###### Polyfills + +- For legacy browsers that do not support native ES6+ features, rollup bundles all the polyfills needed + +- At the top of the entrypoint-deflect-captcha.ts file, the necessary polyfills are explicitly imported: + ``` + import 'core-js/stable' + import 'regenerator-runtime/runtime' + ``` + +- Each polyfill serves a different purpose, for example: + + - core-js/stable: Provides shims for missing JavaScript features. + - regenerator-runtime/runtime: Ensures async/await support for older browsers. + + +## Project Structure + +``` + . + ├── README.md <- You are here + ├── captchaRollup.config.mjs <- Rollup config for bundling + ├── dist <- Production-ready build output + │   ├── client + │   │   └── scripts + │   │   └── bundle.js <- Bundled JS for the client + │   └── index.html <- Fully self-contained, bundled page. **This is the ONLY thing that need be served by the server.** + ├── injectBundleJSToIndexHTML.js <- Post-bundling script injector + ├── package-lock.json + ├── package.json + ├── src <- Main source directory + │   ├── client + │   │   ├── scripts <- Client-side logic + │   │   │   ├── attach-footer-and-header-info.ts + │   │   │   ├── check-initial-state.ts + │   │   │   ├── client-captcha-solver.ts + │   │   │   ├── entrypoint-deflect-captcha.ts <- Main entrypoint, initializes everything + │   │   │   ├── inspect-target-image-modal.ts + │   │   │   ├── puzzle-instructions-info-button.ts + │   │   │   ├── request-different-puzzle.ts + │   │   │   └── utils <- Helper functions (containing functions with prebundled dependencies as fallbacks for legacy browsers) + │   │   │   ├── cookie-utils.ts + │   │   │   └── hmac-utils.ts + │   │   └── styles <- CSS for the UI (Note: Currently ALL css is already injected directly into the tags of the `index.html` - these are here for convenience) + │   │   ├── main.css + │   │   ├── puzzle-container.css + │   │   ├── puzzle-grid.css + │   │   ├── puzzle-instructions.css + │   │   ├── puzzle-messages-to-user.css + │   │   ├── puzzle-refresh.css + │   │   ├── puzzle-submission.css + │   │   └── puzzle-thumbnail.css + │   ├── deflect_logo.svg <- Deflect Logo (injected into `index.html` during bundling) + │   └── index.html <- The HTML template **before** bundling (**not to be served to user**) + ├── tsconfig.json <- TypeScript configuration + └── types <- Shared type definitions + └── shared.d.ts +``` + + +### types (`puzzle_ui/types`) + +- Contains TypeScript type definitions shared across the UI. + +### src (`puzzle_ui/src`) + +#### src/index.html + +- `src/index.html` is the template HTML file **before** bundling. +- It gets modified during the build process to embed scripts, styles, and the Deflect logo. + +#### src/deflect_logo.svg + +- The Deflect CAPTCHA logo injected into the final `index.html`. + +#### src/client + +- Houses all scripts and styles required for the CAPTCHA UI. + +##### src/client/scripts + +- The core client side logic that runs in the browser + +- **Entrypoint:** `entrypoint-deflect-captcha.ts` + - Initializes the CAPTCHA system. + - Checks if an initial state was injected or needs to be fetched. + - Handles error reporting and retry logic. + - Implements a fallback mechanism for worst-case scenarios. + +###### src/client/scripts/utils + +- These are utilities that are prebundled with the dependencies required for legacy browsers to function. + - For example, not all browsers admit crypto.subtle API. Therefore, we provide `hmac-utils.ts` such that all browsers can either use their `crypto.subtle` API should they have it, or fallback to the pre bundled depdency (`crypto-js`) + +- `cookie-utils.ts`: Handles cookies for authentication and state management. +- `hmac-utils.ts`: Cryptographic helper functions. + +##### src/client/styles + +- Defines the visual styling for different puzzle components. + +- By default, styles are embedded directly into `index.html`. + +- If you prefer to bundle CSS with JS, you must: + 1) Enable the postcss Rollup hook. + 2) Uncomment the import statements in `entrypoint-deflect-captcha.ts`. + 3) Remove the tags from `src/index.html`. + +### dist (`puzzle_ui/dist`) + +- Contains the production-ready assets. + +- Key files: + - `dist/index.html`: The final, self-contained page (fully bundled). + - `dist/client/scripts/bundle.js`: The compiled JavaScript bundle. + +- **Note:** + - The `index.html` already includes all required `scripts/styles`. + - *Only* `index.html` and the user's cookie are needed for deployment. + +### root (`puzzle_ui/`) + +- houses `injectBundleJSToIndexHTML.js` + +- This is a custom Rollup hook that modifies index.html after bundling. + +- Automatically injects bundle.js into index.html, ensuring that: + + 1) All assets are inline (to be served in a single request). + 2) The Deflect CAPTCHA system remains self-contained. + +## Deployment Guide + +### Serving in Production + +- The bundling process ensures that all required assets are packaged into a **single deliverable** for easy deployment. This includes: + +1) JavaScript - Bundled with Rollup, optimized, and obfuscated (for production only) & is injected directly into `index.html` via the `injectBundleJSToIndexHTML.js` script. +2) Deflect Logo SVG - Also injected directly into index.html via the `injectBundleJSToIndexHTML.js` script. +3) CSS - Embedded directly inside `index.html`. + **Note:** The css may also be included with the `bundle.js` if you: + 1) remmove it from `index.html` + 2) uncomment the entrypoint `*.css` imports + 3) uncomment the postcss() rollup code +4) Polyfills - Included to support legacy browsers (which is an important requirement). + +#### What needs to be served? + +- This means that the only *file* you need to serve is: `dist/index.html` +- You must also attach a *challenge cookie* along with the `dist/index.html` response payload using the cookie name: `deflect_challenge4` +- That's it. Nothing else is required. + +#### You do **not** need to serve: + +- Logos, JS, CSS, or external assets—they are already embedded in the `index.html`. +- Separate API endpoints for fetching assets—everything needed is in the single file. + +#### What the Server Needs to Do + +- To properly issue a unique challenge per user, the **server** can follow one of two procedures: + +- **1) Recommended Approach (Production Best Practice):** inject the initial state into index.html + - The server reads index.html before serving it. + - The server injects a dynamically generated initial state (per user). + - The user receives index.html with the puzzle state already embedded. + - This is how Deflect works in production and ensures a seamless, efficient challenge issuance. + + +- **2) Alternative Approach:** do not inject the initial state into index.html, but have an endpoint prepared to handle a request for the puzzle state + - If the initial state is not injected, the puzzle will immediately phone home requesting it from the server. + - In this case, the server must provide an endpoint to handle these state requests dynamically. + - This approach may be useful for development but is not recommended for production. + + +- **Note:** You *will* need to have the endpoint to serve a puzzle state on request regardless of what option you choose as the puzzle includes a rate limited "refresh" button that requets a new puzzle state and updates the current state. This was included to provide the user the option of trying a different one if they deem the current board too difficult. However, it is still recommended to inject the initial state into `index.html` as this is a requirement for how Deflect works. + +- For details on how the server should inject the initial state, refer to the [Server-Side Documentation](../internal/puzzle-util/README.md). + +## Contributing + +### Setting Up the Development Environment + +- Step 1) Clone this repository + +- Step 2) Install dependencies + + ### The UI is built using Node.js and Rollup. Ensure you have Node.js installed (v18 or later). Then, install the required dependencies: + ``` + cd puzzle_ui + npm install + ``` + +- Step 3) Create a **.env.production** and **.env.development** files + + #### .env.production: + ``` + MINIFY_CSS=true + SOURCE_MAP=false + OBFUSCATE=true + ``` + + #### .env.development: + ``` + MINIFY_CSS=false + SOURCE_MAP=true + OBFUSCATE=false + ``` + +- Step 4) run the following commands: + ``` + npm run clean + npm run build + ``` + +#### Package.json Commands + +- ```npm run dev``` + - deletes the dist/ directory and rebuilds from scratch, bundling all dependencies, and watching for changes to client side code before rebundling (uses dev env variables) + +- ```npm run build``` + - runs Rollup to bundle client-side code (which injects the bundle into the` index.html`) + +- ```npm run clean``` + - clears the dist/ directory if you want a fresh build + +- ```npm run watch``` + - watches for changes in client code & automatically rebundles + +- ```npm run prod``` + - deletes the dist/ directory and rebuilds from scratch using production environment variables + + + +#### Typical Development Workflow + +- Either run: + ``` + 1) npm run clean + 2) npm run build + ``` +- Or: + ``` + 1) npm run dev + ``` + +- The only difference is that the npm run dev will continue monitoring for changes such that when you make a change, it will automatically clean and build such that your server serves the most recent one + +- **In both cases**, you must serve from **`dist/index.html`** + +- This will not only include the html, but also the css as well as the js and all dependencies and polyfills +- The only thing that remains to do when serving it is to inject the initial state at runtime. Since each puzzle is unique to the user, the initial state cannot be precomputed and must be dynamically generated. The server handles this by issuing a state-specific challenge upon request. This is injected directly into the `index.html` as per Deflect requirements. For more details, check the [Server-Side Documentation](../internal/puzzle-util/README.md). + - **Note:** If the initial state is **not injected** at runtime, the puzzle **will automatically request it** from the server. In this case, you **must have an endpoint** to handle this request and provide the state dynamically. + +#### Typical Production Workflow + - run: + ``` + npm run prod + ``` +- You can now serve the CAPTCHA directly from `dist/index.html` + - This will contain the HTML, CSS, JS, Polyfills & all dependencies (such as for calculating HMAC) + - It is also obfuscated via rollup + - The only thing that remains to do when serving it is to inject the initial state at runtime. Since each puzzle is unique to the user, the initial state cannot be precomputed and must be dynamically generated. The server handles this by issuing a state-specific challenge upon request. This is injected directly into the `index.html` as per Deflect requirements. For more details, check the [Server-Side Documentation](../internal/puzzle-util/README.md). + - **Note:** If the initial state is **not injected** at runtime, the puzzle **will automatically request it** from the server. In this case, you **must have an endpoint** to handle this request and provide the state dynamically. + +--- \ No newline at end of file diff --git a/includes/class-baskerville-gatekeeper.php b/includes/class-baskerville-gatekeeper.php new file mode 100644 index 0000000..26742e8 --- /dev/null +++ b/includes/class-baskerville-gatekeeper.php @@ -0,0 +1,1068 @@ + $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'); + + $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(CAPTCH_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()); + + 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() { + $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'); + + wpsec_log('[gatekeeper] issuing challenge for uri=' . ($_SERVER['REQUEST_URI'] ?? '')); + + $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()); + + status_header(503); + header('Content-Type: text/html; charset=utf-8'); + echo '

Challenge unavailable

'; + exit; + } + + $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); + + status_header(200); + header('Content-Type: ' . $content_type); + header('Cache-Control: no-store, no-cache'); + 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'] ?? '')); + + /* + 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')) { + wpsec_log('[gatekeeper] privileged logged-in user, allowing'); + return; + } + + /* + 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 + */ + + //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); + + if ($status_code === 204) { + wpsec_log('[gatekeeper] valid pass cookie found, allowing'); + //make sure we always relay their valid cookie back to them so that they can continue + //to browser undisturbed since their solution is legitamate + 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!'); + } + } + } + + //if the user didnt have a challenge pass BUT also is not meant to be challenged + //then allow them through normally + if (!wpsec_should_challenge()) { + wpsec_log('[gatekeeper] should not challenge, allowing normal render'); + return; + } + + /* + at this point we know that we are dealing with a user who needs to be challenged + but it could also be that we are in the middle of a challenge + */ + + //check if its a refresh + $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(); + } + + //check if it has solution + 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); + + wpsec_log('[gatekeeper] verification result status=' . $status_code . ' message=' . $message); + + if ($status_code === 403 && $message === 'invalid solution') { + //relay back the 403 + message such that the puzzle displays the correct + //message to the user + wpsec_log('[gatekeeper] relaying invalid solution back to client'); + wpsec_send_plain_response(403, 'invalid solution'); + + } 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) { + //re-issue the challenge but NOT here, instead respond BACK to the user + //with 404 such that client side triggers refresh and gets challenged again + //however, we need to first clear all of their existing cookies + wpsec_log('[gatekeeper] bad verification payload, clearing cookies and telling client to refresh'); + wpsec_clear_challenge_cookies(); + wpsec_send_plain_response(404, 'refresh challenge'); + + } else if ($status_code === 200) { + //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, clearing leftover challenge cookies and allowing'); + + // so for example, we could implement a function that would mark them as having already + // passed and remove them from the list of people to be challenged and not be interferred with + // later unless the ML pipeline pushes up another challenge or something. + // so as a placeholder here to remove from local challenge list / cache / rule store i left + //this just for it to be clear + // wpsec_mark_visitor_as_passed(); + + // upstream pass cookie has already been forwarded in wpsec_verify_captcha_solution_via_upstream_cookies() + // now clear any leftover challenge/solution cookies from this host-side state + wpsec_clear_challenge_cookies(); + + wpsec_log('[gatekeeper] verification succeeded, allowing normal render'); + return; + + } else { + //something went wrong, this is unexpected, log it in some persistent error + //log and fallback on re-issueing the challenge + wpsec_log('[gatekeeper] unexpected verification result, clearing cookies and reissuing challenge'); + wpsec_clear_challenge_cookies(); + wpsec_issue_challenge(); + } + } + + + 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()); + } +} + + +//------------------------------------------------------------- +//------------------------------------------------------------- +//calls main function to enforce captcha enforcement policy +//------------------------------------------------------------- +//------------------------------------------------------------- +add_action('template_redirect', 'wpsec_enforce_captcha_policy'); \ No newline at end of file From bb13cb3026dd6828daa92fbcef0e51b7c05882d1 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 13 Apr 2026 12:59:45 -0400 Subject: [PATCH 2/4] modified readme and added main includes --- README.md | 613 ------------------------------------ baskerville-ai-security.php | 1 + 2 files changed, 1 insertion(+), 613 deletions(-) diff --git a/README.md b/README.md index afb9a7a..79326a1 100644 --- a/README.md +++ b/README.md @@ -619,48 +619,6 @@ So the plugin integration as a whole can be understood as: - [What We Have Achieved & What Comes Next](#what-we-have-achieved--what-comes-next) -
- User Interaction and Client Side System Design - How users engage with the puzzle & High-level design. - -- [How the client side works](#how-the-client-side-works) -- [User Interaction Flow](#how-the-client-side-works) - - [Receiving a Challenge](#receiving-a-challenge) - - [Solving & Submitting](#solving-and-submitting) - - [Accesssibility Considerations](#accessability-considerations) -
- -
- Security - How we prevent tampering & automated solvers. - -- [Security Principles](#security-principles) - - [Preventing Automated Solvers](#preventing-automated-solvers) - - [Rate Limiting](#rate-limiting) - - [Client Side Rate Limiting](#client-side-rate-limiting) - - [Server Side Rate Limiting](#server-side-rate-limiting) - - [Click-Chain Validation](#client-side-integrity-checking) - - [Client-Side Integrity Checks](#client-side-integrity-checking) - - [Trust Boundaries: Client vs. Server](#trust-boundaries) -
- - -
- Developer Guide - Understanding the filesystem & Instructions for setting up, deploying, and contributing to the project. - -- [Developer Guide](#developer-guide) - - [Languages & Tools](#languages--tools) - - [Languages](#languages) - - [Tools](#tools) - - [Project Structure](#project-structure) - - [Deployment Guide](#deployment-guide) - - [Serving In Production](#serving-in-production) - - [Contributing](#contributing) - - [Setting up the development environment](#setting-up-the-development-environment) - - [Package.json Commands](#package.json-commands) - - [Typical Development Workflow](#typical-development-workflow) - - [Typical Production Workflow](#typical-production-workflow) -
- - --- @@ -706,574 +664,3 @@ So the plugin integration as a whole can be understood as: #### Cool fact about the puzzle: - Finding a solution for n-puzzle is easy. However, finding a shortest solution is NP-hard. - ---- - - - -# User Interaction and Client Side System Design - -## How the Client Side Works - -- The Deflect CAPTCHA client operates as a self-contained, pre-bundled system delivered to the user's browser in a single request. This ensures a seamless experience without requiring additional external dependencies or network requests beyond the initial page load. - -## User Interaction Flow - -### Receiving a Challenge - -1) The client receives an `index.html` file containing: - - Prebundled CSS, JavaScript, dependencies, and polyfills. - - The initial game state, injected at the time of delivery. - - If no initial state is found, the puzzle phones home to request a challenge. -2) The puzzle immediately starts, prompting the user to solve it. -3) The challenge issued to the user has the following structure: - - ``` - type CAPTCHAChallenge struct { - GameBoard [][]*Tile `json:"gameBoard"` - ThumbnailBase64 string `json:"thumbnail_base64"` - MaxAllowedMoves int `json:"maxNumberOfMovesAllowed"` - TimeToSolveMS int `json:"timeToSolve_ms"` - CollectDataEnabled bool `json:"collect_data"` - ClickChain []ClickChainEntry `json:"click_chain"` - } - ``` - -### Solving & Submitting - -1) The puzzle consists of an nxn grid, where one tile is missing. -2) The user can only move tiles adjacent to the missing space by clicking on them. Clicking a tile swaps its position with the missing tile. -3) The objective is to rearrange the tiles until they recreate the original reference image. - -4) Each tile contains: - ``` - type Tile struct { - Base64Image string `json:"base64_image"` - TileGridID string `json:"tile_grid_id"` - } - ``` -- Where: - - Base64Image: Encoded PNG of the puzzle segment. - - TileGridID: A hashed identifier derived from: - - Hmac(The tile's base64 image + The user's challenge cookie + A server-side secret) - - The TileGridID ensures that each puzzle instance is unique and prevents replay attacks. - -- When the user clicks Solve, the system: - 1) Extracts the TileGridID of each tile in order. - 2) Concatenates them into a single string. - 3) Computes an HMAC hash of the string using the challenge cookie as a key. - 4) Submits this computed hash as the solution. - -### Accessibility Considerations - -- These are as of yet not addressed and remain and important TODO -- Perhaps an auditory challenge for the visually impaired? - - ---- - - - -# Security - -## Security Principles - -- The following outlines the client-side security mechanisms implemented in Deflect CAPTCHA to prevent spam, mitigate automated solvers, and ensure the integrity of submitted solutions. - -### Preventing Automated Solvers - -- State-Space Search Problem - - - Deflect CAPTCHA is designed as a state-space search problem—a well-studied class of problems in computer science where solving involves transitioning through valid states. - -- As mentioned in the introduction: - - - Bots and LLMs struggle with this type of problem due to combinatorial complexity. - - Humans also find it difficult, but they approach it differently, which allows us to analyze behavioral patterns. - - We can study interactions over time to differentiate bots from real users based on how they play rather than solely correctness. - -- Configurable Difficulty to Deter Bots: - - Configurations dynamically adjust puzzle difficulty based on detected behavior. - - If a bot is suspected, we have the capability of making the puzzle exponentially harder: - - ``` - profiles: - - default: - nPartitions: 9 #3x3 - nShuffles: [1, 2] #only one shuffle as per Antons suggestion - maxNumberOfMovesAllowed: 10 - removeTileIndex: 4 #set to -1 if you want to instead enable randomly choosing from the board - timeToSolve_ms: 895_000 #14.91 mins (because cookie itself is valid for 15 mins by default) - - easy: - nPartitions: 9 # 3x3 grid - nShuffles: [5, 8] - maxNumberOfMovesAllowed: 160 - timeToSolve_ms: 1_200_000 # 20 minutes - - medium: - nPartitions: 16 # 4x4 grid - nShuffles: [5, 8] - maxNumberOfMovesAllowed: 200 - timeToSolve_ms: 900_000 # 15 minutes - - painful: - nPartitions: 49 # 7x7 grid - nShuffles: [30, 50] - maxNumberOfMovesAllowed: 300 - timeToSolve_ms: 420_000 - - nightmare_fuel: - nPartitions: 100 # 10x10 grid - nShuffles: [1000, 2000] - maxNumberOfMovesAllowed: 6000 - timeToSolve_ms: 360_000 - ``` - -## How This Prevents Bots: - -- The most important reason has to do with **several components working together**: - - 1) Click-Chain Validation & Uniqueness - - - A click-chain blockchain cryptographically proves that the user performed a valid sequence of moves leading to the final board state. - - Each puzzle board is unique per user, preventing replay attacks. - - Rate limiting ensures only a few submissions within the allotted time, making brute-force infeasible—getting rate-limited 3-4 times can drain the available time, forcing a restart. - - 2) Dynamic Puzzle Adjustments - - - The system dynamically modifies puzzles by: - - Changing the missing tile position. - - Altering the image, time limit, and max moves. - - Adjusting the grid size (number of partitions) and shuffle complexity. - - These parameters scale difficulty based on behavior, making automated solving exponentially harder. - - 3) Built-in Anti-Cheat Mechanisms - - - Noise is deliberately added to the thumbnail image to prevent trivial reconstruction. - - Even if an attacker partitions the thumbnail, the Base64-encoded pieces won’t match due to injected noise, preventing automated board reconstruction. - - Each tile is also encoded with its own noise using different entropy from that which was applied to the thumbnail ensuring there is no correlation between the two - - Each puzzle tile base64 encoded PNG is guarenteed to be unique per challenge per user - - Each thumbnail is guarenteed to be unique per challenge per user - - Each thumbnail is guarenteed to be different from the puzzle grid tile base64 images even when partitioned - - This ensures even replay attacks are not possible as even if you record the base64 images you put into order in one puzzle, the next time you receive it, - the base64 PNG will not be the same as they have different noise applied to them. Since the TileID's are derived from the base64 PNGs which as mentioned are unique - we can guarentee that the solution that is derived from placing them in order is guarenteed to be unique as well. - - Finally, for any given challenge, if there are tiles on the board that are the same, for example if there are blank tiles which have the same b64 data, then after adding noise - they will continue to be the same ensuring they remain interchangeable! Between different challenges, these blank tiles, as with all other tiles are guarenteed to be different from one another! - - Integrity checking and click-chain solution guarentee that no replay or forgery attacks can occur. - - ClickChain also ensures that the solution itself is correct in that each step the user took while solving does indeed lead to the final result being what they submitted - - For more on the ClickChain or how integrity, noise or uniqueness are guarenteed, see [Server-Side Documentation](../internal/puzzle-util/README.md). - - 4) Machine Learning (behaviour analysis) - NOT YET DONE - - - Once the data collection is complete, we wiil be able to collect data about gameplay and use it to make predictions about whether a human or bot was playing the game - - A combination of the correct solution and human-like behaviour while playing will be used to produce the final decision - - This **layered approach** ensures that bots cannot brute-force, replay, or reconstruct the puzzle *while keeping it solvable for real users*. - -### Rate Limiting - -#### Client-Side Rate Limiting - -- Client-side protections prevent spamming by users pressing submit repeatedly (especially useful under heavy load). This is enforced via delays and UI locking mechanisms to slow down consecutive attempts - -#### Server-Side Rate Limiting - -- Server-side rate limiting is designed to: - - 1) Throttle requests to prevent brute-force guessing. - 2) Enforce a max of 4 solution submissions per unit time. - 3) Ensure users run out of time before brute-forcing a solution. - -### Click-Chain Validation - -- Each puzzle challenge is unique per user and validated via a cryptographic click-chain (similar to how a block chain works) - - - A unique puzzle board is generated for each user, where each tile has a hashed ID derived from the tile’s image and the user’s challenge cookie. - - A Genesis Block (initial entry) is created, linked to the user’s challenge cookie and an internal secret. - - Each valid move is appended to the click-chain, referencing the previous move's hash, ensuring an immutable sequence. - - Solution validation: The final board state is verified against the expected target solution. - -- Security Benefits: - - - Ensures puzzle integrity – every move is logged and cryptographically linked. - - Prevents tampering – since the chain is HMAC-signed, users cannot forge solutions. - - Stops replay attacks – the secret key ensures that click-chains are tied to individual challenges. - - -### Client-Side Integrity Checks - -- To prevent tampering or bypassing, the server side will perform integrity checks: - - 1) Ensuring the click-chain hash is valid. - 2) Confirming that move sequences are logically possible. - 3) Detecting unnatural solving patterns indicative of automation. - -For more on how this works, see [Server-Side Documentation](../internal/puzzle-util/README.md). - - -### Trust Boundaries: Client vs. Server - -- The client only knows its cookie and board state. -- The server holds the entropy for challenge verification (a secret only we know concatenated with the users challenge cookie) - - Even with the entire click-chain, users cannot forge a solution because the secret key remains unknown to them. - ---- - - -# Developer Guide - -## Languages & Tools - -### Languages - -- The Client-Side Puzzle UI is written in TypeScript (v4.0+ recommended). -- No frameworks (e.g., React, Vue) are used—event listeners are attached directly to ensure maximum compatibility with legacy browsers as these frameworks depend a lot on ES6+ features which break older environments even when using transpilation. - -### Tools - -#### Bundler: Rollup - -- Rollup is used to bundle, optimize, and minify the client-side JavaScript, CSS, and dependencies into a single deliverable. - -##### What is Being included by Rollup - -- The following assets are bundled and optimized: - - 1) JavaScript - All client-side logic and dependencies (when using production environment variables the JS will be obfuscated) - 2) CSS - Embedded directly into `index.html`. - 3) Polyfills - Ensures compatibility with older browsers. (The required polyfills are imported in the entrypoint-deflect-captcha.ts file such that rollup knows what is needed when bundling) - 4) utility functions required for compatibility with legacy environments. - -###### How Rollup Works in This Project - -- Entry Point: entrypoint-deflect-captcha.ts - - Specified in the `input` field of the rollup - -- Bundling Process: - - The script is compiled and minified. - - Polyfills are included for older browsers. - - The final bundle is injected into index.html. - -- Rollup Configuration Breakdown: - - - JavaScript and TypeScript: - - - The entrypoint-deflect-captcha.ts script serves as the entry point. - - Babel is used to transpile the code, ensuring compatibility with older browsers. - - TypeScript is processed using @rollup/plugin-typescript. - - - CSS Handling: - - - By default, CSS is embedded directly into index.html. - - If you want to bundle CSS inside bundle.js, uncomment the PostCSS plugin in rollup.config.js. - - - Legacy Browser Support: - - - Babel targets Internet Explorer 11+. - - Ensures compatibility by using core-js for polyfills. - - - Security & Performance Enhancements: - - - The bundle is obfuscated in production (rollup-plugin-obfuscator). - - Minification is done using terser. - -##### Compatibility with Legacy Browsers - -- One of the **most important requirements** for this project is ensuring that it runs on **legacy browsers**. -- Some older browsers do not support modern cryptographic APIs and other tooling we take for granted today, so we must fallback to pre-bundled dependencies when necessary. - -- These tools are all included in the `src/client/scripts/utils` directory and are imported by the event listener attachment functions that need them - - Since these are being imported into the entrypoint (via these event listener attachment functions), rollup knows to include them in the `bundle.js` - - Example: Modern browsers support crypto.subtle, but legacy browsers do not. For this reason we have a util function: - - ``` - import {HmacSHA256, enc} from 'crypto-js' - - export async function generateHmacWithFallback(key: string, message: string): Promise { - if (window.crypto && window.crypto.subtle) { - const encKey = new TextEncoder().encode(key) - const encMessage = new TextEncoder().encode(message) - const cryptoKey = await crypto.subtle.importKey('raw', encKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) - const signature = await crypto.subtle.sign('HMAC', cryptoKey, encMessage) - return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, '0')).join('') - } else { - return HmacSHA256(message, key).toString(enc.Hex) - } - } - ``` - - For browsers that support crypto.subtle, they will use the standard API provided by the browser. However, for those that do not, we have bundled `crypto-js` - such that their browsers can still invoke the `generateHmacWithFallback()` function - - -###### Polyfills - -- For legacy browsers that do not support native ES6+ features, rollup bundles all the polyfills needed - -- At the top of the entrypoint-deflect-captcha.ts file, the necessary polyfills are explicitly imported: - ``` - import 'core-js/stable' - import 'regenerator-runtime/runtime' - ``` - -- Each polyfill serves a different purpose, for example: - - - core-js/stable: Provides shims for missing JavaScript features. - - regenerator-runtime/runtime: Ensures async/await support for older browsers. - - -## Project Structure - -``` - . - ├── README.md <- You are here - ├── captchaRollup.config.mjs <- Rollup config for bundling - ├── dist <- Production-ready build output - │   ├── client - │   │   └── scripts - │   │   └── bundle.js <- Bundled JS for the client - │   └── index.html <- Fully self-contained, bundled page. **This is the ONLY thing that need be served by the server.** - ├── injectBundleJSToIndexHTML.js <- Post-bundling script injector - ├── package-lock.json - ├── package.json - ├── src <- Main source directory - │   ├── client - │   │   ├── scripts <- Client-side logic - │   │   │   ├── attach-footer-and-header-info.ts - │   │   │   ├── check-initial-state.ts - │   │   │   ├── client-captcha-solver.ts - │   │   │   ├── entrypoint-deflect-captcha.ts <- Main entrypoint, initializes everything - │   │   │   ├── inspect-target-image-modal.ts - │   │   │   ├── puzzle-instructions-info-button.ts - │   │   │   ├── request-different-puzzle.ts - │   │   │   └── utils <- Helper functions (containing functions with prebundled dependencies as fallbacks for legacy browsers) - │   │   │   ├── cookie-utils.ts - │   │   │   └── hmac-utils.ts - │   │   └── styles <- CSS for the UI (Note: Currently ALL css is already injected directly into the tags of the `index.html` - these are here for convenience) - │   │   ├── main.css - │   │   ├── puzzle-container.css - │   │   ├── puzzle-grid.css - │   │   ├── puzzle-instructions.css - │   │   ├── puzzle-messages-to-user.css - │   │   ├── puzzle-refresh.css - │   │   ├── puzzle-submission.css - │   │   └── puzzle-thumbnail.css - │   ├── deflect_logo.svg <- Deflect Logo (injected into `index.html` during bundling) - │   └── index.html <- The HTML template **before** bundling (**not to be served to user**) - ├── tsconfig.json <- TypeScript configuration - └── types <- Shared type definitions - └── shared.d.ts -``` - - -### types (`puzzle_ui/types`) - -- Contains TypeScript type definitions shared across the UI. - -### src (`puzzle_ui/src`) - -#### src/index.html - -- `src/index.html` is the template HTML file **before** bundling. -- It gets modified during the build process to embed scripts, styles, and the Deflect logo. - -#### src/deflect_logo.svg - -- The Deflect CAPTCHA logo injected into the final `index.html`. - -#### src/client - -- Houses all scripts and styles required for the CAPTCHA UI. - -##### src/client/scripts - -- The core client side logic that runs in the browser - -- **Entrypoint:** `entrypoint-deflect-captcha.ts` - - Initializes the CAPTCHA system. - - Checks if an initial state was injected or needs to be fetched. - - Handles error reporting and retry logic. - - Implements a fallback mechanism for worst-case scenarios. - -###### src/client/scripts/utils - -- These are utilities that are prebundled with the dependencies required for legacy browsers to function. - - For example, not all browsers admit crypto.subtle API. Therefore, we provide `hmac-utils.ts` such that all browsers can either use their `crypto.subtle` API should they have it, or fallback to the pre bundled depdency (`crypto-js`) - -- `cookie-utils.ts`: Handles cookies for authentication and state management. -- `hmac-utils.ts`: Cryptographic helper functions. - -##### src/client/styles - -- Defines the visual styling for different puzzle components. - -- By default, styles are embedded directly into `index.html`. - -- If you prefer to bundle CSS with JS, you must: - 1) Enable the postcss Rollup hook. - 2) Uncomment the import statements in `entrypoint-deflect-captcha.ts`. - 3) Remove the tags from `src/index.html`. - -### dist (`puzzle_ui/dist`) - -- Contains the production-ready assets. - -- Key files: - - `dist/index.html`: The final, self-contained page (fully bundled). - - `dist/client/scripts/bundle.js`: The compiled JavaScript bundle. - -- **Note:** - - The `index.html` already includes all required `scripts/styles`. - - *Only* `index.html` and the user's cookie are needed for deployment. - -### root (`puzzle_ui/`) - -- houses `injectBundleJSToIndexHTML.js` - -- This is a custom Rollup hook that modifies index.html after bundling. - -- Automatically injects bundle.js into index.html, ensuring that: - - 1) All assets are inline (to be served in a single request). - 2) The Deflect CAPTCHA system remains self-contained. - -## Deployment Guide - -### Serving in Production - -- The bundling process ensures that all required assets are packaged into a **single deliverable** for easy deployment. This includes: - -1) JavaScript - Bundled with Rollup, optimized, and obfuscated (for production only) & is injected directly into `index.html` via the `injectBundleJSToIndexHTML.js` script. -2) Deflect Logo SVG - Also injected directly into index.html via the `injectBundleJSToIndexHTML.js` script. -3) CSS - Embedded directly inside `index.html`. - **Note:** The css may also be included with the `bundle.js` if you: - 1) remmove it from `index.html` - 2) uncomment the entrypoint `*.css` imports - 3) uncomment the postcss() rollup code -4) Polyfills - Included to support legacy browsers (which is an important requirement). - -#### What needs to be served? - -- This means that the only *file* you need to serve is: `dist/index.html` -- You must also attach a *challenge cookie* along with the `dist/index.html` response payload using the cookie name: `deflect_challenge4` -- That's it. Nothing else is required. - -#### You do **not** need to serve: - -- Logos, JS, CSS, or external assets—they are already embedded in the `index.html`. -- Separate API endpoints for fetching assets—everything needed is in the single file. - -#### What the Server Needs to Do - -- To properly issue a unique challenge per user, the **server** can follow one of two procedures: - -- **1) Recommended Approach (Production Best Practice):** inject the initial state into index.html - - The server reads index.html before serving it. - - The server injects a dynamically generated initial state (per user). - - The user receives index.html with the puzzle state already embedded. - - This is how Deflect works in production and ensures a seamless, efficient challenge issuance. - - -- **2) Alternative Approach:** do not inject the initial state into index.html, but have an endpoint prepared to handle a request for the puzzle state - - If the initial state is not injected, the puzzle will immediately phone home requesting it from the server. - - In this case, the server must provide an endpoint to handle these state requests dynamically. - - This approach may be useful for development but is not recommended for production. - - -- **Note:** You *will* need to have the endpoint to serve a puzzle state on request regardless of what option you choose as the puzzle includes a rate limited "refresh" button that requets a new puzzle state and updates the current state. This was included to provide the user the option of trying a different one if they deem the current board too difficult. However, it is still recommended to inject the initial state into `index.html` as this is a requirement for how Deflect works. - -- For details on how the server should inject the initial state, refer to the [Server-Side Documentation](../internal/puzzle-util/README.md). - -## Contributing - -### Setting Up the Development Environment - -- Step 1) Clone this repository - -- Step 2) Install dependencies - - ### The UI is built using Node.js and Rollup. Ensure you have Node.js installed (v18 or later). Then, install the required dependencies: - ``` - cd puzzle_ui - npm install - ``` - -- Step 3) Create a **.env.production** and **.env.development** files - - #### .env.production: - ``` - MINIFY_CSS=true - SOURCE_MAP=false - OBFUSCATE=true - ``` - - #### .env.development: - ``` - MINIFY_CSS=false - SOURCE_MAP=true - OBFUSCATE=false - ``` - -- Step 4) run the following commands: - ``` - npm run clean - npm run build - ``` - -#### Package.json Commands - -- ```npm run dev``` - - deletes the dist/ directory and rebuilds from scratch, bundling all dependencies, and watching for changes to client side code before rebundling (uses dev env variables) - -- ```npm run build``` - - runs Rollup to bundle client-side code (which injects the bundle into the` index.html`) - -- ```npm run clean``` - - clears the dist/ directory if you want a fresh build - -- ```npm run watch``` - - watches for changes in client code & automatically rebundles - -- ```npm run prod``` - - deletes the dist/ directory and rebuilds from scratch using production environment variables - - - -#### Typical Development Workflow - -- Either run: - ``` - 1) npm run clean - 2) npm run build - ``` -- Or: - ``` - 1) npm run dev - ``` - -- The only difference is that the npm run dev will continue monitoring for changes such that when you make a change, it will automatically clean and build such that your server serves the most recent one - -- **In both cases**, you must serve from **`dist/index.html`** - -- This will not only include the html, but also the css as well as the js and all dependencies and polyfills -- The only thing that remains to do when serving it is to inject the initial state at runtime. Since each puzzle is unique to the user, the initial state cannot be precomputed and must be dynamically generated. The server handles this by issuing a state-specific challenge upon request. This is injected directly into the `index.html` as per Deflect requirements. For more details, check the [Server-Side Documentation](../internal/puzzle-util/README.md). - - **Note:** If the initial state is **not injected** at runtime, the puzzle **will automatically request it** from the server. In this case, you **must have an endpoint** to handle this request and provide the state dynamically. - -#### Typical Production Workflow - - run: - ``` - npm run prod - ``` -- You can now serve the CAPTCHA directly from `dist/index.html` - - This will contain the HTML, CSS, JS, Polyfills & all dependencies (such as for calculating HMAC) - - It is also obfuscated via rollup - - The only thing that remains to do when serving it is to inject the initial state at runtime. Since each puzzle is unique to the user, the initial state cannot be precomputed and must be dynamically generated. The server handles this by issuing a state-specific challenge upon request. This is injected directly into the `index.html` as per Deflect requirements. For more details, check the [Server-Side Documentation](../internal/puzzle-util/README.md). - - **Note:** If the initial state is **not injected** at runtime, the puzzle **will automatically request it** from the server. In this case, you **must have an endpoint** to handle this request and provide the state dynamically. - ---- \ No newline at end of file diff --git a/baskerville-ai-security.php b/baskerville-ai-security.php index bafdbd9..627e553 100644 --- a/baskerville-ai-security.php +++ b/baskerville-ai-security.php @@ -32,6 +32,7 @@ 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'; +// require_once BASKERVILLE_PLUGIN_PATH . 'includes/class-baskerville-gatekeeper.php'; // Add custom cron intervals add_filter('cron_schedules', function($schedules) { From bc77efc47b70987bf79d56e918fb225aff00a9a3 Mon Sep 17 00:00:00 2001 From: mazhurin Date: Thu, 16 Apr 2026 19:02:47 +0200 Subject: [PATCH 3/4] Gatekeeper challenge. Slider under attack mode. --- README.md | 43 +- admin/class-baskerville-admin.php | 566 ++++++++++++--- assets/css/admin.css | 107 +++ assets/js/live-feed.js | 11 +- baskerville-ai-security.php | 28 +- includes/class-baskerville-core.php | 56 +- includes/class-baskerville-firewall.php | 60 +- includes/class-baskerville-gatekeeper.php | 808 +++++++++++++++++++--- readme.txt | 61 +- 9 files changed, 1485 insertions(+), 255 deletions(-) diff --git a/README.md b/README.md index 79326a1..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/ 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 627e553..c65573a 100644 --- a/baskerville-ai-security.php +++ b/baskerville-ai-security.php @@ -30,9 +30,7 @@ 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'; -// require_once BASKERVILLE_PLUGIN_PATH . 'includes/class-baskerville-gatekeeper.php'; // Add custom cron intervals add_filter('cron_schedules', function($schedules) { @@ -53,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 @@ -80,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..768aa87 100644 --- a/includes/class-baskerville-firewall.php +++ b/includes/class-baskerville-firewall.php @@ -470,14 +470,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 +493,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 +537,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 +612,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 index 26742e8..8492d17 100644 --- a/includes/class-baskerville-gatekeeper.php +++ b/includes/class-baskerville-gatekeeper.php @@ -4,6 +4,14 @@ exit; } +if (!function_exists('wpsec_log')) { + function wpsec_log($message) { + if (defined('BASKERVILLE_DEBUG') && BASKERVILLE_DEBUG) { + error_log('[Baskerville Gatekeeper] ' . $message); + } + } +} + //-------------------------------------------------- //-------------------------------------------------- //constants @@ -63,8 +71,7 @@ function wpsec_should_challenge() { wpsec_log('[gatekeeper] checking challenge decision'); - //TODO - $should_challenge = true; + $should_challenge = !empty($GLOBALS['baskerville_gatekeeper_challenge']); wpsec_log('[gatekeeper] challenge decision=' . ($should_challenge ? 'challenge' : 'allow')); return $should_challenge; @@ -82,7 +89,9 @@ function wpsec_should_challenge() { function wpsec_send_plain_response($status_code, $message) { status_header((int) $status_code); header('Content-Type: text/plain; charset=utf-8'); - header('Cache-Control: no-store, no-cache'); + 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'); echo (string) $message; exit; } @@ -106,6 +115,30 @@ function wpsec_is_asset_like_request() { return false; } +/** + * Clear only the per-attempt solution cookies (__wpsec_sol_hash_ and __wpsec_cc_*). + * Unlike wpsec_clear_challenge_cookies(), this intentionally keeps __wpsec_challenge_ + * so the visitor can retry the same puzzle after a failed verification attempt. + */ +function wpsec_clear_solution_cookies() { + foreach ($_COOKIE as $name => $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 @@ -366,6 +399,19 @@ function wpsec_build_cookie_header_for_captcha_challenge_verification() { 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'] ?? ''); @@ -374,7 +420,7 @@ function wpsec_verify_captcha_solution_via_upstream_cookies() { wpsec_log('[gatekeeper] forwarding verification cookie header=' . $cookie_header); - $response = wp_remote_get(CAPTCH_SOLUTION_VERIFICATION_ENDPOINT, [ + $response = wp_remote_get(CAPTCHA_SOLUTION_VERIFICATION_ENDPOINT, [ 'timeout' => 10, 'redirection' => 3, 'headers' => [ @@ -391,6 +437,7 @@ function wpsec_verify_captcha_solution_via_upstream_cookies() { 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(), @@ -452,6 +499,19 @@ function wpsec_verify_captcha_solution_via_upstream_cookies() { 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'] ?? ''); @@ -505,8 +565,27 @@ function wpsec_verify_captcha_pass_token_cookie_is_valid() { 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'] ?? ''); @@ -527,12 +606,10 @@ function wpsec_issue_challenge() { ]); if (is_wp_error($response)) { - wpsec_log('[gatekeeper] challenge fetch failed: ' . $response->get_error_message()); - - status_header(503); - header('Content-Type: text/html; charset=utf-8'); - echo '

Challenge unavailable

'; - exit; + 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); @@ -545,9 +622,24 @@ function wpsec_issue_challenge() { 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); - header('Cache-Control: no-store, no-cache'); echo $body; exit; } @@ -852,7 +944,38 @@ function wpsec_enforce_captcha_policy() { 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 @@ -861,20 +984,23 @@ function wpsec_enforce_captcha_policy() { 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')) { - wpsec_log('[gatekeeper] privileged logged-in user, allowing'); - return; + if (!$is_test_mode) { + wpsec_log('[gatekeeper] privileged logged-in user, allowing'); + return; + } + wpsec_log('[gatekeeper] admin test mode active, proceeding with challenge flow'); } /* @@ -899,23 +1025,36 @@ function wpsec_enforce_captcha_policy() { 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); - - if ($status_code === 204) { + + 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'); - //make sure we always relay their valid cookie back to them so that they can continue - //to browser undisturbed since their solution is legitamate return; - + } else { //either the token is a fake, or replayed from someone else or @@ -952,19 +1091,10 @@ function wpsec_enforce_captcha_policy() { } } - //if the user didnt have a challenge pass BUT also is not meant to be challenged - //then allow them through normally - if (!wpsec_should_challenge()) { - wpsec_log('[gatekeeper] should not challenge, allowing normal render'); - return; - } - - /* - at this point we know that we are dealing with a user who needs to be challenged - but it could also be that we are in the middle of a challenge - */ - - //check if its a refresh + // 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') { @@ -972,20 +1102,70 @@ function wpsec_enforce_captcha_policy() { wpsec_clear_challenge_cookies(); wpsec_refresh_challenge_state(); } - - //check if it has solution + + // 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') { - //relay back the 403 + message such that the puzzle displays the correct - //message to the user - wpsec_log('[gatekeeper] relaying invalid solution back to client'); - wpsec_send_plain_response(403, '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 @@ -1006,46 +1186,115 @@ function wpsec_enforce_captcha_policy() { exit; } else if ($status_code === 400) { - //re-issue the challenge but NOT here, instead respond BACK to the user - //with 404 such that client side triggers refresh and gets challenged again - //however, we need to first clear all of their existing cookies - wpsec_log('[gatekeeper] bad verification payload, clearing cookies and telling client to refresh'); - wpsec_clear_challenge_cookies(); - wpsec_send_plain_response(404, 'refresh challenge'); + // 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) { + } 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 + //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, clearing leftover challenge cookies and allowing'); - - // so for example, we could implement a function that would mark them as having already - // passed and remove them from the list of people to be challenged and not be interferred with - // later unless the ML pipeline pushes up another challenge or something. - // so as a placeholder here to remove from local challenge list / cache / rule store i left - //this just for it to be clear - // wpsec_mark_visitor_as_passed(); - - // upstream pass cookie has already been forwarded in wpsec_verify_captcha_solution_via_upstream_cookies() - // now clear any leftover challenge/solution cookies from this host-side state + 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; - wpsec_log('[gatekeeper] verification succeeded, allowing normal render'); + } 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 { - //something went wrong, this is unexpected, log it in some persistent error - //log and fallback on re-issueing the challenge - wpsec_log('[gatekeeper] unexpected verification result, clearing cookies and reissuing challenge'); - wpsec_clear_challenge_cookies(); - wpsec_issue_challenge(); + // 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(); @@ -1060,9 +1309,412 @@ function wpsec_enforce_captcha_policy() { } -//------------------------------------------------------------- -//------------------------------------------------------------- -//calls main function to enforce captcha enforcement policy -//------------------------------------------------------------- -//------------------------------------------------------------- -add_action('template_redirect', 'wpsec_enforce_captcha_policy'); \ No newline at end of file +/** + * 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/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. From b86bc286f5d2665eb97dab186d3aea5b7c0eb08f Mon Sep 17 00:00:00 2001 From: mazhurin Date: Fri, 17 Apr 2026 13:01:17 +0200 Subject: [PATCH 4/4] Block reasons removed. --- includes/class-baskerville-firewall.php | 28 ++----------------------- includes/class-baskerville-honeypot.php | 6 ++---- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/includes/class-baskerville-firewall.php b/includes/class-baskerville-firewall.php index 768aa87..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; } 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})");