CreaCaptcha

helpers.php

Helper functions for CreaCaptcha.

Table of Contents

Constants

CREATIONELL_CAPTCHA_LOCALE_MAP  = [ // Deutsch 'de_DE' => 'de', 'de_DE_formal' => 'de', 'de_AT' => 'de', 'de_CH' => 'de', 'de_CH_informal' => 'de', // Englisch 'en_US' => 'en', 'en_GB' => 'en', 'en_AU' => 'en', 'en_CA' => 'en', 'en_NZ' => 'en', 'en_ZA' => 'en', // Französisch 'fr_FR' => 'fr-fr', 'fr_BE' => 'fr-fr', 'fr_LU' => 'fr-fr', 'fr_CH' => 'fr-fr', 'fr_CA' => 'fr-ca', // Spanisch (Europa) 'es_ES' => 'es-es', // Spanisch (LatAm — alle nach es-419) 'es_AR' => 'es-419', 'es_CL' => 'es-419', 'es_CO' => 'es-419', 'es_CR' => 'es-419', 'es_DO' => 'es-419', 'es_EC' => 'es-419', 'es_GT' => 'es-419', 'es_HN' => 'es-419', 'es_MX' => 'es-419', 'es_PA' => 'es-419', 'es_PE' => 'es-419', 'es_PR' => 'es-419', 'es_UY' => 'es-419', 'es_VE' => 'es-419', // Portugiesisch 'pt_PT' => 'pt-pt', 'pt_AO' => 'pt-pt', 'pt_BR' => 'pt-br', // Italienisch / Niederländisch / Polnisch 'it_IT' => 'it', 'nl_NL' => 'nl', 'nl_BE' => 'nl', 'pl_PL' => 'pl', // Tschechisch / Slowakisch 'cs_CZ' => 'cs', 'sk_SK' => 'sk', // Nordisch 'da_DK' => 'da', 'sv_SE' => 'sv', 'fi' => 'fi', 'nb_NO' => 'nb', 'nn_NO' => 'nb', // Sonstige EU 'hu_HU' => 'hu', 'ro_RO' => 'ro', 'el' => 'el', ]
Maps WordPress locales to the matching ALTCHA i18n locale bundle.
CREATIONELL_CAPTCHA_PURPOSE_UA_CTX  = 'underattack-ctx'
Purpose label of the under-attack code-challenge suppression token (`ctx`).
CREATIONELL_CAPTCHA_PURPOSE_UA_PASS  = 'underattack-pass'
Purpose label of the under-attack pass-cookie key.
CREATIONELL_CAPTCHA_UA_CTX_TTL  = 120
Lifetime (seconds) of a `ctx` suppression token. The interstitial widget fetches the challenge on load, i.e. within seconds of the 503 being rendered; two minutes is generous for that and replaces the ~10 minutes the old 5-minute-bucket pair accepted (BK-4).

Functions

creationell_captcha_resolve_widget_locale()  : string|null
Resolves the active WordPress locale to a vendored ALTCHA locale code.
creationell_captcha_get_default_settings()  : array<string, mixed>
Default plugin settings.
creationell_captcha_get_settings()  : array<string, mixed>
Current plugin settings, merged over the defaults.
creationell_captcha_invalidate_settings_cache()  : void
Drops the in-request settings cache. Wired to the option-change hooks below so callers that read settings after an update see the fresh value.
creationell_captcha_is_disabled()  : bool
Whether the captcha is globally disabled via the wp-config constant.
creationell_captcha_sodium_available()  : bool
Whether ext-sodium (required for Argon2id) is available.
creationell_captcha_generate_secrets()  : array{signature: string, key_signature: string}
Generates both HMAC secrets and persists them (non-autoloaded).
creationell_captcha_get_secret()  : string
Returns a stored HMAC secret, generating + persisting it on first use.
creationell_captcha_get_hmac_secret()  : string
The HMAC signature secret (signs each challenge).
creationell_captcha_get_hmac_key_secret()  : string
The HMAC key-signature secret (enables the fast verification path).
creationell_captcha_derive_hmac_key()  : string
Derives a purpose-bound HMAC key from the plugin's base signature secret.
creationell_captcha_client_ua()  : string
The request's User-Agent, capped at 256 bytes. MAC input for the under-attack tokens — never rendered, never stored.
creationell_captcha_underattack_pass_binding()  : string
The fingerprint the under-attack pass cookie is bound to (BK-3).
creationell_captcha_underattack_pass_issue()  : string
Mints an under-attack pass token for the current visitor.
creationell_captcha_underattack_pass_check()  : bool
Verifies an under-attack pass token against the CURRENT visitor.
creationell_captcha_underattack_ctx_issue()  : string
Mints a single-use `ctx` suppression token for one interstitial rendering.
creationell_captcha_underattack_ctx_check()  : bool
Verifies a `ctx` suppression token and consumes it.
creationell_captcha_engine()  : Engine
Shared captcha engine instance.
creationell_captcha_log()  : void
Write a message to the debug log when CREATIONELL_CAPTCHA_DEBUG is active.
creationell_captcha_normalize_ip()  : string
Canonicalises an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) into its plain IPv4 spelling. Every other value — including anything that is not an IP at all — is handed back unchanged.
creationell_captcha_get_client_ip()  : string
Resolves the client IP address.
creationell_captcha_ip_in_list()  : bool
Whether an IP matches any entry in a list of IPs or CIDR ranges.
creationell_captcha_ip_in_cidr()  : bool
Whether an IP falls within a CIDR range. Supports IPv4 and IPv6.
creationell_captcha_is_valid_ip_or_cidr()  : bool
Whether a string is a valid IP address or CIDR range (IPv4 or IPv6).
creationell_captcha_wildcard_match()  : bool
Whether a subject matches any of the given wildcard patterns (case-insensitive).
creationell_captcha_private_ranges()  : array<string|int, string>
Returns the canonical list of private/loopback CIDR ranges used when the `firewall_trust_private_ranges` toggle is active.
creationell_captcha_trusted_proxies_constant()  : array<string|int, string>
Reads the optional `CREATIONELL_CAPTCHA_TRUSTED_PROXIES` wp-config constant as a list. Accepts either a string array or a comma/whitespace-separated scalar; invalid entries are dropped.
creationell_captcha_is_trusted_proxy()  : bool
Whether the given IP belongs to a trusted upstream proxy.
creationell_captcha_evaluate_bypass()  : array{reason: string, source: string}|false
Pure bypass evaluator — checks the three bypass sources against the supplied inputs without touching $_SERVER, $_COOKIE or any static cache. The caller is responsible for providing the values.
creationell_captcha_request_bypassed()  : array{reason: string, source: string}|false
Whether the current request is allowed to bypass captcha, under-attack and firewall protections. Reads $_SERVER, $_COOKIE and the request's client IP, then delegates to `creationell_captcha_evaluate_bypass()`.
creationell_captcha_validate_action_pattern()  : string|null
Validates a single interceptor-action pattern.
creationell_captcha_validate_cookie_entry()  : string|null
Validates a single bypass-cookie entry of the form `name=value`.
creationell_captcha_anonymize_ip()  : string
Truncates an IP for DSGVO-compliant storage. IPv4 → last octet zeroed, IPv6 → last 80 bits zeroed. Invalid IPs return ''.
creationell_captcha_request_body_fingerprint()  : string
Returns a JSON-encoded fingerprint of $_POST: { field-name: value-byte-length }.
creationell_captcha_block_response()  : void
Sends a fail-closed block response and terminates the request.
creationell_captcha_base64url_encode()  : string
Base64URL encoder (RFC 4648 §5) — strips standard-base64 padding and replaces +/ with -_ so the value is URL-safe.
creationell_captcha_base64url_decode()  : string
Base64URL decoder — accepts unpadded URL-safe input and returns the raw bytes. Returns the empty string on malformed input (no exceptions).
creationell_captcha_ratelimit_current_count()  : int
Reads the current rate-limit counter for an IP without incrementing it.

