CreaCaptcha

Interceptor
in package

Inspects front-end POST requests and enforces a valid ALTCHA payload for requests whose path matches a configured pattern. See the module-2 design spec for the bypass chain (§6) and the guard decision (§7/§8).

Table of Contents

Constants

EXCLUDED_SCRIPTS  = ['wp-login.php', 'wp-comments-post.php', 'wp-cron.php', 'xmlrpc.php']
Script basenames that are never guarded. The module-1 core-form integrations own these; a second verification here would consume the challenge and make the subsequent hook verification fail as a replay.

Properties

$action_patterns  : array<int, string>|null
Request-local cache of the filtered action patterns. The filter is evaluated in exactly one place (self::action_patterns()) and exactly once per request, so the bypass step and the guard step can never see two different lists — that asymmetry was BK-10.

Methods

match_path()  : bool
Matches a request path against a list of `*`-wildcard patterns, with `!`-prefixed entries acting as hard exclusions (allow-list semantics).
match_request_path()  : bool
Matches a request path against the pattern list in BOTH spellings the request can have: URL-decoded and as it came off the wire.
run()  : void
Runs the interceptor for the current request. Registered on `init` at priority 1. Terminates the request when verification fails.
action_patterns()  : array<int, string>
The filtered action-slug patterns — the single evaluation point for `creationell_captcha_interceptor_actions` (BK-10). Cached per request so the bypass step and the guard step can never disagree, not even with a non-deterministic filter callback attached.
context()  : array{path: string, path_raw: string, method: string, script: string, is_ajax: bool, action: string, actions: array}
Builds the request context.
fail()  : void
Sends the fail-closed response and terminates the request — see spec §10.
is_ajax_request()  : bool
Best-effort detection of a JSON/AJAX request.
is_guarded()  : bool
Whether the current request is a guarded target. Checks both URL paths (`interceptor_paths`) and action slugs (`interceptor_actions`); a positive match in either list guards the request.
is_rest_request()  : bool
Whether the current request is served by the WordPress REST API.
matches_action()  : bool
Whether any of the request's action candidates matches a pattern.
should_bypass()  : bool
The bypass chain. Evaluation order = listing order. Action-based protection overrides the `is_admin()` step (and only that step) so named-action endpoints in wp-admin/admin-post.php and admin-ajax.php can be guarded.

Constants

EXCLUDED_SCRIPTS

Script basenames that are never guarded. The module-1 core-form integrations own these; a second verification here would consume the challenge and make the subsequent hook verification fail as a replay.

private mixed EXCLUDED_SCRIPTS = ['wp-login.php', 'wp-comments-post.php', 'wp-cron.php', 'xmlrpc.php']

Properties

$action_patterns

Request-local cache of the filtered action patterns. The filter is evaluated in exactly one place (self::action_patterns()) and exactly once per request, so the bypass step and the guard step can never see two different lists — that asymmetry was BK-10.

private array<int, string>|null $action_patterns = null

Methods

match_path()

Matches a request path against a list of `*`-wildcard patterns, with `!`-prefixed entries acting as hard exclusions (allow-list semantics).

public static match_path(string $path, array<int, string> $patterns) : bool

Returns true iff at least one positive pattern matches AND no !-pattern matches. Order in the list is irrelevant — a single !-match short-circuits the whole list to false.

Public and static so the matching can be exercised in isolation; also reused for action-name matching (the algorithm is charset-agnostic).

Matches ONE spelling of a path. Callers that hold a request path use self::match_request_path() instead, which runs this against the decoded and the raw spelling (BK-5); calling it with a single spelling would either lose the percent-encoded patterns of existing configurations or the pages core routes through decoding.

Parameters
$path : string

Request path or action slug.

$patterns : array<int, string>

Wildcard patterns; !-prefixed = exclude.

Return values
bool

match_request_path()

Matches a request path against the pattern list in BOTH spellings the request can have: URL-decoded and as it came off the wire.

public static match_request_path(string $decoded_path, string $raw_path, array<int, string> $patterns) : bool

BK-5 asked for matching on the DECODED path, because core routes /%6Bontakt and /kontakt to the same page. Decoding alone, however, would have been a one-way replacement, and that silently REMOVES protection on updating installations: up to 1.0.2 the matching ran on the raw path, so for every page whose slug is not plain ASCII (Cyrillic/Greek/CJK stay UTF-8 in a WordPress slug) the percent-encoded pattern was the only configuration that ever worked. interceptor_paths = ['/%C3%BCber-uns*'] guarding the page /über-uns matched POST /%C3%BCber-uns before, and would have stopped matching it after a decode-only change — the exact silent hole this audit is about, only in the other direction.

