From 12d9e4c7fa71f893c081802e518177083f994066 Mon Sep 17 00:00:00 2001 From: svedge Date: Tue, 27 Oct 2026 09:00:29 +0500 Subject: [PATCH 1/4] Upd. Firewall. Improve fw check. --- inc/spbc-firewall.php | 24 +- .../SpbctWP/Firewall/FirewallBypass.php | 336 ++++++++++++++++++ security-malware-firewall.php | 5 + .../Firewall/FirewallBypassJwtTest.php | 300 ++++++++++++++++ 4 files changed, 644 insertions(+), 21 deletions(-) create mode 100644 tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php diff --git a/inc/spbc-firewall.php b/inc/spbc-firewall.php index d43c6a6cf..e020d7c8e 100644 --- a/inc/spbc-firewall.php +++ b/inc/spbc-firewall.php @@ -12,7 +12,6 @@ use CleantalkSP\SpbctWP\Firewall\WAF; use CleantalkSP\SpbctWP\Firewall\WafBlocker; use CleantalkSP\SpbctWP\Firewall\UploadChecker; -use CleantalkSP\SpbctWP\Helpers\IP; use CleantalkSP\SpbctWP\Variables\Cookie; // Prevent direct call @@ -249,26 +248,9 @@ function spbc_firewall_skip_check() return true; } - // By cookie - if ( ! empty($_GET['access']) ) { - $apbct_settings = get_option('cleantalk_settings'); - $apbct_key = ! empty($apbct_settings['apikey']) ? $apbct_settings['apikey'] : false; - if ( ( $_GET['access'] === $spbc->settings['spbc_key'] || ( $apbct_key !== false && $_GET['access'] === $apbct_key ) ) ) { - Cookie::set('spbc_firewall_pass_key', md5($_SERVER['REMOTE_ADDR'] . $spbc->settings['spbc_key']), time() + 1200, '/'); - Cookie::set('ct_sfw_pass_key', md5($_SERVER['REMOTE_ADDR'] . $apbct_key), time() + 1200, '/'); - - return true; - } - } - - // Legacy bypass: keep the old IP/API-key cookie mechanism for the transition period. - $ip_set = IP::get(); - $ip_set = empty($ip_set) ? array() : $ip_set; - $ip_set = is_array($ip_set) ? $ip_set : array($ip_set); - foreach ( $ip_set as $spbc_cur_ip ) { - if ( Cookie::getString('spbc_firewall_pass_key') === md5($spbc_cur_ip . $spbc->settings['spbc_key']) ) { - return true; - } + // Emergency bypass: let the signed token from the control panel reach its admin-post.php handler. + if ( Firewall\FirewallBypass::isJwtBypassRequest() ) { + return true; } // Turn off the SpamFireWall if Remote Call is in progress diff --git a/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php index 1c3445c04..95eccbdcb 100644 --- a/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php +++ b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php @@ -3,7 +3,9 @@ namespace CleantalkSP\SpbctWP\Firewall; use CleantalkSP\SpbctWP\Escape; +use CleantalkSP\SpbctWP\Helpers\IP; use CleantalkSP\Variables\Cookie; +use CleantalkSP\Variables\Post; use CleantalkSP\Variables\Request; use CleantalkSP\Variables\Server; @@ -17,6 +19,10 @@ * issued to the browser as an HttpOnly cookie, while only its SHA-256 hash is kept on the server. * 3. Every following request is matched by the cookie secret. The firewall is skipped on match. * + * Alternative entry point instead of steps 1-2: the control panel posts a signed token (JWT HS256) + * to admin-post.php with the `ct_fw_bypass` action. The token is signed with the access key and bound + * to the site, the visitor IP and a short lifetime. A valid token grants the same cookie secret as the link. + * * The bypass is bound to a secret known only to the browser that opened the link. * It is intentionally NOT bound to IP/User-Agent because both of them are spoofable. */ @@ -64,6 +70,46 @@ class FirewallBypass */ private const USER_COOKIE_NAME = self::PLUGIN_PREFIX . 'fw_bypass'; + /** + * The admin-post.php action the control panel posts the signed token to. Shared by both plugins. + */ + public const JWT_ACTION = 'ct_fw_bypass'; + + /** + * POST parameter carrying the signed token. + */ + private const JWT_REQUEST_PARAM = 'ct_fw_bypass_jwt'; + + /** + * Service the token must be issued for to be processed by this plugin. + */ + private const JWT_SERVICE = 'security'; + + /** + * Services of both plugins. A token for the other one is left to its own handler. + */ + private const JWT_KNOWN_SERVICES = array('antispam', 'security'); + + /** + * Longer values are rejected before any decoding. + */ + private const JWT_MAX_LENGTH = 2048; + + /** + * Maximal lifetime of the token in seconds. + */ + private const JWT_LIFETIME = 300; + + /** + * Allowed clock difference between the control panel and the site in seconds. + */ + private const JWT_CLOCK_SKEW = 60; + + /** + * Prefix of the transients marking the used token IDs (jti). Makes every token single use. + */ + private const JWT_USED_ID_TRANSIENT_PREFIX = self::PLUGIN_PREFIX . 'fw_bypass_jti_'; + /** * Entry point of the `send_fw_bypass_email` remote call. * @@ -170,6 +216,296 @@ public static function maybeSetUserToken() } } + /** + * Checks whether the current request posts the signed token to admin-post.php. + * + * The firewall runs on `init`, before the `admin_post_*` handlers, so such request must skip it + * to reach the handler. It is safe: without a valid token the handler grants nothing. + * + * @return bool + */ + public static function isJwtBypassRequest() + { + global $pagenow; + + return Server::getString('REQUEST_METHOD') === 'POST' + && $pagenow === 'admin-post.php' + && Post::getString('action') === self::JWT_ACTION; + } + + /** + * Handler of the `admin_post(_nopriv)_ct_fw_bypass` actions: the control panel posts the signed token here. + * + * @return void + */ + public static function handleJwtRequest() + { + $redirect_url = self::processJwtRequest(); + + // The token is issued for the other plugin, its handler is next on the same action. + if ( $redirect_url === null ) { + return; + } + + // 303 makes the browser follow with GET, so a page reload does not resubmit the form. + wp_safe_redirect($redirect_url, 303); + exit; + } + + /** + * Validates the signed token from the request and grants the bypass to the current browser on success. + * + * @return string|null URL to send the browser to: the admin area on success, the home page on failure. + * Null when the token is issued for the other plugin. + */ + public static function processJwtRequest() + { + global $spbc; + + self::$last_error = null; + $failure_url = home_url('/'); + + try { + $jwt = Post::getString(self::JWT_REQUEST_PARAM); + if ( $jwt === '' || strlen($jwt) > self::JWT_MAX_LENGTH ) { + throw new \Exception('The token is missing or too long.'); + } + + $segments = explode('.', $jwt); + if ( count($segments) !== 3 ) { + throw new \Exception('The token is malformed.'); + } + list($header_segment, $payload_segment, $signature_segment) = $segments; + + $payload = self::decodeJwtSegment($payload_segment); + if ( + ! is_array($payload) || + ! isset($payload['svc']) || + ! in_array($payload['svc'], self::JWT_KNOWN_SERVICES, true) + ) { + throw new \Exception('The token service is unknown.'); + } + + // Routing only, nothing is granted before the signature is verified below. + // Checking the signature first would reject the other plugin's token, it is signed with another key. + if ( $payload['svc'] !== self::JWT_SERVICE ) { + return null; + } + + $key = is_string($spbc->api_key) ? $spbc->api_key : ''; + self::verifyJwtSignature($header_segment, $payload_segment, $signature_segment, $key); + $claims = self::validateJwtPayload($payload); + + // Burn the token before the bypass is granted, so a parallel request can not reuse it. + self::markJwtIdUsed($claims['jti'], $claims['exp']); + + $user_secret = self::generateToken(); + if ( ! is_string($user_secret) ) { + throw new \Exception('Failed to generate bypass user secret.'); + } + + self::setUserToken($user_secret); + + // The secret is stored and visible to the current request, otherwise the admin area stays blocked. + if ( ! self::bypassByUserToken() ) { + return $failure_url; + } + + self::logBypassActivation(); + + return admin_url(); + } catch (\Exception $e) { + self::$last_error = $e->getMessage(); + return $failure_url; + } + } + + /** + * Checks the token header and the HMAC-SHA256 signature. + * + * @param string $header_segment + * @param string $payload_segment + * @param string $signature_segment + * @param string $key Access key the token is signed with. + * + * @return void + * @throws \Exception + */ + private static function verifyJwtSignature($header_segment, $payload_segment, $signature_segment, $key) + { + if ( $key === '' ) { + throw new \Exception('The access key is not set.'); + } + + // Strict header: any other algorithm, including "none", is rejected. + $header = self::decodeJwtSegment($header_segment); + if ( ! isset($header['alg'], $header['typ']) || $header['alg'] !== 'HS256' || $header['typ'] !== 'JWT' ) { + throw new \Exception('The token header is not supported.'); + } + + $expected_signature = self::encodeJwtSegment( + hash_hmac('sha256', $header_segment . '.' . $payload_segment, $key, true) + ); + + // Constant time comparison to exclude any timing side channel. + if ( ! hash_equals($expected_signature, $signature_segment) ) { + throw new \Exception('The token signature is invalid.'); + } + } + + /** + * Checks the claims of the token with the verified signature. + * + * @param array $payload + * + * @return array{jti: string, exp: int} The claims needed to burn the token. + * @throws \Exception + */ + private static function validateJwtPayload($payload) + { + $now = time(); + $expiration = isset($payload['exp']) && is_int($payload['exp']) ? $payload['exp'] : 0; + if ( + $expiration < $now - self::JWT_CLOCK_SKEW || + // There is no issue time in the token, so the lifetime is limited by the expiration itself. + $expiration > $now + self::JWT_LIFETIME + self::JWT_CLOCK_SKEW + ) { + throw new \Exception('The token is expired or its lifetime is too long.'); + } + + // One key may serve several sites, the token must not work on another one. + $site_host = parse_url(home_url(), PHP_URL_HOST); + if ( + ! isset($payload['aud']) || + ! is_string($payload['aud']) || + ! is_string($site_host) || + $site_host === '' || + self::normalizeJwtHost($payload['aud']) !== self::normalizeJwtHost($site_host) + ) { + throw new \Exception('The token is issued for another site.'); + } + + // The same IP the firewall checks, including the header selected in the settings. + $request_ip = IP::get(); + $request_ip = is_string($request_ip) ? self::normalizeJwtIp($request_ip) : ''; + if ( + ! isset($payload['ip']) || + ! is_string($payload['ip']) || + $request_ip === '' || + self::normalizeJwtIp($payload['ip']) !== $request_ip + ) { + throw new \Exception('The token is issued for another IP.'); + } + + $jti = isset($payload['jti']) && is_string($payload['jti']) ? $payload['jti'] : ''; + if ( ! preg_match('/^[a-f0-9]{32}$/', $jti) ) { + throw new \Exception('The token ID is malformed.'); + } + + if ( get_transient(self::getJwtUsedIdTransientKey($jti)) !== false ) { + throw new \Exception('The token has already been used.'); + } + + return array('jti' => $jti, 'exp' => $expiration); + } + + /** + * Marks the token ID as used until the token expires. + * + * @param string $jti + * @param int $expiration Token expiration timestamp. + * + * @return void + */ + private static function markJwtIdUsed($jti, $expiration) + { + set_transient( + self::getJwtUsedIdTransientKey($jti), + 1, + max(1, $expiration - time() + self::JWT_CLOCK_SKEW) + ); + } + + /** + * @param string $jti + * + * @return string + */ + private static function getJwtUsedIdTransientKey($jti) + { + return self::JWT_USED_ID_TRANSIENT_PREFIX . hash('sha256', $jti); + } + + /** + * Decodes a base64url encoded JSON segment of the token. + * + * @param string $segment + * + * @return array|null Null if the segment is not a valid base64url JSON object. + */ + private static function decodeJwtSegment($segment) + { + if ( ! preg_match('/^[A-Za-z0-9_-]+$/', $segment) ) { + return null; + } + + $remainder = strlen($segment) % 4; + if ( $remainder ) { + $segment .= str_repeat('=', 4 - $remainder); + } + + $json = base64_decode(strtr($segment, '-_', '+/'), true); + if ( $json === false ) { + return null; + } + + $data = json_decode($json, true); + + return is_array($data) ? $data : null; + } + + /** + * @param string $data Raw binary data. + * + * @return string Base64url without padding. + */ + private static function encodeJwtSegment($data) + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * Brings the host to the form the control panel puts into the token: lower case, without "www.". + * + * @param string $host + * + * @return string + */ + private static function normalizeJwtHost($host) + { + $host = strtolower(trim($host)); + + return strpos($host, 'www.') === 0 ? substr($host, 4) : $host; + } + + /** + * Brings the IP to the comparable form: IPv6 is expanded to the full notation. + * + * @param string $ip + * + * @return string Empty string for an invalid IP. + */ + private static function normalizeJwtIp($ip) + { + $ip_version = IP::validate($ip); + if ( $ip_version === 'v6' ) { + $normalized = IP::normalizeIPv6($ip); + return is_string($normalized) ? $normalized : ''; + } + + return $ip_version === 'v4' ? $ip : ''; + } + /** * Grants the bypass: stores the hash of the secret on the server and gives the secret to the browser. * diff --git a/security-malware-firewall.php b/security-malware-firewall.php index 1cbcee25b..653ee7b8c 100644 --- a/security-malware-firewall.php +++ b/security-malware-firewall.php @@ -18,6 +18,7 @@ use CleantalkSP\SpbctWP\FSWatcher\Controller as FSWatcherController; use CleantalkSP\SpbctWP\DB; use CleantalkSP\SpbctWP\Firewall\BFP; +use CleantalkSP\SpbctWP\Firewall\FirewallBypass; use CleantalkSP\SpbctWP\Firewall\FW; use CleantalkSP\SpbctWP\Cron as SpbcCron; use CleantalkSP\SpbctWP\HTTP\CDNHeadersChecker; @@ -246,6 +247,10 @@ function __spbc($text) } }); +// Emergency firewall bypass: the control panel posts a signed token to admin-post.php. +add_action('admin_post_nopriv_' . FirewallBypass::JWT_ACTION, array(FirewallBypass::class, 'handleJwtRequest')); +add_action('admin_post_' . FirewallBypass::JWT_ACTION, array(FirewallBypass::class, 'handleJwtRequest')); + //First start // Do recheck the settings key if ( !$spbc->key_is_ok || !empty($spbc->errors['apikey']) ) { diff --git a/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php b/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php new file mode 100644 index 000000000..dc33dc67e --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php @@ -0,0 +1,300 @@ +wpdb = $wpdb; + + $constants = (new ReflectionClass(FirewallBypass::class))->getConstants(); + $this->user_token_key = $constants['USER_TOKEN_TRANSIENT_KEY']; + $this->cookie_name = $constants['USER_COOKIE_NAME']; + + // The security log is used to audit the activation. + $db_tables_creator = new \CleantalkSP\SpbctWP\DB\TablesCreator(); + $db_tables_creator->createTable(SPBC_TBL_SECURITY_LOG); + $this->wpdb->query('DELETE FROM ' . SPBC_TBL_SECURITY_LOG); + + // Enable the security log feature restriction. + $spbc->moderate = 1; + $spbc->key_is_ok = 1; + + $this->original_api_key = $spbc->api_key; + $spbc->api_key = self::KEY; + + $this->resetEnvironment(); + } + + protected function tearDown(): void + { + global $spbc, $pagenow; + + $spbc->api_key = $this->original_api_key; + $pagenow = 'index.php'; + + $this->resetEnvironment(); + unset($_POST['action']); + FirewallBypass::$last_error = null; + + parent::tearDown(); + } + + /** + * @return void + */ + private function resetEnvironment() + { + delete_transient($this->user_token_key); + unset($_POST['ct_fw_bypass_jwt'], $_COOKIE[$this->cookie_name]); + $_SERVER['REMOTE_ADDR'] = self::VISITOR_IP; + $this->resetRequestCache(); + } + + /** + * The variable handlers and the IP helper cache the values, drop the cache between the request emulations. + * + * @return void + */ + private function resetRequestCache() + { + Post::getInstance()->variables = array(); + Server::getInstance()->variables = array(); + Cookie::getInstance()->variables = array(); + IP::getInstance()->ips_stored = array(); + } + + /** + * @param array $payload_overrides Null value removes the claim. + * @param string $key + * @param array $header + * + * @return string + */ + private function makeToken($payload_overrides = array(), $key = self::KEY, $header = array('alg' => 'HS256', 'typ' => 'JWT')) + { + $payload = array_merge( + array( + 'svc' => 'security', + 'aud' => parse_url(home_url(), PHP_URL_HOST), + 'ip' => self::VISITOR_IP, + 'exp' => time() + 300, + 'jti' => bin2hex(random_bytes(16)), + ), + $payload_overrides + ); + $payload = array_filter($payload, function ($value) { + return $value !== null; + }); + + $signing_input = $this->encode(json_encode($header)) . '.' . $this->encode(json_encode($payload)); + + return $signing_input . '.' . $this->encode(hash_hmac('sha256', $signing_input, $key, true)); + } + + /** + * @param string $data + * + * @return string + */ + private function encode($data) + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * @param string $token + * + * @return string|null + */ + private function postToken($token) + { + $this->resetRequestCache(); + $_POST['ct_fw_bypass_jwt'] = $token; + + return FirewallBypass::processJwtRequest(); + } + + /** + * @param string|null $result + * @param string $expected_error + * + * @return void + */ + private function assertRejected($result, $expected_error) + { + $this->assertSame(home_url('/'), $result); + $this->assertSame($expected_error, FirewallBypass::$last_error); + $this->assertFalse(get_transient($this->user_token_key), 'The bypass must not be granted.'); + } + + /** + * @param string $method + * @param string $page + * @param string $action + * + * @return void + */ + private function emulateAdminPostRequest($method, $page, $action) + { + global $pagenow; + + $pagenow = $page; + $_SERVER['REQUEST_METHOD'] = $method; + $_POST['action'] = $action; + $this->resetRequestCache(); + } + + public function testValidTokenGrantsBypass() + { + $result = $this->postToken($this->makeToken()); + + $this->assertSame(admin_url(), $result, (string)FirewallBypass::$last_error); + $this->assertTrue(FirewallBypass::bypassByUserToken()); + + $logged_events = $this->wpdb->get_col('SELECT event FROM ' . SPBC_TBL_SECURITY_LOG); + $this->assertContains('fw_bypass', $logged_events); + } + + public function testTokenIsSingleUse() + { + $token = $this->makeToken(); + $this->postToken($token); + delete_transient($this->user_token_key); + + $this->assertRejected($this->postToken($token), 'The token has already been used.'); + } + + public function testTokenForOtherPluginIsSkipped() + { + // Signed with another key: the signature must not even be checked. + $result = $this->postToken($this->makeToken(array('svc' => 'antispam'), 'other_plugin_key')); + + $this->assertNull($result); + $this->assertNull(FirewallBypass::$last_error); + $this->assertFalse(get_transient($this->user_token_key)); + } + + public function testUnknownServiceIsRejected() + { + $this->assertRejected($this->postToken($this->makeToken(array('svc' => 'other'))), 'The token service is unknown.'); + $this->assertRejected($this->postToken($this->makeToken(array('svc' => null))), 'The token service is unknown.'); + } + + public function testWrongSignatureIsRejected() + { + $this->assertRejected($this->postToken($this->makeToken(array(), 'wrong_key')), 'The token signature is invalid.'); + } + + public function testUnsupportedHeaderIsRejected() + { + $this->assertRejected( + $this->postToken($this->makeToken(array(), self::KEY, array('alg' => 'none', 'typ' => 'JWT'))), + 'The token header is not supported.' + ); + } + + public function testExpirationIsChecked() + { + $this->assertRejected( + $this->postToken($this->makeToken(array('exp' => time() - 61))), + 'The token is expired or its lifetime is too long.' + ); + $this->assertRejected( + $this->postToken($this->makeToken(array('exp' => time() + 361))), + 'The token is expired or its lifetime is too long.' + ); + } + + public function testSiteIsChecked() + { + $this->assertRejected( + $this->postToken($this->makeToken(array('aud' => 'another-site.com'))), + 'The token is issued for another site.' + ); + + $aud = 'WWW.' . strtoupper(parse_url(home_url(), PHP_URL_HOST)); + $this->assertSame(admin_url(), $this->postToken($this->makeToken(array('aud' => $aud)))); + } + + public function testIpIsChecked() + { + $this->assertRejected( + $this->postToken($this->makeToken(array('ip' => '203.0.113.11'))), + 'The token is issued for another IP.' + ); + } + + public function testIpv6IsComparedNormalized() + { + $_SERVER['REMOTE_ADDR'] = '2001:db8::1'; + + $this->assertSame(admin_url(), $this->postToken($this->makeToken(array('ip' => '2001:db8:0:0:0:0:0:1')))); + } + + public function testMalformedTokenIsRejected() + { + $this->assertRejected($this->postToken(''), 'The token is missing or too long.'); + $this->assertRejected($this->postToken(str_repeat('a', 2049)), 'The token is missing or too long.'); + $this->assertRejected($this->postToken('a.b'), 'The token is malformed.'); + $this->assertRejected( + $this->postToken($this->makeToken(array('jti' => 'not-a-hex-id'))), + 'The token ID is malformed.' + ); + } + + public function testJwtRequestIsRecognized() + { + $this->emulateAdminPostRequest('POST', 'admin-post.php', 'ct_fw_bypass'); + $this->assertTrue(FirewallBypass::isJwtBypassRequest()); + + $this->emulateAdminPostRequest('GET', 'admin-post.php', 'ct_fw_bypass'); + $this->assertFalse(FirewallBypass::isJwtBypassRequest()); + + $this->emulateAdminPostRequest('POST', 'admin-post.php', 'other_action'); + $this->assertFalse(FirewallBypass::isJwtBypassRequest()); + + $this->emulateAdminPostRequest('POST', 'index.php', 'ct_fw_bypass'); + $this->assertFalse(FirewallBypass::isJwtBypassRequest()); + } +} From 0abe0ccf48a5d5a5968128648411e67443af955a Mon Sep 17 00:00:00 2001 From: svedge Date: Tue, 27 Oct 2026 09:06:22 +0500 Subject: [PATCH 2/4] Dev. Firewall. Fix psalm. --- lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php index 95eccbdcb..c0f6f817f 100644 --- a/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php +++ b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php @@ -483,7 +483,7 @@ private static function encodeJwtSegment($data) */ private static function normalizeJwtHost($host) { - $host = strtolower(trim($host)); + $host = strtolower(trim($host, " \n\r\t\v\x00")); return strpos($host, 'www.') === 0 ? substr($host, 4) : $host; } From 3baa2f7b7af142bee07c0caca0d92ae62789e61b Mon Sep 17 00:00:00 2001 From: svedge Date: Tue, 27 Oct 2026 09:44:21 +0500 Subject: [PATCH 3/4] Dev. Firewall. Fix cp. --- .../SpbctWP/Firewall/FirewallBypass.php | 87 ++++++++++++------- .../Firewall/FirewallBypassJwtTest.php | 46 +++++++++- 2 files changed, 99 insertions(+), 34 deletions(-) diff --git a/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php index c0f6f817f..87af41eaa 100644 --- a/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php +++ b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php @@ -106,9 +106,10 @@ class FirewallBypass private const JWT_CLOCK_SKEW = 60; /** - * Prefix of the transients marking the used token IDs (jti). Makes every token single use. + * Prefix of the options claiming the used token IDs (jti). Makes every token single use. + * The value is the timestamp the claim may be dropped after. */ - private const JWT_USED_ID_TRANSIENT_PREFIX = self::PLUGIN_PREFIX . 'fw_bypass_jti_'; + private const JWT_USED_ID_OPTION_PREFIX = self::PLUGIN_PREFIX . 'fw_bypass_jti_'; /** * Entry point of the `send_fw_bypass_email` remote call. @@ -297,7 +298,7 @@ public static function processJwtRequest() $claims = self::validateJwtPayload($payload); // Burn the token before the bypass is granted, so a parallel request can not reuse it. - self::markJwtIdUsed($claims['jti'], $claims['exp']); + self::claimJwtId($claims['jti'], $claims['exp']); $user_secret = self::generateToken(); if ( ! is_string($user_secret) ) { @@ -387,12 +388,12 @@ private static function validateJwtPayload($payload) // The same IP the firewall checks, including the header selected in the settings. $request_ip = IP::get(); - $request_ip = is_string($request_ip) ? self::normalizeJwtIp($request_ip) : ''; + $request_ip = is_string($request_ip) ? self::canonicalizeJwtIp($request_ip) : ''; if ( ! isset($payload['ip']) || ! is_string($payload['ip']) || $request_ip === '' || - self::normalizeJwtIp($payload['ip']) !== $request_ip + self::canonicalizeJwtIp($payload['ip']) !== $request_ip ) { throw new \Exception('The token is issued for another IP.'); } @@ -402,38 +403,50 @@ private static function validateJwtPayload($payload) throw new \Exception('The token ID is malformed.'); } - if ( get_transient(self::getJwtUsedIdTransientKey($jti)) !== false ) { - throw new \Exception('The token has already been used.'); - } - return array('jti' => $jti, 'exp' => $expiration); } /** - * Marks the token ID as used until the token expires. + * Claims the token ID, so the token can be used only once. + * + * The claim is a single INSERT IGNORE on the unique option name: of several parallel requests + * with the same token exactly one inserts the row. Transients can not do this, a check and a write + * there are separate queries. Any storage failure rejects the token. * * @param string $jti * @param int $expiration Token expiration timestamp. * * @return void + * @throws \Exception */ - private static function markJwtIdUsed($jti, $expiration) + private static function claimJwtId($jti, $expiration) { - set_transient( - self::getJwtUsedIdTransientKey($jti), - 1, - max(1, $expiration - time() + self::JWT_CLOCK_SKEW) + global $wpdb; + + // The expired claims are useless, drop them to keep the table clean. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST(option_value AS UNSIGNED) < %d", + $wpdb->esc_like(self::JWT_USED_ID_OPTION_PREFIX) . '%', + time() + ) ); - } - /** - * @param string $jti - * - * @return string - */ - private static function getJwtUsedIdTransientKey($jti) - { - return self::JWT_USED_ID_TRANSIENT_PREFIX . hash('sha256', $jti); + $claimed = $wpdb->query( + $wpdb->prepare( + "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + self::JWT_USED_ID_OPTION_PREFIX . hash('sha256', $jti), + (string)($expiration + self::JWT_CLOCK_SKEW) + ) + ); + + if ( $claimed === false ) { + throw new \Exception('Failed to claim the token ID.'); + } + + if ( $claimed !== 1 ) { + throw new \Exception('The token has already been used.'); + } } /** @@ -489,21 +502,33 @@ private static function normalizeJwtHost($host) } /** - * Brings the IP to the comparable form: IPv6 is expanded to the full notation. + * Brings the IP to the canonical comparable form: the packed binary address in hex. + * + * Equivalent notations ("2001:DB8::1", "2001:0db8:0:0:0:0:0:1") give the same result. + * An IPv4-mapped IPv6 address ("::ffff:203.0.113.10") is the same client as the plain IPv4 one. * * @param string $ip * * @return string Empty string for an invalid IP. */ - private static function normalizeJwtIp($ip) + private static function canonicalizeJwtIp($ip) { - $ip_version = IP::validate($ip); - if ( $ip_version === 'v6' ) { - $normalized = IP::normalizeIPv6($ip); - return is_string($normalized) ? $normalized : ''; + $ip = trim($ip, " \n\r\t\v\x00"); + if ( filter_var($ip, FILTER_VALIDATE_IP) === false ) { + return ''; + } + + $packed = inet_pton($ip); + if ( $packed === false ) { + return ''; + } + + $ipv4_mapped_prefix = str_repeat("\x00", 10) . "\xff\xff"; + if ( strlen($packed) === 16 && strpos($packed, $ipv4_mapped_prefix) === 0 ) { + $packed = substr($packed, 12); } - return $ip_version === 'v4' ? $ip : ''; + return bin2hex($packed); } /** diff --git a/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php b/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php index dc33dc67e..66a14c29f 100644 --- a/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php +++ b/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php @@ -86,6 +86,7 @@ protected function tearDown(): void private function resetEnvironment() { delete_transient($this->user_token_key); + $this->wpdb->query("DELETE FROM {$this->wpdb->options} WHERE option_name LIKE 'spbc\\_fw\\_bypass\\_jti\\_%'"); unset($_POST['ct_fw_bypass_jwt'], $_COOKIE[$this->cookie_name]); $_SERVER['REMOTE_ADDR'] = self::VISITOR_IP; $this->resetRequestCache(); @@ -205,6 +206,32 @@ public function testTokenIsSingleUse() $this->assertRejected($this->postToken($token), 'The token has already been used.'); } + public function testClaimedTokenIdIsRejected() + { + $jti = bin2hex(random_bytes(16)); + $this->wpdb->insert( + $this->wpdb->options, + array('option_name' => 'spbc_fw_bypass_jti_' . hash('sha256', $jti), 'option_value' => (string)(time() + 300), 'autoload' => 'no') + ); + + $this->assertRejected($this->postToken($this->makeToken(array('jti' => $jti))), 'The token has already been used.'); + } + + public function testExpiredClaimsAreDropped() + { + $expired_claim = 'spbc_fw_bypass_jti_' . hash('sha256', 'expired'); + $this->wpdb->insert( + $this->wpdb->options, + array('option_name' => $expired_claim, 'option_value' => (string)(time() - 1), 'autoload' => 'no') + ); + + $this->assertSame(admin_url(), $this->postToken($this->makeToken())); + + $claims = $this->wpdb->get_col("SELECT option_name FROM {$this->wpdb->options} WHERE option_name LIKE 'spbc\\_fw\\_bypass\\_jti\\_%'"); + $this->assertNotContains($expired_claim, $claims); + $this->assertCount(1, $claims, 'Only the claim of the just used token must stay.'); + } + public function testTokenForOtherPluginIsSkipped() { // Signed with another key: the signature must not even be checked. @@ -265,11 +292,24 @@ public function testIpIsChecked() ); } - public function testIpv6IsComparedNormalized() + /** + * @dataProvider equivalentIpProvider + */ + public function testEquivalentIpNotationsMatch($request_ip, $token_ip) { - $_SERVER['REMOTE_ADDR'] = '2001:db8::1'; + $_SERVER['REMOTE_ADDR'] = $request_ip; - $this->assertSame(admin_url(), $this->postToken($this->makeToken(array('ip' => '2001:db8:0:0:0:0:0:1')))); + $this->assertSame(admin_url(), $this->postToken($this->makeToken(array('ip' => $token_ip))), (string)FirewallBypass::$last_error); + } + + public function equivalentIpProvider() + { + return array( + 'expanded IPv6' => array('2001:db8::1', '2001:0db8:0000:0000:0000:0000:0000:0001'), + 'upper case IPv6' => array('2001:db8::1', '2001:0DB8::1'), + 'compressed token' => array('2001:0DB8:0:0:0:0:0:1', '2001:db8::1'), + 'IPv4-mapped token' => array(self::VISITOR_IP, '::ffff:' . self::VISITOR_IP), + ); } public function testMalformedTokenIsRejected() From f5f2a5d0a093b99c1f2f00207ff08d4f95e87f08 Mon Sep 17 00:00:00 2001 From: svedge Date: Tue, 27 Oct 2026 10:05:26 +0500 Subject: [PATCH 4/4] Dev. Firewall. Fix cp. --- .../SpbctWP/Firewall/FirewallBypass.php | 41 +++++++++++++++---- .../Firewall/FirewallBypassJwtTest.php | 25 +++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php index 87af41eaa..2d4469959 100644 --- a/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php +++ b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php @@ -375,13 +375,12 @@ private static function validateJwtPayload($payload) } // One key may serve several sites, the token must not work on another one. - $site_host = parse_url(home_url(), PHP_URL_HOST); + $site_audience = self::getSiteJwtAudience(); if ( ! isset($payload['aud']) || ! is_string($payload['aud']) || - ! is_string($site_host) || - $site_host === '' || - self::normalizeJwtHost($payload['aud']) !== self::normalizeJwtHost($site_host) + $site_audience === '' || + self::normalizeJwtAudience($payload['aud']) !== $site_audience ) { throw new \Exception('The token is issued for another site.'); } @@ -488,17 +487,41 @@ private static function encodeJwtSegment($data) } /** - * Brings the host to the form the control panel puts into the token: lower case, without "www.". + * The audience the token must be issued for: the host with the path of the site home URL. * - * @param string $host + * The path matters: subsites of a subdirectory multisite share the host and the access key, + * so a token for "example.com/site-a" must not work on "example.com/site-b". + * A site in the root of the domain has no path, its audience is the host only. + * + * @return string Empty string if the home URL has no host. + */ + private static function getSiteJwtAudience() + { + $home_url = home_url(); + $host = parse_url($home_url, PHP_URL_HOST); + $path = parse_url($home_url, PHP_URL_PATH); + + if ( ! is_string($host) || $host === '' ) { + return ''; + } + + return self::normalizeJwtAudience($host . (is_string($path) ? $path : '')); + } + + /** + * Brings the audience to the form the control panel puts into the token: + * lower case, without "www." and the trailing slash, e.g. "example.com" or "example.com/site-a". + * + * @param string $audience * * @return string */ - private static function normalizeJwtHost($host) + private static function normalizeJwtAudience($audience) { - $host = strtolower(trim($host, " \n\r\t\v\x00")); + $audience = strtolower(trim($audience, " \n\r\t\v\x00")); + $audience = strpos($audience, 'www.') === 0 ? substr($audience, 4) : $audience; - return strpos($host, 'www.') === 0 ? substr($host, 4) : $host; + return rtrim($audience, '/'); } /** diff --git a/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php b/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php index 66a14c29f..ae39e05a0 100644 --- a/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php +++ b/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassJwtTest.php @@ -284,6 +284,31 @@ public function testSiteIsChecked() $this->assertSame(admin_url(), $this->postToken($this->makeToken(array('aud' => $aud)))); } + public function testSubdirectorySiteIsBoundByPath() + { + $original_home = get_option('home'); + $host = parse_url($original_home, PHP_URL_HOST); + update_option('home', $original_home . '/site-a'); + + try { + $this->assertRejected( + $this->postToken($this->makeToken(array('aud' => $host))), + 'The token is issued for another site.' + ); + $this->assertRejected( + $this->postToken($this->makeToken(array('aud' => $host . '/site-b'))), + 'The token is issued for another site.' + ); + $this->assertSame( + admin_url(), + $this->postToken($this->makeToken(array('aud' => strtoupper($host) . '/Site-A/'))), + (string)FirewallBypass::$last_error + ); + } finally { + update_option('home', $original_home); + } + } + public function testIpIsChecked() { $this->assertRejected(