$status)); } public static function token(int $bytes = 32): string { return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '='); } public static function hash(string $value): string { return hash('sha256', $value); } /** * Encrypts the reusable fragment secret for the active share link. The * ciphertext is safe to persist with the token row, while the encryption * key is derived from this WordPress installation's authentication salt and * is therefore not stored in the database alongside it. * * @return string|\WP_Error URL-safe ciphertext. */ public static function encrypt_share_link_secret(int $review_id, string $secret): string|\WP_Error { if ($review_id < 1 || !preg_match('/\A[A-Za-z0-9_-]{43}\z/', $secret)) { return self::error('share_link_secret_invalid', __('The share link could not be secured.', 'commentmate'), 503); } if (!function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_encrypt') || !defined('SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES')) { return self::error('share_link_secret_unavailable', __('The share link could not be secured.', 'commentmate'), 503); } $key = self::share_link_secret_key($review_id); if (is_wp_error($key)) { return $key; } try { $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); $ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($secret, self::share_link_secret_context($review_id), $nonce, $key); } catch (\Throwable) { return self::error('share_link_secret_unavailable', __('The share link could not be secured.', 'commentmate'), 503); } return rtrim(strtr(base64_encode($nonce . $ciphertext), '+/', '-_'), '='); } /** * @return string|\WP_Error The original 256-bit URL-fragment secret. */ public static function decrypt_share_link_secret(int $review_id, string $ciphertext): string|\WP_Error { if ($review_id < 1 || !preg_match('/\A[A-Za-z0-9_-]{64,512}\z/', $ciphertext) || !function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_decrypt') || !defined('SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES')) { return self::error('share_link_secret_unavailable', __('The active share link cannot be recovered.', 'commentmate'), 503); } $key = self::share_link_secret_key($review_id); if (is_wp_error($key)) { return $key; } $padding = (4 - (strlen($ciphertext) % 4)) % 4; $encoded = strtr($ciphertext, '-_', '+/') . str_repeat('=', $padding); $payload = base64_decode($encoded, true); $nonce_bytes = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; if (!is_string($payload) || strlen($payload) <= $nonce_bytes) { return self::error('share_link_secret_unavailable', __('The active share link cannot be recovered.', 'commentmate'), 503); } try { $secret = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( substr($payload, $nonce_bytes), self::share_link_secret_context($review_id), substr($payload, 0, $nonce_bytes), $key ); } catch (\Throwable) { $secret = false; } if (!is_string($secret) || !preg_match('/\A[A-Za-z0-9_-]{43}\z/', $secret)) { return self::error('share_link_secret_unavailable', __('The active share link cannot be recovered.', 'commentmate'), 503); } return $secret; } /** * @return string|\WP_Error A 32-byte AEAD key that is not stored in the database. */ private static function share_link_secret_key(int $review_id): string|\WP_Error { if ($review_id < 1 || !function_exists('wp_salt')) { return self::error('share_link_secret_unavailable', __('The active share link cannot be recovered.', 'commentmate'), 503); } $salt = wp_salt('auth'); if (!is_string($salt) || '' === $salt) { return self::error('share_link_secret_unavailable', __('The active share link cannot be recovered.', 'commentmate'), 503); } return hash('sha256', self::share_link_secret_context($review_id) . '|' . $salt, true); } private static function share_link_secret_context(int $review_id): string { return 'commentmate-share-link-v1|' . $review_id; } /** * Derives a stable per-session CSRF token from the HttpOnly session bearer. * The browser cannot derive it itself, but a same-origin bootstrap response * can return it after validating the cookie. Keeping it stable prevents * polling and multiple tabs from invalidating each other's in-flight writes. */ public static function csrf_token(string $session_token): string { return rtrim(strtr(base64_encode(hash_hmac('sha256', 'commentmate-review-csrf-v1', $session_token, true)), '+/', '-_'), '='); } public static function is_https(): bool { return is_ssl(); } /** * The origin comparison deliberately ignores any WordPress subdirectory but * requires an exact scheme, host, and non-default port match. */ public static function home_origin(): string { return self::normalise_origin(home_url('/')); } public static function is_same_origin_request(\WP_REST_Request $request): bool { $origin = trim((string) $request->get_header('origin')); if ('' === $origin) { $origin = trim((string) $request->get_header('referer')); } if ('' !== $origin) { return hash_equals(self::home_origin(), self::normalise_origin($origin)); } // Same-origin browser GETs do not always include Origin. Sec-Fetch-Site is // an additional browser signal; cross-origin scripts cannot read REST data // without CORS permission, which this plugin never grants. $fetch_site = strtolower(trim((string) self::server('HTTP_SEC_FETCH_SITE'))); return 'same-origin' === $fetch_site; } public static function client_fingerprint(): string { $ip = self::client_ip(); $user_agent = substr(trim((string) self::server('HTTP_USER_AGENT')), 0, 512); return $ip . '|' . $user_agent; } public static function client_ip(): string { $ip = trim((string) self::server('REMOTE_ADDR')); return false !== filter_var($ip, FILTER_VALIDATE_IP) ? $ip : 'unknown'; } public static function request_cookie(): string { $value = isset($_COOKIE[self::SESSION_COOKIE]) && is_string($_COOKIE[self::SESSION_COOKIE]) ? wp_unslash($_COOKIE[self::SESSION_COOKIE]) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Unslash the string then enforce the exact credential alphabet and length immediately below. return preg_match('/\A[A-Za-z0-9_-]{40,128}\z/', $value) ? $value : ''; } public static function set_session_cookie(string $value, int $expires): bool { if (headers_sent() || !self::is_https()) { return false; } return setcookie( self::SESSION_COOKIE, $value, array( 'expires' => $expires, 'path' => defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Strict', ) ); } public static function clear_session_cookie(): void { if (headers_sent()) { return; } setcookie( self::SESSION_COOKIE, '', array( 'expires' => time() - HOUR_IN_SECONDS, 'path' => defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/', 'secure' => self::is_https(), 'httponly' => true, 'samesite' => 'Strict', ) ); } public static function send_no_cache_headers(): void { if (headers_sent()) { return; } nocache_headers(); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, private'); header('Pragma: no-cache'); header('Expires: Wed, 11 Jan 1984 05:00:00 GMT'); header('X-Robots-Tag: noindex, nofollow, noarchive', true); } /** * @return bool|\WP_Error */ public static function verify_admin_request(\WP_REST_Request $request): bool|\WP_Error { if (!self::is_https()) { return self::error('https_required', __('CommentMate requires HTTPS.', 'commentmate'), 403); } if (!self::is_same_origin_request($request)) { return self::error('origin_denied', __('This request must originate from this website.', 'commentmate'), 403); } $nonce = (string) $request->get_header('x-wp-nonce'); if ('' === $nonce || !wp_verify_nonce($nonce, 'wp_rest')) { return self::error('admin_nonce_invalid', __('A valid WordPress REST nonce is required.', 'commentmate'), 403); } if (!Owner::can_manage_current_user()) { return self::error('admin_forbidden', __('You do not have permission to manage CommentMate.', 'commentmate'), 403); } return true; } /** * Canonicalises a supplied page URL after enforcing this exact WordPress * origin. Fragments and UI-only review query flags are intentionally removed * before a page record is created. * * @return string|\WP_Error */ public static function canonical_review_url(string $url): string|\WP_Error { $url = trim($url); if ('' === $url || strlen($url) > 4096) { return self::error('invalid_url', __('A valid same-origin page URL is required.', 'commentmate'), 400); } $parts = wp_parse_url($url); if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { return self::error('invalid_url', __('A valid absolute page URL is required.', 'commentmate'), 400); } if (!in_array(strtolower((string) $parts['scheme']), array('https', 'http'), true) || !hash_equals(self::home_origin(), self::normalise_origin($url))) { return self::error('cross_origin_url', __('Comments can only target pages on this website.', 'commentmate'), 400); } $scheme = strtolower((string) $parts['scheme']); $host = strtolower((string) $parts['host']); $port_number = isset($parts['port']) ? (int) $parts['port'] : 0; $port = (0 === $port_number || ('https' === $scheme && 443 === $port_number) || ('http' === $scheme && 80 === $port_number)) ? '' : ':' . $port_number; $path = isset($parts['path']) && '' !== $parts['path'] ? (string) $parts['path'] : '/'; $canonical = $scheme . '://' . $host . $port . $path; $query = self::query_without_review_controls((string) ($parts['query'] ?? '')); if ('' !== $query) { $canonical .= '?' . $query; } return esc_url_raw($canonical); } /** * Mirrors URLSearchParams.delete() without parsing/reserialising the remaining * query string. That preserves `+`, repeated keys, empty values, and * percent-encoding exactly as the client-side anchor URL retains them. */ private static function query_without_review_controls(string $query): string { if ('' === $query) { return ''; } $kept = array(); foreach (explode('&', $query) as $pair) { $name = explode('=', $pair, 2)[0]; $decoded_name = rawurldecode(str_replace('+', ' ', $name)); if (!in_array($decoded_name, array('review', 'commentmate-mode', 'commentmate-focus', 'commentmate-focus-kind'), true)) { $kept[] = $pair; } } return implode('&', $kept); } private static function normalise_origin(string $url): string { $parts = wp_parse_url($url); if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { return ''; } $scheme = strtolower((string) $parts['scheme']); $host = strtolower((string) $parts['host']); $port = isset($parts['port']) ? (int) $parts['port'] : 0; $port_suffix = (0 === $port || ('https' === $scheme && 443 === $port) || ('http' === $scheme && 80 === $port)) ? '' : ':' . $port; return $scheme . '://' . $host . $port_suffix; } private static function server(string $key): string { return isset($_SERVER[$key]) && is_string($_SERVER[$key]) ? wp_unslash($_SERVER[$key]) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Raw string transport metadata is unslashed here; each caller validates IP, origin, method or bounded fingerprint semantics without altering signed inputs. } }