Hence OR, not replace. The OR can only ever guard MORE requests, never fewer, so it cannot open a bypass. Its price: an exclusion (!…) written in one spelling no longer suppresses a positive match found in the other spelling — e.g. !/kontakt/admin does not exclude /kontakt/%61dmin, which the raw pass still matches via /kontakt*. That request is then guarded rather than let through, which is fail-closed and exactly the 1.0.2 behaviour.

Parameters
$decoded_path : string

Path decoded exactly once (see self::context()).

$raw_path : string

Path as sent, still percent-encoded.

$patterns : array<int, string>

Wildcard patterns; !-prefixed = exclude.

Return values
bool

run()

Runs the interceptor for the current request. Registered on `init` at priority 1. Terminates the request when verification fails.

public run() : void

action_patterns()

The filtered action-slug patterns — the single evaluation point for `creationell_captcha_interceptor_actions` (BK-10). Cached per request so the bypass step and the guard step can never disagree, not even with a non-deterministic filter callback attached.

private action_patterns() : array<int, string>
Return values
array<int, string>

context()

Builds the request context.

private context() : array{path: string, path_raw: string, method: string, script: string, is_ajax: bool, action: string, actions: array}

action keeps the historic single-value shape (POST preferred) for the event payloads; actions carries BOTH candidate sources, because the guard has to consider both — see below. path is the URL-decoded path, path_raw the wire spelling; the guard matches against both (BK-5, see self::match_request_path()).

Return values
array{path: string, path_raw: string, method: string, script: string, is_ajax: bool, action: string, actions: array}

fail()

Sends the fail-closed response and terminates the request — see spec §10.

private fail(array{path: string, path_raw: string, method: string, script: string, is_ajax: bool} $context) : void
Parameters
$context : array{path: string, path_raw: string, method: string, script: string, is_ajax: bool}

Request context.

is_ajax_request()

Best-effort detection of a JSON/AJAX request.

private is_ajax_request() : bool
Return values
bool

is_guarded()

Whether the current request is a guarded target. Checks both URL paths (`interceptor_paths`) and action slugs (`interceptor_actions`); a positive match in either list guards the request.

private is_guarded(array{path: string, path_raw: string, method: string, script: string, is_ajax: bool, action: string, actions: array} $context) : bool
Parameters
$context : array{path: string, path_raw: string, method: string, script: string, is_ajax: bool, action: string, actions: array}

Request context.

Return values
bool

is_rest_request()

Whether the current request is served by the WordPress REST API.

private is_rest_request() : bool

The REST_REQUEST constant is not yet defined at init priority 1 — core only defines it inside rest_api_loaded(), which runs on parse_request, i.e. after init. That is still true, so the constant remains unusable here and the request has to be identified from its own shape.

BK-1: the previous implementation did that with a bare isset( $_GET['rest_route'] ) — value-independent, so a trailing ?rest_route= on ANY request switched the whole interceptor off while core, seeing an empty value, bailed out of rest_api_loaded() and served the page normally. The decision now lives in creationell_captcha_current_rest_route(), which evaluates the VALUE and the serving entry point exactly the way core does.

Return values
bool

matches_action()

Whether any of the request's action candidates matches a pattern.

private matches_action(array{action: string, actions: array} $context, array<int, string> $patterns) : bool

BK-11: a request counts as protected when EITHER $_POST['action'] or $_GET['action'] hits a guarded pattern, regardless of which one core's $_REQUEST-based dispatcher would pick under the active request_order.

Parameters
$context : array{action: string, actions: array}

Request context.

$patterns : array<int, string>

Wildcard patterns.

Return values
bool

should_bypass()

The bypass chain. Evaluation order = listing order. Action-based protection overrides the `is_admin()` step (and only that step) so named-action endpoints in wp-admin/admin-post.php and admin-ajax.php can be guarded.

private should_bypass(array{path: string, path_raw: string, method: string, script: string, is_ajax: bool, action: string, actions: array} $context) : bool
Parameters
$context : array{path: string, path_raw: string, method: string, script: string, is_ajax: bool, action: string, actions: array}

Request context.

Return values
bool

        
On this page

Search results