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
boolmatch_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
boolrun()
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: arrayfail()
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
boolis_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
boolis_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
boolmatches_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
boolshould_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.