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
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
boolcreationell_captcha_sodium_available()
Whether ext-sodium (required for Argon2id) is available.
creationell_captcha_sodium_available() : bool
Return values
boolcreationell_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
stringcreationell_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
stringcreationell_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
stringcreationell_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
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
Return values
stringcreationell_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
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
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
Return values
boolcreationell_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
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
ctxquery value.
Tags
Return values
boolcreationell_captcha_engine()
Shared captcha engine instance.
creationell_captcha_engine() : Engine
Return values
Enginecreationell_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), andip_in_list()otherwise compares plain strings;- so
firewall_ip_block,firewall_ip_allow,firewall_trusted_proxiesandcode_challenge_watchlistmatched 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
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_blockandcode_challenge_watchlisthit 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
stringcreationell_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
boolcreationell_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
boolcreationell_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
boolcreationell_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
boolcreationell_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:
- firewall_trusted_proxies (the explicit textarea list)
- CREATIONELL_CAPTCHA_TRUSTED_PROXIES (wp-config constant)
- firewall_trust_private_ranges (when on): the private/loopback ranges
- firewall_trust_cloudflare (when on): the cached/bundled CF ranges
Parameters
- $ip : string
-
A validated client IP address.
Return values
boolcreationell_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:
- firewall_ip_allow vs $ip
- bypass_ua_allow vs $ua
- 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}|falsecreationell_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}|falsecreationell_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
stringcreationell_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
stringcreationell_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
stringcreationell_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
stringcreationell_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()).