`, * the rate limiters, the D3 claim marker) stay inside WP's option-name * limit, and absurd or structured input is refused at the edge rather * than travelling into meta queries. * * Deliberately PERMISSIVE: legacy `sess__<9 base36>` ids minted * before #155 must keep working — there is no migration, and a visitor * mid-conversation must not be ejected by a format change. Both the * legacy shape and the new `sess_` + 32 hex satisfy this. */ public const SESSION_ID_PATTERN = '/^[A-Za-z0-9_-]{1,64}$/'; /** * #261 D4: the owner-facing marker for consent receipts that could not be * written (the #263 D5 shape, free-side because consent is free). * * `{count, last}`. Written on EVERY failed receipt write, grant or revoke; * DELETED on the next successful one, so the signal cannot outlive the * fault and the notice needs no dismiss affordance. */ public const CONSENT_LEDGER_FAILURE_OPTION = 'answerrails_consent_ledger_failures'; public static function is_valid_session_id(string $session_id): bool { return (bool) preg_match(self::SESSION_ID_PATTERN, $session_id); } /** * THE per-message size bound, in BYTES (#262 D6). * * Bytes, not characters: `maxlength` counts UTF-16 code units and every * intermediary that refuses a request counts bytes — up to 3x apart in * CJK, in exactly the direction that breaks the request. * * Sited here beside the other request-boundary bounds because it has * THREE consumers that must never disagree: the widget's live composer * check (localized by `AnswerRails_Core`), the composer's coarse * `maxlength` backstop, and `REST_Chat`'s server-side companion. One * filter moves all three. * * ⚠ The CLIENT half is the remedy. The failure this bounds happens * BEFORE the origin — a request an intermediary refuses never reaches * WordPress, so the server check never gets the chance to fire for a * widget visitor. It is here for non-widget callers, so they get a * readable refusal instead of whatever the intermediary does. * * 4,096 bytes sits under the 6,144-byte body budget (#262 D2) with room * for the session fields and a reCAPTCHA token, so a message at the * ceiling still sends — it simply crowds the history out. 0 disables the * bound, the `answerrails_session_idle_ttl` convention. */ public static function message_byte_limit(): int { return (int) apply_filters('answerrails_message_byte_limit', 4096); } // ===================================================================== // #155 D3 — THE identifier↔capability authority. // // The session id is a NAME. The capability is a SECRET the server hands // out once, on first contact, to whoever claims the name first. Knowing // the name is not permission to drive the session. // // The token is DERIVED, never stored (design D3 amendment): a stored // token under a fail-closed rule is evictable, and an object-cache flush // would fail every in-flight conversation on the site at once — with no // durable backstop available, since the chatlog post does not exist yet // at /consent time and never exists in logging mode `none`. // hash_hmac() makes verification stateless, so nothing an eviction can // touch decides whether a legitimate visitor is served. // // Persistent state is therefore ONE boolean per session — the claim // marker — read only on the bootstrap path where no token is presented. // ===================================================================== private const CAP_SALT_KEY = 'answerrails_session_cap'; private const CLAIM_PREFIX = 'answerrails_sess_claimed_'; /** Set by enforce_session_capability() on EVERY run (null or the token), * so a reused handler instance can never surface a stale mint. Consumed * once by the route handler via take_minted_token(). */ private static $minted_token = null; /** * The capability for a session id. Stateless by construction. * wp_salt() keys it to the install, so a token from one site is * meaningless on another. */ public static function derive_capability(string $session_id): string { return hash_hmac('sha256', $session_id, wp_salt(self::CAP_SALT_KEY)); } /** * Timing-safe comparison. hash_equals(), never === — a variable-time * compare on a secret is the defect this whole control exists to avoid. */ public static function verify_capability(string $session_id, string $token): bool { if ($token === '') { return false; } return hash_equals(self::derive_capability($session_id), $token); } /** Claim marker keyed on the session HASH, never the raw id — the * transient name must carry no visitor identifier (DSAR posture). */ private static function claim_key(string $session_id): string { return self::CLAIM_PREFIX . AnswerRails_Response_Service::session_hash($session_id); } /** * Has this session id already been claimed? * * TWO sources, deliberately (amended during Phase 2 — the transient-only * version had a hole the design's residual statement understated): * * 1. The claim transient — fast, covers a session before anything is * persisted about it. * 2. DURABLE evidence — a chatlog post or a consent receipt for this * session id, via the ONE resolution authority * (Privacy_Tools::resolve_by_session_id). * * Why (2) is load-bearing: with the transient alone, evicting it * mid-conversation does NOT inconvenience the legitimate visitor (their * token still verifies statelessly — that is the whole point of the HMAC) * but DOES make the gate read "unclaimed" for an attacker presenting no * token, so it would mint and hand over the capability for a LIVE * session. Eviction opens the window immediately, not after the idle TTL * as originally recorded. Durable evidence closes that: once a single * turn is logged or consent is recorded, no eviction can un-claim the * session. * * Residual, stated — and NARROWER than first written (corrected * 2026-08-07): `REST_Auth::log_consent_event()` inserts its `answerrails_consent` * post UNCONDITIONALLY. `Logger::logging_mode()` gates only the PII FIELDS * on that receipt (IP/UA/referer), never the insert, because a consent * receipt is a legitimate-interest record. Since this method queries * `answerrails_consent`, a consented session keeps durable claim evidence even when * conversation logging is `off`. * * The transient-only window is therefore: logging off AND the consent gate * not required (so no receipt is ever minted), or the instant before first * contact — which the mint itself covers. NOT "any session with logging * off". ⚠ If consent is ever made optional-by-default, this window widens * and the claim's durability should be revisited. * * Runs ONLY on the bootstrap path (no token presented), so the query * cost is once per session for legitimate traffic, and rate-limited for * anyone else. */ public static function is_session_claimed(string $session_id): bool { if (get_transient(self::claim_key($session_id)) !== false) { return true; } return self::has_persisted_record($session_id); } /** * Durable claim evidence: does ANY record already exist for this raw * session id? * * Deliberately an EXISTENCE check, not DSAR subject resolution. The first * cut of this called Privacy_Tools::resolve_by_session_id() to keep one * home for "what belongs to this session" — but that is a different * judgment (find EVERYTHING about a subject, across all three key * schemes, with idea fan-out), it needs $wpdb, and putting it on the * bootstrap path drags the whole privacy subsystem into every first * contact. A boolean does not need a fan-out. * * Scope is therefore narrow ON PURPOSE — the two raw-id meta schemes on * chatlogs, plus consent receipts. It does NOT consult the session-hash * scheme, because hashed/anonymized siblings are past-tense records and * the live window they would cover is exactly what the claim transient * already holds. * * ⚠ Note the direction of error: a MISS here reads as "unclaimed", which * permits a mint. So this must never be relaxed into a heuristic — it is * an exact-key lookup or nothing, and the transient carries the window * before anything is persisted. */ private static function has_persisted_record(string $session_id): bool { $lookups = array( array('answerrails_chatlog', '_answerrails_session_id'), array('answerrails_chatlog', '_answerrails_legacy_session_id'), array('answerrails_consent', '_answerrails_session_id'), ); foreach ($lookups as $lookup) { $found = get_posts(array( 'post_type' => $lookup[0], 'post_status' => 'any', 'fields' => 'ids', 'numberposts' => 1, 'meta_query' => array( array('key' => $lookup[1], 'value' => $session_id, 'compare' => '='), ), )); if (!empty($found)) { return true; } } return false; } /** TTL tracks the session's own idle lifetime (#74), so the claim never * outlives the thing it protects. */ public static function claim_session(string $session_id): void { $ttl = (int) apply_filters('answerrails_session_idle_ttl', 7200); set_transient(self::claim_key($session_id), 1, $ttl > 0 ? $ttl : 7200); } /** * THE gate. Sited BEFORE rate limiting in both write paths, so a * capability failure is never masked by a 429. (It ran after a nonce * check until #258 D2 removed one; the relative order that matters is * capability-before-rate-limit, and that is unchanged.) * * token presented + verifies -> proceed * token presented + wrong -> 403, FAIL CLOSED * no token + id NOT yet claimed -> claim, derive, proceed, return it * (FIRST WRITER WINS — creation-time * declaration) * no token + id already claimed -> 403, FAIL CLOSED * * D3a: the widget must NOT rotate on this error — it keeps the * transcript, renders red, and offers "New conversation". */ public function enforce_session_capability(\WP_REST_Request $req, string $session_id) { self::$minted_token = null; $headers = $req->get_headers(); $token = (string) ($headers['x_ar_session_token'][0] ?? $headers['x-ar-session-token'][0] ?? ''); if ($token !== '') { if (self::verify_capability($session_id, $token)) { return true; } return new \WP_Error( 'session_capability_invalid', __('This conversation could not be verified. Please start a new conversation.', 'answerrails'), ['status' => 403] ); } if (self::is_session_claimed($session_id)) { return new \WP_Error( 'session_capability_invalid', __('This conversation could not be verified. Please start a new conversation.', 'answerrails'), ['status' => 403] ); } self::claim_session($session_id); self::$minted_token = self::derive_capability($session_id); return true; } /** One-shot read: returns the token minted by the last gate run and * clears it, so it cannot be replayed onto a later response. */ public static function take_minted_token(): ?string { $t = self::$minted_token; self::$minted_token = null; return $t; } // ===================================================================== // #258 D3 — the public routes are anonymous against ANY nonce state. // // Core's `rest_cookie_check_errors()` (hooked `rest_authentication_errors` // @100) adjudicates any PRESENT `X-WP-Nonce` before the route's permission // callback and before any plugin code. The widget no longer sends one // (D1), but a browser or CDN still holding pre-fix JS does — and a stale // one 403s. For the two routes that serve anonymous visitors by design, // that verdict is converted into what the request actually is: a public // request from uid 0. Legacy clients therefore recover the moment this // ships, instead of when their cache drains. // ===================================================================== /** The routes that serve the visitor. EXACT — see the trap below. */ private const PUBLIC_ROUTES = array('/answerrails/v1/chat', '/answerrails/v1/consent'); /** * #258 D3: core's nonce rejection becomes an anonymous public request on * `/chat` and `/consent`. Every other verdict, and every other route, * passes through untouched. * * 🔴 ROUTE-scoped, NEVER namespace-scoped. `/api/reset/{api}` and the four * `/knowledge` routes share `answerrails/v1` and are gated by * `current_user_can('manage_options')` in a permission callback that runs * AFTER this filter. Zeroing the current user for the namespace would * break all five. Pinned by * `PublicPathNonceRemovalTest::test_t4_the_admin_routes_keep_their_verdict_and_their_identity`, * which was written first and watched to fail against a namespace-scoped * predicate. * * @since 3.52.0 (#258 D3) * @param \WP_Error|true|null $result Core's authentication verdict so far. * @return \WP_Error|true|null `true` for a neutralised nonce rejection on a * public route; otherwise the verdict unchanged. */ public static function allow_anonymous_on_public_routes($result) { if (!is_wp_error($result) || 'rest_cookie_invalid_nonce' !== $result->get_error_code()) { return $result; } if (!self::is_public_route_request()) { return $result; } // What core itself does for a nonce-less request: the caller is the // anonymous visitor. Safe here because the public path reads no // current user at all (design F1). wp_set_current_user(0); return true; } /** * Is the CURRENT request one of the two public routes, EXACTLY? * * `rest_authentication_errors` fires before routing, so the route is read * the way core itself finds it — the `rest_route` query var, which core * sets for both permalink shapes before the REST server serves. * * 🔴 Codex P1 (PR #260): the comparison is EQUALITY, never a suffix test. * A foreign plugin may register `foreign/v1` with the route * `/answerrails/v1/chat`; its route then ENDS with ours, and a suffix test * would hand that route's nonce verdict to our neutraliser — a foreign * handler relying on core's authentication verdict would run anonymously. */ private static function is_public_route_request(): bool { return in_array(self::current_rest_route(), self::PUBLIC_ROUTES, true); } /** * The current REST route, exactly as core resolved it. * * ONE source: `$wp->query_vars['rest_route']`. `rest_api_loaded()` early * returns unless that var is non-empty, and it is the same value core * hands to `serve_request()` — so on every genuine REST request, under * BOTH permalink shapes, it is set and it is authoritative. There is no * request-URI fallback: a parallel reading of the raw URI could only ever * disagree with core, and a predicate that disagrees with core about * WHICH route is being served is the whole hazard this method exists to * avoid (#258 A1, gate note on PR #260). * * '' for anything else, which is a NON-match against PUBLIC_ROUTES and so * fail-closed: an unresolvable route neutralises nothing. */ private static function current_rest_route(): string { if (!isset($GLOBALS['wp']->query_vars['rest_route'])) { return ''; } return rtrim((string) $GLOBALS['wp']->query_vars['rest_route'], '/'); } /** * Consent-endpoint rate limit (#120) — its own tight budget, * separate from the /chat limiter. A human ticks a handful of times; * rotation re-consent is one call per rotation. */ public function is_consent_rate_limited(string $session_id): bool { $key = 'answerrails_consent_rl_' . $session_id; $count = get_transient($key); if ($count === false) { set_transient($key, 1, 300); return false; } $max = apply_filters('answerrails_consent_rate_limit', 10); // 10 per 5 minutes if ($count >= $max) { return true; } set_transient($key, $count + 1, 300); return false; } /** * Handle consent endpoint — the ONLY receipt mint (#120 D1/G1). * * Records a consent EVENT (granted/revoked) for the session. The chat * path never mints; a payload-asserted `consented` is not evidence. */ public function handle_consent(\WP_REST_Request $req) { $body = json_decode($req->get_body(), true); if (!is_array($body)) { return AnswerRails_REST_Router::json_error('invalid_json', 'Body must be JSON', 400); } $session_id = sanitize_text_field($body['session_id'] ?? ''); if ($session_id === '') { return AnswerRails_REST_Router::json_error('missing_session', 'Missing session_id', 400); } // #155 D2b: THE format bound. Same authority as /chat — one home. if (!self::is_valid_session_id($session_id)) { return AnswerRails_REST_Router::json_error('invalid_session', 'Malformed session_id', 400); } // #155 D3: same gate, same order — capability before rate limiting. $capability_result = $this->enforce_session_capability($req, $session_id); if (is_wp_error($capability_result)) { $cap_data = $capability_result->get_error_data(); return AnswerRails_REST_Router::json_error( $capability_result->get_error_code(), $capability_result->get_error_message(), (int) ($cap_data['status'] ?? 403) ); } // #161 amendment (#160): take the mint NOW, not at the success // tail — the gate just minted on a bootstrap turn, and the token must // ride even the rate-limited shape below, or the claimed session // fails closed on its next request. A capability refusal above never // mints (pinned), so this reads null on every non-mint turn. $minted = self::take_minted_token(); if ($this->is_consent_rate_limited($session_id)) { return AnswerRails_REST_Router::json_error('rate_limited', 'Too many requests. Please wait before trying again.', 429, $minted !== null ? ['session_token' => $minted] : []); } $v = $body['consented'] ?? false; $consented = ($v === true || $v === 1 || $v === '1' || (is_string($v) && strtolower($v) === 'true')); // Store consent with Core-compatible logging. // // #261 D4: answering success:true without checking the write is the // defect. A refused receipt is REFUSED to the caller — the widget // retries it (D1) and, failing that, tells the visitor (D2) rather // than being told a withdrawal landed that did not. The mint rides // this shape exactly as it rides the 429 above: a claimed bootstrap // session that fails closed on its next request is the #161 bug. if (!$this->set_session_consent($session_id, $consented, $req)) { return AnswerRails_REST_Router::json_error( 'consent_record_failed', 'Your consent choice could not be recorded. Please try again.', 500, $minted !== null ? ['session_token' => $minted] : [] ); } $payload = [ 'success' => true, 'session_id' => $session_id, 'consented' => $consented ]; // #155 D3 / #161 amendment: the capability travels on the MINT turn // only — on every shape that turn takes ($minted taken once, above) — // and only in the API response, never localized into the page (a // token in cached HTML would be the #120 nonce bug with real // consequences). if ($minted !== null) { $payload['session_token'] = $minted; } return AnswerRails_REST_Router::json_ok($payload); } /** * Validate request security, consent, and reCAPTCHA (Core compatible) */ public function validate_request(\WP_REST_Request $req, array $payload, string $session_id) { // 0) #155 D3: session capability — the FIRST gate, and BEFORE the // rate limiter: a capability failure must never be masked by a 429. // (#258 D2 removed the nonce step that used to sit ahead of it; the // capability-before-rate-limit order is the part that is // load-bearing, and it is unchanged.) $capability_result = $this->enforce_session_capability($req, $session_id); if (is_wp_error($capability_result)) { return $capability_result; } // 1) Rate limiting check if ($this->is_rate_limited($session_id)) { return new \WP_Error('rate_limited', 'Too many requests. Please wait before trying again.'); } // 2) reCAPTCHA validation (Core's implementation) $recaptcha_result = $this->check_chat_permissions($req, $payload); if (is_wp_error($recaptcha_result)) { return $recaptcha_result; } // 3) Consent validation $consent_result = $this->validate_consent($payload, $session_id); if (is_wp_error($consent_result)) { return $consent_result; } return true; } /** * Check chat permissions (Core's reCAPTCHA implementation) */ public function check_chat_permissions(\WP_REST_Request $req, array $payload = []) { $recaptcha_enabled = get_option('answerrails_recaptcha_enabled', false); // #51: read the option the settings UI actually saves // (answerrails_recaptcha_secret). The old answerrails_recaptcha_secret_key // name was never written, which kept this check permanently inert. $recaptcha_secret = get_option('answerrails_recaptcha_secret', ''); if (!$recaptcha_enabled || empty($recaptcha_secret)) { return true; // No reCAPTCHA required } // Get reCAPTCHA token from multiple sources $recaptcha_token = ''; // Check payload first if (!empty($payload['recaptcha_token'])) { $recaptcha_token = $payload['recaptcha_token']; } elseif (!empty($payload['recaptcha'])) { $recaptcha_token = $payload['recaptcha']; } // Check request parameter if (empty($recaptcha_token)) { $recaptcha_token = $req->get_param('recaptcha_token'); } // Check headers. WP_REST_Request::get_headers() normalizes header // names to underscores (x_recaptcha_token); accept the hyphen form // too for safety. if (empty($recaptcha_token)) { $headers = $req->get_headers(); $recaptcha_token = $headers['x_recaptcha_token'][0] ?? $headers['x-recaptcha-token'][0] ?? ''; } if (empty($recaptcha_token)) { return new \WP_Error('missing_recaptcha', __('reCAPTCHA verification required.', 'answerrails'), ['status' => 403]); } // Verify with Google (Core's verification logic) $verify_response = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', [ 'body' => [ 'secret' => $recaptcha_secret, 'response' => $recaptcha_token, 'remoteip' => $this->get_client_ip() ], 'timeout' => 15, ]); if (is_wp_error($verify_response)) { // Allow on verification error (Core's behavior) error_log('AnswerRails reCAPTCHA verification error: ' . $verify_response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational failure diagnostic: deliberately loud so provider/API failures reach server logs without WP_DEBUG return true; } $verify_data = json_decode(wp_remote_retrieve_body($verify_response), true); if (empty($verify_data['success'])) { return new \WP_Error('recaptcha_failed', __('reCAPTCHA verification failed.', 'answerrails'), ['status' => 403]); } // #51 D6: reject tokens minted for a different action on the same // site key (defence against token harvesting from other widgets). // The widget executes grecaptcha with action 'answerrails_chat'. if (isset($verify_data['action']) && $verify_data['action'] !== 'answerrails_chat') { return new \WP_Error('recaptcha_failed', __('reCAPTCHA verification failed.', 'answerrails'), ['status' => 403]); } // Check score for reCAPTCHA v3 if (isset($verify_data['score'])) { $min_score = (float) get_option('answerrails_recaptcha_min_score', 0.5); if ($verify_data['score'] < $min_score) { return new \WP_Error('recaptcha_low_score', __('reCAPTCHA score too low.', 'answerrails'), ['status' => 403]); } } return true; } /** * Validate consent requirement — STRICT gate (#120 D1, 2026-08-03). * * Consent state lives SERVER-SIDE ONLY: the latest consent event for * the session (via get_session_consent()). A payload-asserted * `consented: true` neither passes the gate nor mints a receipt — the * old behaviour minted an `answerrails_consent` receipt from any bare client * assertion, making the consent evidence circular (issue finding 1). * The payload field remains accepted in the request contract for * back-compat; it is simply not consulted. */ private function validate_consent(array $payload, string $session_id) { if (!self::consent_required()) { return true; } if (!$this->get_session_consent($session_id)) { return new \WP_Error('consent_required', __('Consent is required before chatting.', 'answerrails')); } return true; } /** * Get session consent status — latest-event-wins (#120). * * Resolution order: * 1. Transient CACHE ('granted'/'revoked' strings; pure cache of the * resolved state, never an independent authority). * 2. The consent-event ledger: newest `answerrails_consent` post for the * session; its `_answerrails_consent_event` meta decides. A * receipt WITHOUT event meta (every pre-#120 receipt) reads as * `granted` — legacy evidence keeps its meaning, nothing is * migrated or rewritten. * 3. No events => no consent. * * ⚠ Chatlog meta is NOT an authority here, and as of #287 / #286 D5 it * is not written at all: `_answerrails_consent` was a cache of this * ledger with one consumer, and the DSAR export now derives that row from * the ledger at read time. Pre-#286 installs still carry the key on old * chatlogs; nothing reads it. Nor is receipt EXISTENCE an authority — a * revoked session FAILS the gate even with zero chatlogs. */ private function get_session_consent(string $session_id): bool { // 1) Cache. $cached = get_transient('answerrails_consent_state_' . $session_id); if ($cached === 'granted') { return true; } if ($cached === 'revoked') { return false; } // 2) Ledger: newest event wins (ID tiebreak for same-second events). $events = get_posts([ 'post_type' => 'answerrails_consent', 'meta_query' => [ [ 'key' => '_answerrails_session_id', 'value' => $session_id, 'compare' => '=' ] ], 'numberposts' => 1, 'orderby' => ['date' => 'DESC', 'ID' => 'DESC'], ]); if (empty($events)) { return false; } $event = get_post_meta($events[0]->ID, '_answerrails_consent_event', true); $granted = ($event !== 'revoked'); // missing/legacy meta => granted set_transient('answerrails_consent_state_' . $session_id, $granted ? 'granted' : 'revoked', 86400); return $granted; } /** * Set session consent status — records a consent EVENT (#120). * * Called ONLY from handle_consent() (the tick surface). Both grant AND * revoke append to the ledger — the revocation itself is evidence, and * the trail is append-only: receipts are never deleted. */ private function set_session_consent(string $session_id, bool $val, ?\WP_REST_Request $req = null): bool { // #261 D4: the cache and the recorded fact never move ahead of the // ledger they cache. If the receipt did not land there is nothing to // cache and nothing to have changed the visitor's mind about, so the // 24 h transient is NOT set and the chatlog meta below is NOT // touched — a cache that says the opposite of the ledger for a day is // worse than no cache at all. $event = $val ? 'granted' : 'revoked'; if (!$this->log_consent_event($session_id, $event, $req)) { error_log('[AnswerRails] consent receipt could not be written for session ' . $session_id . ' (' . $event . ')'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational failure diagnostic: deliberately loud so provider/API failures reach server logs without WP_DEBUG return false; } // Append the event, then cache the resolved state (24h). set_transient('answerrails_consent_state_' . $session_id, $event, 86400); // #287 / #286 D5, Phase 3 cleanup: the `_answerrails_consent` chatlog // write is GONE. It stamped only chatlogs that ALREADY EXISTED, and // on the ordinary path the grant precedes the first chatlog — so the // conversation a visitor actually had was the one case it never // recorded. It also matched `_answerrails_session_id` only, so a // legacy-scheme or anonymized-mode chatlog was never stamped either. // // The DSAR export now derives that row from the receipt ledger at // read time (Privacy_Tools::chatlog_consent_label), which makes the // three-schemes question moot rather than answering it three times. // 📌 A CACHE OF THE LEDGER IS NOT THE LEDGER. return true; } /** * Log a consent EVENT (#120 — was log_consent, grant-only). * * Consent receipts follow the EFFECTIVE logging mode (#109): IP / user * agent / referer are recorded only when the mode is 'identified'. In every * mode the receipt keeps the session id + timestamp: the session id is * the functional key consent lookups use, and together they still * evidence that THIS session consented. Whether identified-mode * consent evidence should be strengthened or trimmed further is on the * #101 solicitor brief (a note that provenance + revocation shipped * rides that rider). * * `granted` events additionally record the sha256 of the disclaimer * text at tick time (#120 D5) — later disputes can show WHAT text was * agreed to. No PII implication; mode-independent. */ private function log_consent_event(string $session_id, string $event, ?\WP_REST_Request $req = null): bool { $meta = [ '_answerrails_session_id' => $session_id, // #286 D3: the session-correlation hash, in EVERY mode, from the // single authority (Response_Service::session_hash) rather than a // second derivation — chatlogs already carry exactly this key. // Without it a receipt is orphaned the moment its chatlog's raw // session id goes: our own anonymise deletes // `_answerrails_session_id` from the chatlog (CHATLOG_DELETE_METAS) // but keeps the hash, so the hash is the only surviving join. // Non-reversible, so it adds no personal data. '_answerrails_session_hash' => AnswerRails_Response_Service::session_hash($session_id), '_answerrails_timestamp' => current_time('mysql'), '_answerrails_consent_event' => $event, ]; if ('granted' === $event) { // #244: hash the EFFECTIVE text, token-substituted exactly as the // widget renders it. The raw option is '' on a default-rendered // disclaimer, so hashing it recorded proof of an empty string // against a visitor who was shown real words. $meta['_answerrails_disclaimer_hash'] = hash( 'sha256', AnswerRails_Core::substitute_disclaimer_tokens(AnswerRails_Core::effective_disclaimer_text()) ); } if ('identified' === AnswerRails_Logger::logging_mode()) { $security_info = $this->get_security_info($req); $meta['_answerrails_ip_address'] = $security_info['ip']; $meta['_answerrails_user_agent'] = $security_info['user_agent']; $meta['_answerrails_referer'] = $security_info['referer']; } // #261 D4: ask for the WP_Error and CHECK it. The return was discarded // here, so a refused insert produced a 200 with no receipt — the same // family as #263 F1's discarded wp_mail() return. A CLAIM ABOUT A // WRITE IS GATED ON THE WRITE'S RETURN VALUE. $post_id = wp_insert_post([ 'post_type' => 'answerrails_consent', 'post_status' => 'publish', 'post_title' => ('granted' === $event ? 'Consent: ' : 'Consent revoked: ') . $session_id, 'meta_input' => $meta, ], true); if (is_wp_error($post_id) || (int) $post_id < 1) { return $this->record_receipt_outcome(false); } // Codex P2 (PR #285), ACCEPTED: a positive post id is NOT evidence that // the receipt is usable. `wp_insert_post()` writes `meta_input` through // update_post_meta() and discards every return, so a postmeta fault or // a filter that blocks the write yields a receipt with no metadata — // and BOTH of the keys below are load-bearing: // // _answerrails_session_id — the functional key. Without it the // receipt is invisible to get_session_consent()'s meta query and to // DSAR resolution: it exists and answers nothing. // _answerrails_consent_event — without it #120 §3.1 reads the receipt // as GRANTED, forever. For a revocation that is precisely this // issue's defect wearing a different cause. // // So the write is verified by READING IT BACK, not inferred from the // insert's return. 📌 A POSITIVE ID IS A CLAIM ABOUT A ROW, NOT ABOUT // THE ROW YOU NEEDED. // // ⚠ Named consequence: the unusable post is LEFT IN PLACE. The ledger // is append-only and receipts are never deleted (#120 D4), and the // remedy is the one D4 already builds — refuse, mark the owner, let the // widget retry, which writes a fresh and complete receipt. foreach (array('_answerrails_session_id', '_answerrails_consent_event') as $required) { if ('' === (string) get_post_meta((int) $post_id, $required, true)) { return $this->record_receipt_outcome(false); } } return $this->record_receipt_outcome(true); } /** * #261 D4: record whether a consent receipt reached the ledger (the #263 * D5 marker shape). * * Returns its argument so every call site stays a one-liner and the * outcome cannot be recorded without also being returned to the caller — * the discarded-return defect this issue exists to fix. * * @param bool $written Whether the receipt insert succeeded. */ private function record_receipt_outcome(bool $written): bool { if ($written) { // Check-then-delete: a successful write on a healthy site must not // pay for an option write on every consent tick. if (get_option(self::CONSENT_LEDGER_FAILURE_OPTION, null) !== null) { delete_option(self::CONSENT_LEDGER_FAILURE_OPTION); } return true; } $marker = get_option(self::CONSENT_LEDGER_FAILURE_OPTION, array()); $count = is_array($marker) ? (int) ($marker['count'] ?? 0) : 0; update_option(self::CONSENT_LEDGER_FAILURE_OPTION, array( 'count' => $count + 1, 'last' => current_time('mysql', true), )); return false; } /** * Get client IP address (enhanced) */ public function get_client_ip(): string { // Single trusted resolver (#52) — see AnswerRails_Utils::get_client_ip(). return sanitize_text_field(AnswerRails_Utils::get_client_ip()); } /** * Check if session has been rate limited */ public function is_rate_limited(string $session_id): bool { $key = 'answerrails_rate_limit_' . $session_id; $count = get_transient($key); if ($count === false) { // First request - set counter set_transient($key, 1, 300); // 5 minute window return false; } $max_requests = apply_filters('answerrails_chat_rate_limit', 30); // 30 requests per 5 minutes if ($count >= $max_requests) { return true; } // Increment counter set_transient($key, $count + 1, 300); return false; } /** * Get security headers for logging (Core compatibility) */ public function get_security_info(?\WP_REST_Request $req = null): array { $info = [ 'ip' => $this->get_client_ip(), 'timestamp' => current_time('mysql', true) ]; if ($req) { $info['user_agent'] = $req->get_header('User-Agent') ?? ''; $info['referer'] = $req->get_header('Referer') ?? ''; } else { $info['user_agent'] = sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'] ?? '')); $info['referer'] = sanitize_text_field(wp_unslash($_SERVER['HTTP_REFERER'] ?? '')); } return $info; } }