Constants

CREATIONELL_CAPTCHA_LOCALE_MAP

Maps WordPress locales to the matching ALTCHA i18n locale bundle.

public mixed CREATIONELL_CAPTCHA_LOCALE_MAP = [ // Deutsch 'de_DE' => 'de', 'de_DE_formal' => 'de', 'de_AT' => 'de', 'de_CH' => 'de', 'de_CH_informal' => 'de', // Englisch 'en_US' => 'en', 'en_GB' => 'en', 'en_AU' => 'en', 'en_CA' => 'en', 'en_NZ' => 'en', 'en_ZA' => 'en', // Französisch 'fr_FR' => 'fr-fr', 'fr_BE' => 'fr-fr', 'fr_LU' => 'fr-fr', 'fr_CH' => 'fr-fr', 'fr_CA' => 'fr-ca', // Spanisch (Europa) 'es_ES' => 'es-es', // Spanisch (LatAm — alle nach es-419) 'es_AR' => 'es-419', 'es_CL' => 'es-419', 'es_CO' => 'es-419', 'es_CR' => 'es-419', 'es_DO' => 'es-419', 'es_EC' => 'es-419', 'es_GT' => 'es-419', 'es_HN' => 'es-419', 'es_MX' => 'es-419', 'es_PA' => 'es-419', 'es_PE' => 'es-419', 'es_PR' => 'es-419', 'es_UY' => 'es-419', 'es_VE' => 'es-419', // Portugiesisch 'pt_PT' => 'pt-pt', 'pt_AO' => 'pt-pt', 'pt_BR' => 'pt-br', // Italienisch / Niederländisch / Polnisch 'it_IT' => 'it', 'nl_NL' => 'nl', 'nl_BE' => 'nl', 'pl_PL' => 'pl', // Tschechisch / Slowakisch 'cs_CZ' => 'cs', 'sk_SK' => 'sk', // Nordisch 'da_DK' => 'da', 'sv_SE' => 'sv', 'fi' => 'fi', 'nb_NO' => 'nb', 'nn_NO' => 'nb', // Sonstige EU 'hu_HU' => 'hu', 'ro_RO' => 'ro', 'el' => 'el', ]

Keys are values returned by get_locale() — including the formal / informal variants WP exposes (de_DE_formal, de_CH_informal). Values are the exact locale codes used by the vendored ALTCHA bundles under assets/js/altcha-i18n/<code>.js. Locales not in the map fall through to the widget's own auto-detection (which itself falls back to English since only the bundles enqueued by this plugin are registered).

Extend via the creationell_captcha_widget_locale_map filter rather than patching this constant.

CREATIONELL_CAPTCHA_PURPOSE_UA_CTX

Purpose label of the under-attack code-challenge suppression token (`ctx`).

public mixed CREATIONELL_CAPTCHA_PURPOSE_UA_CTX = 'underattack-ctx'

CREATIONELL_CAPTCHA_PURPOSE_UA_PASS

Purpose label of the under-attack pass-cookie key.

public mixed CREATIONELL_CAPTCHA_PURPOSE_UA_PASS = 'underattack-pass'

CREATIONELL_CAPTCHA_UA_CTX_TTL

Lifetime (seconds) of a `ctx` suppression token. The interstitial widget fetches the challenge on load, i.e. within seconds of the 503 being rendered; two minutes is generous for that and replaces the ~10 minutes the old 5-minute-bucket pair accepted (BK-4).

public mixed CREATIONELL_CAPTCHA_UA_CTX_TTL = 120

Functions

creationell_captcha_resolve_widget_locale()

Resolves the active WordPress locale to a vendored ALTCHA locale code.

creationell_captcha_resolve_widget_locale() : string|null

Returns the locale string (e.g. "de", "fr-fr", "pt-br") if a mapping exists, or null when the WP locale is not in the vendor set — in which case the widget renderer skips both the language attribute and the i18n script enqueue, letting the widget fall through to its own detection (which has only the EN built-in available).

Two filters are applied: creationell_captcha_widget_locale_map to extend / override the lookup table, and creationell_captcha_widget_locale for last-mile overrides after lookup.

Tags
since
0.27.0
Return values
string|null —

Vendored ALTCHA locale code or null.

creationell_captcha_get_default_settings()

Default plugin settings.

creationell_captcha_get_default_settings() : array<string, mixed>
Return values
array<string, mixed>

creationell_captcha_get_settings()

Current plugin settings, merged over the defaults.

creationell_captcha_get_settings([bool $force_refresh = false ]) : array<string, mixed>

Memoised for the duration of the request — get_option() itself is cheap thanks to WP's object cache, but the defaults-merge over ~70 keys adds up across the 10+ call sites per request (Interceptor, Firewall, Rate- Limiter, Under-Attack, every form integration). The cache is invalidated automatically when the option is added, updated or deleted.

Parameters
$force_refresh : bool = false

Re-read from the DB even if a cached copy exists. Used by the invalidation hook.

Return values
array<string, mixed>

creationell_captcha_invalidate_settings_cache()

Drops the in-request settings cache. Wired to the option-change hooks below so callers that read settings after an update see the fresh value.

creationell_captcha_invalidate_settings_cache() : void

creationell_captcha_is_disabled()

Whether the captcha is globally disabled via the wp-config constant.

creationell_captcha_is_disabled() : bool
Return values
bool

creationell_captcha_sodium_available()

Whether ext-sodium (required for Argon2id) is available.

creationell_captcha_sodium_available() : bool
Return values
bool

creationell_captcha_generate_secrets()

Generates both HMAC secrets and persists them (non-autoloaded).

creationell_captcha_generate_secrets() : array{signature: string, key_signature: string}
Return values
array{signature: string, key_signature: string}

creationell_captcha_get_secret()

Returns a stored HMAC secret, generating + persisting it on first use.

creationell_captcha_get_secret(string $which) : string
Parameters
$which : string

Either 'signature' or 'key_signature'.

Return values
string

creationell_captcha_get_hmac_secret()

The HMAC signature secret (signs each challenge).

creationell_captcha_get_hmac_secret() : string

A wp-config constant takes precedence over the stored option.

Return values
string

creationell_captcha_get_hmac_key_secret()

The HMAC key-signature secret (enables the fast verification path).

creationell_captcha_get_hmac_key_secret() : string

A wp-config constant takes precedence over the stored option.

Return values
string

creationell_captcha_derive_hmac_key()

Derives a purpose-bound HMAC key from the plugin's base signature secret.

creationell_captcha_derive_hmac_key(string $purpose) : string

Wurzel 3.5: the challenge signature, the under-attack pass cookie and the ctx suppression token were all MACs under the SAME key. Cross-purpose use was only prevented by the differing message layout — a fragile property that breaks silently as soon as one token family changes its format. HKDF-style expansion with a purpose label makes the separation structural: a MAC minted for one purpose verifies under no other key.

The base secret stays exactly where it is (CREATIONELL_CAPTCHA_HMAC_SECRET / the stored option) — the derivation sits in front of it, so the ALTCHA library keeps signing challenges with the unchanged base key.

Parameters
$purpose : string

Stable purpose label, e.g. underattack-pass.

Tags
since
1.1.0
Return values
string —

64-char hex key, or '' when no base secret is available (callers must fail closed on '').

creationell_captcha_client_ua()

The request's User-Agent, capped at 256 bytes. MAC input for the under-attack tokens — never rendered, never stored.

creationell_captcha_client_ua() : string
Tags
since
1.1.0
Return values
string

creationell_captcha_underattack_pass_binding()

The fingerprint the under-attack pass cookie is bound to (BK-3).

creationell_captcha_underattack_pass_binding() : string

Two components, neither of which travels inside the cookie. Only one of them is load-bearing, and it is worth naming which: the NETWORK is what a recipient cannot bring along, because it is where his packets come from. The User-Agent he can bring along — he sends it himself, and whoever passes the cookie on passes the UA string on with it. So the UA is a cheap extra, the network is the half that actually refuses a transferred cookie.

  • the client NETWORK (IPv4 /24, IPv6 /48 — the same reduction the analytics anonymiser applies), resolved through creationell_captcha_get_client_ip() so a forwarded header only counts behind a trusted proxy (BK-7/BK-8) and never straight off an attacker-set header;
  • the User-Agent.

Trade-off, deliberately made here and not at the two call sites: the User-Agent alone is weak (whoever passes the cookie on passes the UA string on with it), the full address breaks on every mobile hand-over. The network is the middle ground — it survives the address churn inside one access network (CGNAT pool, IPv6 privacy extensions) and still refuses a cookie that travelled to a different one. A false negative costs exactly one extra proof-of-work: the visitor sees the interstitial again, solves it and gets a cookie bound to the new network. No lock-out, no loop.

Tags
since
1.1.0
Return values
string —

Binding string; '' disables the binding entirely.

creationell_captcha_underattack_pass_issue()

Mints an under-attack pass token for the current visitor.

creationell_captcha_underattack_pass_issue(int $expiry) : string
Parameters
$expiry : int

Absolute Unix timestamp the pass runs out at.

Tags
since
1.1.0
Return values
string —

<expiry>.<mac> token, or '' when no key could be derived.

creationell_captcha_underattack_pass_check()

Verifies an under-attack pass token against the CURRENT visitor.

creationell_captcha_underattack_pass_check(string $cookie) : bool

BK-3: before 1.1.0 the MAC covered the expiry timestamp and nothing else, so one solved interstitial produced a bearer token that worked from any client, any address, for up to underattack_pass_duration (86400 s). The MAC now covers the visitor binding as well, which is not part of the cookie value.

Parameters
$cookie : string

Raw cookie value.

Tags
since
1.1.0
Return values
bool

creationell_captcha_underattack_ctx_issue()

Mints a single-use `ctx` suppression token for one interstitial rendering.

creationell_captcha_underattack_ctx_issue() : string

BK-4/CM-4/W5: the old token was HMAC(secret, 'ua-ctx|' . floor(time()/300)) — identical for every visitor inside a five-minute bucket and accepted for the current AND the previous bucket, i.e. up to ten minutes. It was handed to every anonymous 503 visitor inside the HTML. Unforgeable, yes — but trivially obtainable, transferable and replayable.

The replacement carries a random nonce (so every rendering gets its own token), an explicit expiry and a MAC over nonce, expiry and the visitor's User-Agent. It is burned on first use in …_ctx_check().

What this closes, precisely — and what it does NOT:

  • REPLAY is closed. The nonce is burned on first use, so one token buys at most one suppressed challenge (1.0.2: unlimited inside its bucket pair).
  • The accepted WINDOW shrinks from ~10 minutes to 120 seconds.
  • ACQUISITION stays open. One GET of a 503 page still yields one fresh token, i.e. one code-stage-free challenge. Closing that needs the issued challenge itself to be marked under-attack-only and checked on redemption inside Engine::verify() — outside this file (R1 in the task report).
  • HANDING A FRESH TOKEN ON stays open as well. The MAC covers the User-Agent, but the User-Agent is a value the RECIPIENT sends himself: whoever passes the token to a bot passes the UA string along with it. The binding costs an attacker one header, no more. It is kept because it is free and it does stop a token that leaked WITHOUT its UA (log excerpt, referrer, a URL shared out of band). It is not a transfer barrier, and no comment on this token may claim that it is — W5 was exactly that kind of claim, made about exactly this token.

WHAT SINGLE USE COSTS (m7): the token is spent by the FIRST challenge fetch of the interstitial that carried it. If the very same 503 HTML reaches a browser a second time — restored from the back/forward cache, or replayed by a full-page cache or CDN that ignores the response headers — the widget fetches again with a token that is already burned. /challenge then answers without the suppression, and with code_challenge_enabled on that means the INVISIBLE interstitial widget receives an image code it cannot display: that visitor never passes the gate at all. The interstitial itself asks not to be stored (nocache_headers() sends no-store, private, measured against the WordPress 7.0.2 of the test instance), so this needs a cache that disregards that — but such caches exist, and "under attack" is exactly when an operator puts one in front of the site. Making that visible is a doctor job ("under-attack mode on and a full-page cache detected"); it cannot be fixed from the token side without giving up the single use that closed BK-4.

Why no client-network binding here, unlike the pass cookie: it would not reduce what an attacker can do — every bot can fetch its own 503 page, so handing a token around buys nothing over simply acquiring one — while a false negative here is a dead end instead of a retry. A rejected pass cookie costs one extra proof-of-work; a rejected ctx gets the interstitial's invisible widget an image code it cannot display, and that visitor never passes the gate at all. On top of that the address is not even constant across the two requests of ONE visitor: the XHR that fetches the challenge can leave through a different address than the page view that carried the token (dual-stack clients pick the family per connection, egress pools rotate well beyond a /24).

Tags
since
1.1.0
Return values
string —

<nonce>.<expiry>.<mac>, or '' when no key could be derived.

creationell_captcha_underattack_ctx_check()

Verifies a `ctx` suppression token and consumes it.

creationell_captcha_underattack_ctx_check(string $token) : bool

Returns true at most ONCE per token: the nonce is recorded for the rest of the token's lifetime, every later presentation of the same token fails.

Parameters
$token : string

Raw ctx query value.

Tags
since
1.1.0
Return values
bool

creationell_captcha_engine()

Shared captcha engine instance.

creationell_captcha_engine() : Engine
Return values
Engine

creationell_captcha_log()

Write a message to the debug log when CREATIONELL_CAPTCHA_DEBUG is active.

creationell_captcha_log(string $message) : void

W1-13: Steuerzeichen werden entfernt, BEVOR die Zeile geschrieben wird — dieselbe Reduktion, die Analytics::current_path() für die Log-Tabelle vornimmt. Mehrere Meldungen tragen vom Absender bestimmte Bestandteile in die Zeile, allen voran der Interceptor („interceptor blocked POST to " plus dem EINMAL dekodierten Pfad, class-interceptor.php): ein anonymer POST /kontakt%0A auf einen per /kontakt* geschützten Pfad wird von WordPress geroutet (WP::parse_request() trimmt, und die Rewrite-Regel ^kontakt/?$ trifft dank $ vor dem Zeilenumbruch), der Interceptor blockiert — und die Logzeile enthielt einen echten Zeilenumbruch. Damit bestimmte der Absender, wo eine Zeile im PHP-Fehlerlog endet, und konnte eine zweite, frei gewählte anhängen.

Bewusst hier und nicht an der einen Aufrufstelle: dies ist die Senke, durch die JEDE Meldung des Plugins geht, und keine von ihnen enthält eine beabsichtigte mehrzeilige Ausgabe (nachgezählt über alle Aufrufer). Eine Reparatur je Aufrufstelle wäre dieselbe Zeile mehrfach — und die nächste neue Meldung hätte sie wieder nicht.

Parameters
$message : string

Message to log.

creationell_captcha_normalize_ip()

Canonicalises an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) into its plain IPv4 spelling. Every other value — including anything that is not an IP at all — is handed back unchanged.

creationell_captcha_normalize_ip(string $ip) : string

WHY (audit module 27, finding I2)

On every server whose PHP sees the peer address in IPv4-mapped notation (nginx listen [::]:443 ipv6only=off, Apache on an IPv6 socket, plenty of container and proxy setups) REMOTE_ADDR reads ::ffff:203.0.113.7 for an ordinary IPv4 client. That string passes FILTER_VALIDATE_IP — it is a perfectly valid IPv6 address — so nothing ever complained, but:

  • ip_in_cidr() refuses a family change (4 vs. 16 bytes, correctly so), and ip_in_list() otherwise compares plain strings;
  • so firewall_ip_block, firewall_ip_allow, firewall_trusted_proxies and code_challenge_watchlist matched NOTHING for those clients — the IP firewall was silently inert, and the admin who allow-listed his own address locked himself out anyway;
  • anonymize_ip() reduced all of them to ::, which took the network half out of the BK-3 under-attack pass binding and left a pure bearer token behind.

The check runs on the binary form rather than on the string, so the rarer spellings (::FFFF:cb00:7107, 0:0:0:0:0:ffff:203.0.113.7) are caught as well. :: and ::ffff:0:0:0 are NOT mapped addresses and stay untouched.

Parameters
$ip : string

Candidate address.

Tags
since
1.1.0
Return values
string —

Plain IPv4 spelling for mapped addresses, else the input.

creationell_captcha_get_client_ip()

Resolves the client IP address.

creationell_captcha_get_client_ip() : string

Returns the validated REMOTE_ADDR by default. When the firewall_behind_proxy setting is on, the configured forwarded header is used instead — falling back to REMOTE_ADDR if it yields no valid IP.

This is the single ingress for client addresses: every list check, the rate-limit bucket key, the pass binding and the event log take their value from here. Canonicalising IPv4-mapped addresses therefore happens HERE and not at each of those places (I2).

WHAT THE FALLBACK COSTS (m3 / W2-3) — say it here, because it is not obvious at the call sites: every return $remote below hands the PROXY's address to everything downstream. That is the safe direction (BK-8: an entry we cannot classify must never let a forged one to its left win), but it is not a free one. If a fallback fires on EVERY request — an upstream that appends unknown or an obfuscated identifier per RFC 7239, an Azure-style ip:port hop, a firewall_proxy_header naming a header this installation does not actually receive — then all visitors share one address:

  • the rate limiter counts the whole site into one bucket and locks everyone out at the threshold;
  • firewall_ip_block and code_challenge_watchlist hit all or nothing;
  • and if the proxy address happens to sit in firewall_ip_allow, every visitor is bypassed.

Each fallback therefore names itself through creationell_captcha_log(). That is only visible with CREATIONELL_CAPTCHA_DEBUG; making it visible without the debug switch belongs to the settings help text and to wp creacaptcha doctor, not here.

Return values
string

creationell_captcha_ip_in_list()

Whether an IP matches any entry in a list of IPs or CIDR ranges.

creationell_captcha_ip_in_list(string $ip, mixed $list) : bool

This is the choke point all four address lists run through — blocklist, allowlist, trusted proxies and the code-challenge watchlist — which is why the IPv4-mapped canonicalisation is applied here and not four times over.

Admin-typed entries are canonicalised as well, so an installation that spelled an entry ::ffff:203.0.113.7 (the only spelling that worked on an affected server before this release) keeps matching. CIDR entries are deliberately NOT rewritten: a mapped range like ::ffff:0:0/96 would widen to "every IPv4 address", and silently widening a TRUSTED-PROXY range is the one direction this plugin must never take (LK-13/AF-5). A CIDR written in mapped notation therefore stops matching — see the note on ip_in_cidr().

Parameters
$ip : string

The client IP.

$list : mixed

A list of IPs / CIDR ranges (non-arrays are ignored).

Return values
bool

creationell_captcha_ip_in_cidr()

Whether an IP falls within a CIDR range. Supports IPv4 and IPv6.

creationell_captcha_ip_in_cidr(string $ip, string $cidr) : bool

The subject is canonicalised (I2); the RANGE is taken as written. A range spelled in IPv4-mapped notation (::ffff:203.0.113.0/120) consequently no longer matches an IPv4 client — deliberately, because converting it would mean rewriting prefix lengths, and a wrong prefix in a trusted-proxy list is the failure mode this plugin has already had to close twice.

Parameters
$ip : string

The client IP.

$cidr : string

A CIDR range, e.g. "203.0.113.0/24".

Return values
bool

creationell_captcha_is_valid_ip_or_cidr()

Whether a string is a valid IP address or CIDR range (IPv4 or IPv6).

creationell_captcha_is_valid_ip_or_cidr(string $entry) : bool

Prefix length 0 (0.0.0.0/0, ::/0) is refused: it is valid CIDR notation but matches every address, so as a firewall-allow, trusted-proxy or blocklist entry it silently disables the very list it is in (AF-5). An admin who really wants to cover the whole address space can still spell it out as two halves (0.0.0.0/1 + 128.0.0.0/1) and thereby say so on purpose.

This is an input guard only — it decides what may be STORED. Values already in the database keep working; reporting on those is the fail-safe migration's job, not this function's.

Parameters
$entry : string

The candidate string.

Return values
bool

creationell_captcha_wildcard_match()

Whether a subject matches any of the given wildcard patterns (case-insensitive).

creationell_captcha_wildcard_match(string $subject, mixed $patterns[, bool $empty_subject_matches = false ][, bool $allow_catch_all = true ]) : bool

The pattern alphabet is the same as the firewall UA-blocklist: * is the single wildcard, everything else is matched literally.

The two edge cases used to be decided implicitly, and the decision was wrong for one of the two call sites (BK-9). Both are now the caller's to make:

  • $empty_subject_matches — a missing header is not "the empty string", it is no information at all. For a blocklist "no match" is the safe answer, for an allowlist it is too, but the two reach it for opposite reasons, so neither may inherit it silently.
  • $allow_catch_all — a pattern of nothing but * matches every non-empty subject. Harmless in a blocklist, a total shutdown of the protection in an allowlist. Pass false there and such a pattern is skipped.

WHERE $allow_catch_all STOPS — READ THIS BEFORE TRUSTING IT

The guard is SYNTACTIC and nothing else: it drops a pattern when trim( $pattern, '*' ) leaves nothing behind, i.e. *, **, ***. It does NOT drop a pattern that merely happens to match everything in practice. Measured against the production path: star-slash-star (written out because the literal form would close this comment block) passes this guard and matches every realistic User-Agent — every one of them carries a slash. Same for star-dot-star. false here therefore means "no bare star", not "no catch-all".

That boundary is deliberate. "Matches every real subject" is not a decidable property of a pattern; the nearest thing to it is the five-probe criterion in creationell_captcha_hardening_matches_every_user_agent(), and a heuristic has no business deciding a single request — least of all one that would run on every request, for every stored pattern. Applying it here would also silently reinterpret patterns an operator has already stored. Reporting such an entry is the fail-safe migration's job (includes/hardening-migration.php, section 3, finding class jeder-ua, wirkung: aktiv): it names the entry and leaves the decision with the operator. tests/test-bypass-roots.php section 4a pins both halves.

Parameters
$subject : string

The string to test.

$patterns : mixed

A list of patterns; non-arrays return false.

$empty_subject_matches : bool = false

What an empty subject means for this call site. Default false (previous behaviour).

$allow_catch_all : bool = true

Whether a bare * pattern is honoured. Default true (previous behaviour).

Return values
bool

creationell_captcha_private_ranges()

Returns the canonical list of private/loopback CIDR ranges used when the `firewall_trust_private_ranges` toggle is active.

creationell_captcha_private_ranges() : array<string|int, string>
Return values
array<string|int, string>

creationell_captcha_trusted_proxies_constant()

Reads the optional `CREATIONELL_CAPTCHA_TRUSTED_PROXIES` wp-config constant as a list. Accepts either a string array or a comma/whitespace-separated scalar; invalid entries are dropped.

creationell_captcha_trusted_proxies_constant() : array<string|int, string>
Return values
array<string|int, string>

creationell_captcha_is_trusted_proxy()

Whether the given IP belongs to a trusted upstream proxy.

creationell_captcha_is_trusted_proxy(string $ip) : bool

Sources are checked in this order; the first match wins:

  1. firewall_trusted_proxies (the explicit textarea list)
  2. CREATIONELL_CAPTCHA_TRUSTED_PROXIES (wp-config constant)
  3. firewall_trust_private_ranges (when on): the private/loopback ranges
  4. firewall_trust_cloudflare (when on): the cached/bundled CF ranges
Parameters
$ip : string

A validated client IP address.

Return values
bool

creationell_captcha_evaluate_bypass()

Pure bypass evaluator — checks the three bypass sources against the supplied inputs without touching $_SERVER, $_COOKIE or any static cache. The caller is responsible for providing the values.

creationell_captcha_evaluate_bypass(string|null $ip, string|null $ua, array<string, string> $cookies) : array{reason: string, source: string}|false

Sources are checked in this order; the first match wins:

  1. firewall_ip_allow vs $ip
  2. bypass_ua_allow vs $ua
  3. bypass_cookies vs $cookies (strict name=value)
Parameters
$ip : string|null

Client IP, or null to skip the IP check.

$ua : string|null

User-Agent, or null to skip the UA check.

$cookies : array<string, string>

Cookie map (name => value).

Return values
array{reason: string, source: string}|false

creationell_captcha_request_bypassed()

Whether the current request is allowed to bypass captcha, under-attack and firewall protections. Reads $_SERVER, $_COOKIE and the request's client IP, then delegates to `creationell_captcha_evaluate_bypass()`.

creationell_captcha_request_bypassed() : array{reason: string, source: string}|false

Result is memoised for the request — settings, IP and cookies do not change within a single PHP request. Only reason flows into the event-log context; source is exposed for diagnostic logging by callers.

Return values
array{reason: string, source: string}|false

creationell_captcha_validate_action_pattern()

Validates a single interceptor-action pattern.

creationell_captcha_validate_action_pattern(string $entry) : string|null

Allowed: lowercase/uppercase letters, digits, _, -, * (wildcard), with an optional leading ! for exclusion patterns. Empty input or patterns of only ! are rejected.

Parameters
$entry : string

Raw entry (already trimmed by the caller).

Return values
string|null —

Normalised entry, or null if invalid.

creationell_captcha_validate_cookie_entry()

Validates a single bypass-cookie entry of the form `name=value`.

creationell_captcha_validate_cookie_entry(string $entry) : string|null

Name must be alphanumeric, _ or -. The value is length-capped to 200 bytes and passed through sanitize_text_field(); an entry whose value is empty — before or after sanitising — is refused (BK-14): hash_equals('','') is true, so such an entry would let anybody past who sends the bare cookie name. A bypass cookie is a shared secret; a secret of zero length is none.

Parameters
$entry : string

Raw entry (already trimmed by the caller).

Return values
string|null —

Normalised name=value entry, or null if invalid.

creationell_captcha_anonymize_ip()

Truncates an IP for DSGVO-compliant storage. IPv4 → last octet zeroed, IPv6 → last 80 bits zeroed. Invalid IPs return ''.

creationell_captcha_anonymize_ip(string $ip) : string

I2: an IPv4-mapped address is canonicalised first. Without that every such client reduced to :: — one value for the whole IPv4 internet, which made the event log useless AND emptied the network half of the under-attack pass binding (BK-3). Callers normally pass creationell_captcha_get_client_ip(), which canonicalises already; this repeats it for the direct callers.

Parameters
$ip : string

A validated client IP address.

Return values
string

creationell_captcha_request_body_fingerprint()

Returns a JSON-encoded fingerprint of $_POST: { field-name: value-byte-length }.

creationell_captcha_request_body_fingerprint() : string

No values are recorded — only structural metadata for attack-pattern diagnosis. Field names that contain known sensitive substrings (password, iban, api_key, …) are replaced with [masked:<8-char-sha256>] so the fingerprint does not leak custom-form schema (e.g. bank_iban_input). Output is length-capped to 2048 bytes; if longer, the JSON is collapsed to "}" rather than truncated mid-entry.

Return values
string

creationell_captcha_block_response()

Sends a fail-closed block response and terminates the request.

creationell_captcha_block_response(int $status, string $message[, int $retry_after = 0 ]) : void
Parameters
$status : int

HTTP status code (403 firewall, 429 rate limit).

$message : string

The message shown to the client.

$retry_after : int = 0

Optional Retry-After value in seconds.

creationell_captcha_base64url_encode()

Base64URL encoder (RFC 4648 §5) — strips standard-base64 padding and replaces +/ with -_ so the value is URL-safe.

creationell_captcha_base64url_encode(string $bytes) : string
Parameters
$bytes : string

Raw bytes to encode.

Return values
string

creationell_captcha_base64url_decode()

Base64URL decoder — accepts unpadded URL-safe input and returns the raw bytes. Returns the empty string on malformed input (no exceptions).

creationell_captcha_base64url_decode(string $encoded) : string
Parameters
$encoded : string

URL-safe base64 string.

Return values
string

creationell_captcha_ratelimit_current_count()

Reads the current rate-limit counter for an IP without incrementing it.

creationell_captcha_ratelimit_current_count(string $ip) : int

Uses the same bucket key as Creationell\Captcha\RateLimiter::run() so the value matches what the run-loop would see. Returns 0 if no transient exists for the current window.

Parameters
$ip : string

Client IP (call creationell_captcha_get_client_ip()).

Return values
int

        
On this page

Search results