From c9fb92dcb1a385070b44e0437445a524abab1e0e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:24:43 +0000 Subject: [PATCH 01/10] Fix two high-severity block/IP-detection bypasses 1. Unblock token bypass: a blocked IP was let through on any request with view=tokenunblock and an existing token. The token was neither consumed by such a request (e.g. option=com_users&task=user.login) nor checked for expiry or for belonging to the requester's block, so anyone able to get one token emailed (by getting their own account blocked) could keep guessing passwords from a blocked IP. The pass now only applies to com_bfstop's unblock view without a task, to an unexpired token issued for one of the requesting IP's own blocks. The token is no longer logged. 2. Forged client IP behind a proxy: the first public entry of the forwarding header was used, but proxies append to a client-supplied header, so the leftmost entry is attacker-controlled (block evasion, framing other addresses, impersonating allowlisted ones). The address is now taken from the right, skipping trusted proxies; several trusted proxies and CIDR subnets can be configured. The Forwarded header and ports are parsed, and an unparseable last hop falls back to REMOTE_ADDR. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q --- CHANGELOG | 17 +++ .../en-GB/en-GB.plg_system_bfstop.sys.ini | 4 +- src/Extension/Bfstop.php | 31 ++-- src/Helper/DatabaseHelper.php | 37 +++++ src/Helper/IpHelper.php | 133 ++++++++++++++++-- tests/Integration/BlockedRequestTest.php | 31 ++++ tests/Integration/IpHelperAddressTest.php | 30 +++- tests/Unit/IpHelperTest.php | 61 ++++++++ 8 files changed, 316 insertions(+), 28 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index af9b78d..e875a1d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -40,6 +40,23 @@ then silently never blocked anything. IP subnet matching for block and allow list entries is now done in PHP instead of with MySQL-only SQL functions, and fixed-length time windows are computed in PHP + - Security: an emailed unblock token no longer lets a blocked IP address + bypass the block. It was accepted on any request with + view=tokenunblock (e.g. a login), was never used up by such a request + and was not checked for expiry, so anyone who got a token (e.g. by + getting their own account blocked) could keep guessing passwords from + a blocked IP. Now it only passes the unblock page of com_bfstop itself, + only while unexpired, and only if issued for a block of the requesting + IP address; the token is no longer written to the log either + - Security: behind a proxy/load balancer, the client IP is now taken from + the end of the forwarding header (the entry added by the trusted proxy) + instead of its first public entry, which a visitor could forge to evade + blocking, get other addresses blocked or impersonate an allowlisted one. + Several trusted proxies (and CIDR subnets) can be configured, the + "Forwarded" header (RFC 7239) and addresses with ports are understood, + and private client addresses reported by the proxy are accepted. Not + all proxies append to an incoming header: pick a header yours sets or + appends to 1.5.2 (2024-02-18) - Note: Only component changes, no plugin changes diff --git a/language/en-GB/en-GB.plg_system_bfstop.sys.ini b/language/en-GB/en-GB.plg_system_bfstop.sys.ini index 1bfce70..b769b4f 100644 --- a/language/en-GB/en-GB.plg_system_bfstop.sys.ini +++ b/language/en-GB/en-GB.plg_system_bfstop.sys.ini @@ -76,9 +76,9 @@ PLG_SYSTEM_BFSTOP_PROXY_FIELDSET_LABEL="Proxy / Load Balancer" PLG_SYSTEM_BFSTOP_USE_PROXY_LABEL="Behind a proxy / load balancer" PLG_SYSTEM_BFSTOP_USE_PROXY_DESC="Enable this only if your site sits behind a reverse proxy or load balancer, so that the visitor's browser never connects to Joomla! directly. When enabled, bfstop reads the visitor's IP address from the header selected below instead of from the direct TCP connection - but only for requests that actually come from the proxy IP address configured below. Leave disabled unless you have such a setup: any visitor can forge this header, so trusting it without a verified proxy IP would let an attacker get an arbitrary IP address blocked instead of their own." PLG_SYSTEM_BFSTOP_PROXY_HEADER_SOURCE_LABEL="Client IP header" -PLG_SYSTEM_BFSTOP_PROXY_HEADER_SOURCE_DESC="The HTTP header your proxy/load balancer uses to report the original client IP address. Check your proxy's documentation if unsure - X-Forwarded-For is the most common choice." +PLG_SYSTEM_BFSTOP_PROXY_HEADER_SOURCE_DESC="The HTTP header your proxy/load balancer uses to report the original client IP address. Check your proxy's documentation if unsure - X-Forwarded-For is the most common choice. The client address is taken from the end of the header, i.e. the entry added by your own proxy; entries further left are supplied by the client and ignored. Choose a header your proxy sets or appends to itself." PLG_SYSTEM_BFSTOP_PROXY_IP_LABEL="Proxy / load balancer IP address" -PLG_SYSTEM_BFSTOP_PROXY_IP_DESC="The IP address of your reverse proxy or load balancer, i.e. the address Joomla! sees as REMOTE_ADDR for every legitimate request. The client IP header above is only trusted for requests coming from this address, so that a visitor cannot bypass the proxy and spoof the header directly. Required if 'Behind a proxy / load balancer' is enabled above." +PLG_SYSTEM_BFSTOP_PROXY_IP_DESC="The IP address of your reverse proxy or load balancer, i.e. the address Joomla! sees as REMOTE_ADDR for every legitimate request. If there are several proxies in a row (e.g. a CDN in front of a load balancer), list all of them, separated by commas; subnets in CIDR notation (e.g. 10.0.0.0/8) are allowed too. The client IP header above is only trusted for requests coming from one of these addresses, so that a visitor cannot bypass the proxy and spoof the header directly. Required if 'Behind a proxy / load balancer' is enabled above." PLG_SYSTEM_BFSTOP_GROUP_NOTIFICATION_ENABLED_LABEL="User group notification" PLG_SYSTEM_BFSTOP_GROUP_NOTIFICATION_ENABLED_DESC="If enabled, the whole user group selected below will be notified in case of failed logins/banned IPs" PLG_SYSTEM_BFSTOP_USERGROUP_LABEL="User group" diff --git a/src/Extension/Bfstop.php b/src/Extension/Bfstop.php index dd828ab..a4701a7 100644 --- a/src/Extension/Bfstop.php +++ b/src/Extension/Bfstop.php @@ -426,18 +426,33 @@ public function onUserLogin($event) $this->mydb->successfulLogin($info); } - private function isUnblockRequest() + /** + * A blocked IP may only reach com_bfstop's unblock page, and only with an + * unexpired token that was issued for one of *its own* blocks. The pass + * has to stay this narrow: the request is the one that consumes the token, + * so it must really be the unblock view (no task, no other component). + * Letting any request with view=tokenunblock through would turn a single + * valid token - which the owner of any account can get emailed by getting + * themselves blocked - into a reusable key around the block for logins + * (e.g. option=com_users&task=user.login&view=tokenunblock&token=...). + */ + private function isUnblockRequest($blockIds) { $input = $this->getApplication()->input; - $view = $input->getString('view', ''); + if (strcmp($input->getCmd('option', ''), 'com_bfstop') != 0 || + strcmp($input->getCmd('view', ''), 'tokenunblock') != 0 || + $input->getCmd('task', '') !== '') + { + return false; + } $token = $input->getString('token', ''); - $result = (strcmp($view, "tokenunblock") == 0 && - $this->mydb->unblockTokenExists($token)); + $result = $this->mydb->unblockTokenValidForBlocks($token, $blockIds); if ($result) { - $this->logger->log('Seeing valid unblock token ('. - $token.'), letting the request pass through '. - 'to com_bfstop', + // deliberately not logging the token: it is a bearer secret and + // the log is readable in the backend + $this->logger->log('Seeing a valid unblock token for this IP '. + 'address, letting the request pass through to com_bfstop', Log::INFO); } return $result; @@ -545,7 +560,7 @@ public function onAfterRoute($event) $this->mydb->getClientString( $this->getApplication()->getClientId()), Log::INFO); - if ($this->isUnblockRequest()) + if ($this->isUnblockRequest($blockIds)) { return; } diff --git a/src/Helper/DatabaseHelper.php b/src/Helper/DatabaseHelper.php index 6d4691a..e966960 100644 --- a/src/Helper/DatabaseHelper.php +++ b/src/Helper/DatabaseHelper.php @@ -22,6 +22,10 @@ class DatabaseHelper // 10 years in minutes. For all intents here sufficiently large to stand for "forever": public static $UNLIMITED_DURATION = 5256000; + // how long an emailed unblock token can be used; com_bfstop's + // TokenunblockModel::TokenValidDays must stay in sync with this + public static $UNBLOCK_TOKEN_VALID_DAYS = 3; + // how long a reverse-DNS lookup result is trusted before being redone - // keeps the (potentially slow) gethostbyaddr() call to at most once per // IP per TTL window, see issue #103 @@ -334,6 +338,39 @@ public function getNewUnblockToken($id, $token) } } + /** + * Whether $token is an unexpired unblock token issued for one of the + * given blocks. Used to decide if a blocked client may reach the unblock + * page: a token that belongs to a different IP address's block, or one + * that has outlived its validity, must not work as a pass for this one. + */ + public function unblockTokenValidForBlocks($token, array $blockIds) + { + if ($token === '' || count($blockIds) === 0) + { + return false; + } + try + { + $sql = "SELECT block_id, crdate FROM #__bfstop_unblock_token WHERE token=". + $this->db->quote($token); + $this->db->setQuery($sql); + $row = $this->db->loadAssoc(); + if ($row === null || !in_array((int) $row['block_id'], $blockIds, true)) + { + return false; + } + $created = strtotime($row['crdate']); + return $created !== false && + (time() - $created) <= (self::$UNBLOCK_TOKEN_VALID_DAYS * 86400); + } + catch (\Exception $e) + { + $this->logger->log("Database exception occured: ".$e->getMessage(), Log::ERROR); + return false; + } + } + public function unblockTokenExists($token) { try diff --git a/src/Helper/IpHelper.php b/src/Helper/IpHelper.php index ef8b8e4..9f649ef 100644 --- a/src/Helper/IpHelper.php +++ b/src/Helper/IpHelper.php @@ -72,14 +72,122 @@ public static function isInSubnet($ip, $subnet) return (ord($ipBin[$fullBytes]) & $mask) === (ord($subnetBin[$fullBytes]) & $mask); } - private static function firstValidPublicIP($headerValue) + /** + * Splits the trusted proxy setting into its entries. Each entry is a + * single IP address or a subnet in CIDR notation; entries may be + * separated by comma, semicolon or whitespace. A setting holding just one + * address (the only form older versions knew) keeps working unchanged. + */ + public static function parseTrustedProxies($setting) + { + return preg_split('/[\s,;]+/', (string) $setting, -1, PREG_SPLIT_NO_EMPTY); + } + + /** + * Whether $ip is one of the $trustedProxies (see parseTrustedProxies()). + * Addresses are compared in binary form, so different spellings of the + * same IPv6 address match. + */ + public static function isTrustedProxy($ip, array $trustedProxies) { - foreach (explode(',', $headerValue) as $ip) + $ipBin = @inet_pton((string) $ip); + if ($ipBin === false) + { + return false; + } + foreach ($trustedProxies as $proxy) { - $ip = trim($ip); // just to be safe - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) + if (strpos($proxy, '/') !== false) { - return $ip; + if (self::isInSubnet($ip, $proxy)) + { + return true; + } + } + elseif (@inet_pton($proxy) === $ipBin) + { + return true; + } + } + return false; + } + + /** + * The address in one entry of a forwarding header, without the quotes, + * brackets and port the various proxies decorate it with; whatever is + * left is not necessarily a valid IP address. + */ + private static function normalizeHeaderEntry($entry) + { + $entry = trim(trim($entry), '"'); + if (preg_match('/^\[([^\]]+)\](?::\d+)?$/', $entry, $m) || + preg_match('/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/', $entry, $m)) + { + return $m[1]; + } + return $entry; + } + + /** + * The addresses listed in a forwarding header, in the order they appear: + * the leftmost one is whatever the client claimed, every further one was + * appended by the proxy that handled the request next. Elements that carry + * no address (e.g. a "Forwarded" element without for=) yield ''. + */ + private static function headerAddresses($headerName, $headerValue) + { + $addresses = array(); + foreach (explode(',', $headerValue) as $element) + { + if ($headerName === 'HTTP_FORWARDED') + { + // RFC 7239: ';'-separated key=value pairs per element + $address = ''; + foreach (explode(';', $element) as $pair) + { + $keyValue = explode('=', $pair, 2); + if (count($keyValue) === 2 && strcasecmp(trim($keyValue[0]), 'for') === 0) + { + $address = $keyValue[1]; + break; + } + } + $element = $address; + } + $addresses[] = self::normalizeHeaderEntry($element); + } + return $addresses; + } + + /** + * The client address according to a forwarding header that was set by a + * trusted proxy, or null if it cannot be determined reliably. + * + * Only the entries the trusted proxies themselves added can be believed, + * and a proxy adds its entry on the right: everything to the left of that + * is whatever the client (or someone upstream) put in the header, and + * trivially forged. So the list is read from the right, skipping the + * addresses of further trusted proxies (a CDN in front of a load + * balancer, say); the first address that is not a trusted proxy is the + * client. It must never be taken from further left: with a proxy that + * appends to an incoming X-Forwarded-For, an attacker could otherwise + * pick any address - to dodge blocking, to get another visitor's + * address blocked, or to pass as an allowlisted one. + */ + public static function clientAddressFromHeader($headerName, $headerValue, array $trustedProxies) + { + $addresses = self::headerAddresses($headerName, (string) $headerValue); + for ($i = count($addresses) - 1; $i >= 0; --$i) + { + if (filter_var($addresses[$i], FILTER_VALIDATE_IP) === false) + { + // can't tell what this hop was; guessing from the entries + // further left would trust forgeable data + return null; + } + if (!self::isTrustedProxy($addresses[$i], $trustedProxies)) + { + return $addresses[$i]; } } return null; @@ -89,9 +197,11 @@ private static function firstValidPublicIP($headerValue) * Determine the client IP address. By default only REMOTE_ADDR (the * actual TCP peer address, which a visitor cannot forge) is used. A * reverse-proxy header is only consulted if the admin has explicitly - * enabled proxy support and the request's REMOTE_ADDR matches the - * configured, trusted proxy IP address - otherwise a client could bypass - * the proxy entirely and spoof the header itself. + * enabled proxy support and the request's REMOTE_ADDR is one of the + * configured, trusted proxies - otherwise a client could bypass the proxy + * entirely and spoof the header itself - and even then only the part of + * the header the trusted proxies added is believed, see + * clientAddressFromHeader(). */ public static function getAddress(LoggerHelper $logger) { @@ -109,7 +219,8 @@ public static function getAddress(LoggerHelper $logger) } $proxyIpAddress = (string) $params->get('proxyIpAddress', ''); - if ($proxyIpAddress === '' || $remoteAddr !== $proxyIpAddress) + $trustedProxies = self::parseTrustedProxies($proxyIpAddress); + if (count($trustedProxies) === 0 || !self::isTrustedProxy($remoteAddr, $trustedProxies)) { $logger->log('Proxy/load balancer usage is enabled, but the request did not originate from the '. 'configured proxy IP address (configured: "'.$proxyIpAddress.'", actual: "'.$remoteAddr. @@ -120,14 +231,14 @@ public static function getAddress(LoggerHelper $logger) $proxyHeaderSource = (string) $params->get('proxyHeaderSource', 'HTTP_X_FORWARDED_FOR'); if (array_key_exists($proxyHeaderSource, $_SERVER)) { - $ip = self::firstValidPublicIP($_SERVER[$proxyHeaderSource]); + $ip = self::clientAddressFromHeader($proxyHeaderSource, $_SERVER[$proxyHeaderSource], $trustedProxies); if ($ip !== null) { return $ip; } } $logger->log('Proxy/load balancer usage is enabled, but header "'.$proxyHeaderSource. - '" was missing or did not contain a valid public IP address, falling back to REMOTE_ADDR "'. + '" was missing or did not end in a valid client IP address, falling back to REMOTE_ADDR "'. $remoteAddr.'"!', Log::WARNING); return $remoteAddr; } diff --git a/tests/Integration/BlockedRequestTest.php b/tests/Integration/BlockedRequestTest.php index 55ffeed..3f2f8ce 100644 --- a/tests/Integration/BlockedRequestTest.php +++ b/tests/Integration/BlockedRequestTest.php @@ -155,6 +155,37 @@ public function testValidUnblockTokenGetsThrough() $this->assertBlocked('option=com_bfstop&view=tokenunblock'); } + public function testUnblockTokenIsNoPassForOtherRequests() + { + // the token is consumed by the unblock page only; as a parameter of + // any other request (here: a login) it must not get around the block + $this->configure(); + $blockId = $this->block(); + $token = (new DatabaseHelper($this->logger))->getNewUnblockToken($blockId, str_repeat('ab', 20)); + $this->assertBlocked('option=com_users&task=user.login&view=tokenunblock&token='.$token); + $this->assertBlocked('option=com_content&view=tokenunblock&token='.$token); + $this->assertBlocked('option=com_bfstop&task=display&view=tokenunblock&token='.$token); + $this->assertNotBlocked('option=com_bfstop&view=tokenunblock&token='.$token); + } + + public function testUnblockTokenOfAnotherBlockIsNoPass() + { + $this->configure(); + $this->block(); + $otherBlockId = $this->block('203.0.113.99'); + $token = (new DatabaseHelper($this->logger))->getNewUnblockToken($otherBlockId, str_repeat('cd', 20)); + $this->assertBlocked('option=com_bfstop&view=tokenunblock&token='.$token); + } + + public function testExpiredUnblockTokenIsNoPass() + { + $this->configure(); + $blockId = $this->block(); + $this->insert('#__bfstop_unblock_token', array('token' => str_repeat('ef', 20), + 'block_id' => $blockId, 'crdate' => self::minutesAgo(4 * 24 * 60))); + $this->assertBlocked('option=com_bfstop&view=tokenunblock&token='.str_repeat('ef', 20)); + } + public function testRejectedRequestsAreCounted() { $this->configure(); diff --git a/tests/Integration/IpHelperAddressTest.php b/tests/Integration/IpHelperAddressTest.php index 3ffc441..83331d3 100644 --- a/tests/Integration/IpHelperAddressTest.php +++ b/tests/Integration/IpHelperAddressTest.php @@ -97,27 +97,43 @@ public static function trustedProxyHeaderProvider() { return array( 'single public IP' => array('203.0.113.5', '203.0.113.5'), - 'first public in list' => array('203.0.113.5, 198.51.100.9', '203.0.113.5'), - 'skips private' => array('10.0.0.1, 192.168.1.1, 203.0.113.5', '203.0.113.5'), - 'skips loopback/reserved' => array('127.0.0.1,0.0.0.0, 203.0.113.5', '203.0.113.5'), + 'last entry wins' => array('203.0.113.5, 198.51.100.9', '198.51.100.9'), + 'forged leftmost entry' => array('1.2.3.4, 203.0.113.5', '203.0.113.5'), + 'private client' => array('203.0.113.5, 10.0.0.9', '10.0.0.9'), 'whitespace is trimmed' => array(' 203.0.113.5 ', '203.0.113.5'), - 'garbage is skipped' => array('not-an-ip, 203.0.113.5', '203.0.113.5'), 'public IPv6' => array('fd00::1, 2001:4860:4860::8888', '2001:4860:4860::8888'), + 'trusted proxy hop' => array('203.0.113.5, 198.51.100.7', '203.0.113.5'), ); } #[DataProvider('trustedProxyHeaderProvider')] - public function testTrustedProxyUsesFirstPublicIpFromHeader($header, $expected) + public function testTrustedProxyUsesRightmostNonProxyAddress($header, $expected) { $this->configure(array('useProxy' => 1, 'proxyIpAddress' => '198.51.100.7')); $_SERVER['HTTP_X_FORWARDED_FOR'] = $header; $this->assertSame($expected, IpHelper::getAddress($this->logger)); } - public function testTrustedProxyWithOnlyPrivateAddressesFallsBack() + public function testTrustedProxyListAndSubnets() + { + $this->configure(array('useProxy' => 1, 'proxyIpAddress' => '192.0.2.1, 198.51.100.0/24')); + $_SERVER['HTTP_X_FORWARDED_FOR'] = '1.2.3.4, 203.0.113.5, 192.0.2.1'; + $this->assertSame('203.0.113.5', IpHelper::getAddress($this->logger)); + } + + public function testGarbageAsLastEntryFallsBack() + { + // nothing to the left of it can be trusted + $this->configure(array('useProxy' => 1, 'proxyIpAddress' => '198.51.100.7')); + $_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.5, not-an-ip'; + $this->assertSame('198.51.100.7', IpHelper::getAddress($this->logger)); + $this->assertTrue($this->logger->hasMessage(Log::WARNING, 'falling back to REMOTE_ADDR')); + } + + public function testHeaderOfOnlyTrustedProxiesFallsBack() { $this->configure(array('useProxy' => 1, 'proxyIpAddress' => '198.51.100.7')); - $_SERVER['HTTP_X_FORWARDED_FOR'] = '10.1.2.3, 172.16.0.1'; + $_SERVER['HTTP_X_FORWARDED_FOR'] = '198.51.100.7'; $this->assertSame('198.51.100.7', IpHelper::getAddress($this->logger)); $this->assertTrue($this->logger->hasMessage(Log::WARNING, 'falling back to REMOTE_ADDR')); } diff --git a/tests/Unit/IpHelperTest.php b/tests/Unit/IpHelperTest.php index 0a23859..596a333 100644 --- a/tests/Unit/IpHelperTest.php +++ b/tests/Unit/IpHelperTest.php @@ -8,6 +8,7 @@ namespace Codeling\Bfstop\Tests\Unit; use Codeling\Plugin\System\Bfstop\Helper\IpHelper; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class IpHelperTest extends TestCase @@ -55,4 +56,64 @@ public function testMalformedNeverMatches() $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '::/0')); $this->assertFalse(IpHelper::isInSubnet('2001:db8::1', '0.0.0.0/0')); } + + public function testParseTrustedProxies() + { + $this->assertSame(array('192.0.2.1'), IpHelper::parseTrustedProxies('192.0.2.1')); + $this->assertSame(array('192.0.2.1', '198.51.100.0/24', '2001:db8::1'), + IpHelper::parseTrustedProxies(" 192.0.2.1,198.51.100.0/24 ;\n2001:db8::1 ")); + $this->assertSame(array(), IpHelper::parseTrustedProxies('')); + } + + public function testIsTrustedProxy() + { + $proxies = array('192.0.2.1', '198.51.100.0/24', '2001:db8::1'); + $this->assertTrue(IpHelper::isTrustedProxy('192.0.2.1', $proxies)); + $this->assertTrue(IpHelper::isTrustedProxy('198.51.100.77', $proxies)); + // other spelling of the same IPv6 address + $this->assertTrue(IpHelper::isTrustedProxy('2001:0db8:0:0:0:0:0:1', $proxies)); + $this->assertFalse(IpHelper::isTrustedProxy('192.0.2.2', $proxies)); + $this->assertFalse(IpHelper::isTrustedProxy('not-an-ip', $proxies)); + $this->assertFalse(IpHelper::isTrustedProxy('', $proxies)); + $this->assertFalse(IpHelper::isTrustedProxy('192.0.2.1', array())); + // a malformed entry never matches + $this->assertFalse(IpHelper::isTrustedProxy('192.0.2.1', array('foo', '192.0.2.0/abc'))); + } + + public static function forwardedHeaderProvider() + { + $proxy = array('198.51.100.7'); + return array( + 'single entry' => array('HTTP_X_FORWARDED_FOR', '203.0.113.5', $proxy, '203.0.113.5'), + // the case a proxy appending to the client's own header produces: + // the forged entry on the left must be ignored + 'forged leftmost entry' => array('HTTP_X_FORWARDED_FOR', '1.2.3.4, 203.0.113.5', $proxy, '203.0.113.5'), + 'several forged entries' => array('HTTP_X_FORWARDED_FOR', '1.1.1.1, 2.2.2.2, 203.0.113.5', $proxy, '203.0.113.5'), + 'forged allowlisted address' => array('HTTP_X_FORWARDED_FOR', '192.0.2.200, 203.0.113.5', $proxy, '203.0.113.5'), + 'trusted hops are skipped' => array('HTTP_X_FORWARDED_FOR', '1.2.3.4, 203.0.113.5, 198.51.100.7', $proxy, '203.0.113.5'), + 'subnet of trusted proxies' => array('HTTP_X_FORWARDED_FOR', '1.2.3.4, 203.0.113.5, 198.51.100.9', array('198.51.100.0/24'), '203.0.113.5'), + 'private client' => array('HTTP_X_FORWARDED_FOR', '203.0.113.5, 10.0.0.9', $proxy, '10.0.0.9'), + 'whitespace' => array('HTTP_X_FORWARDED_FOR', ' 203.0.113.5 ', $proxy, '203.0.113.5'), + 'IPv6' => array('HTTP_X_FORWARDED_FOR', '1.2.3.4, 2001:db8::5', $proxy, '2001:db8::5'), + 'IPv4 with port' => array('HTTP_X_FORWARDED_FOR', '203.0.113.5:4711', $proxy, '203.0.113.5'), + 'bracketed IPv6 with port' => array('HTTP_X_FORWARDED_FOR', '[2001:db8::5]:4711', $proxy, '2001:db8::5'), + 'single-value header' => array('HTTP_CLIENT_IP', '203.0.113.5', $proxy, '203.0.113.5'), + 'Forwarded header' => array('HTTP_FORWARDED', 'for=1.2.3.4, for=203.0.113.5;proto=https', $proxy, '203.0.113.5'), + 'Forwarded quoted IPv6' => array('HTTP_FORWARDED', 'for="[2001:db8::5]:4711";by=198.51.100.7', $proxy, '2001:db8::5'), + 'Forwarded case-insensitive key' => array('HTTP_FORWARDED', 'For=203.0.113.5', $proxy, '203.0.113.5'), + // unparseable last hop: the entries to its left are not trustworthy + 'garbage last entry' => array('HTTP_X_FORWARDED_FOR', '203.0.113.5, not-an-ip', $proxy, null), + 'empty last entry' => array('HTTP_X_FORWARDED_FOR', '203.0.113.5,', $proxy, null), + 'obfuscated Forwarded identifier' => array('HTTP_FORWARDED', 'for=203.0.113.5, for=_hidden', $proxy, null), + 'Forwarded element without for' => array('HTTP_FORWARDED', 'for=203.0.113.5, proto=https', $proxy, null), + 'only trusted proxies' => array('HTTP_X_FORWARDED_FOR', '198.51.100.7, 198.51.100.7', $proxy, null), + 'empty header' => array('HTTP_X_FORWARDED_FOR', '', $proxy, null), + ); + } + + #[DataProvider('forwardedHeaderProvider')] + public function testClientAddressFromHeader($header, $value, $proxies, $expected) + { + $this->assertSame($expected, IpHelper::clientAddressFromHeader($header, $value, $proxies)); + } } From 43e00dd4c419a471dccfb14d4541570dc06c9fdb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:44:45 +0000 Subject: [PATCH 02/10] Record failed logins before delaying; hash unknown usernames - Delay ordering: the failed login is now inserted, counted and checked against the block threshold before the configured delay, and a request whose IP address just got (or already is) blocked isn't delayed at all. Before, a burst of parallel attempts all ran before the first was counted, and each held a worker for the whole delay. - Username storage: usernames of failed logins that don't belong to an existing account (and aren't on the common-usernames list) are stored, shown, logged and mailed as a keyed hash by default, since users often type their password into the username field. Same input gives the same value, so attempts are still counted together. New setting unknownUsernameMode (hash|plain) restores the old behaviour. - Tests: FailedLoginTest (runs failed logins in separate processes, incl. a check that the attempt is recorded while the request is still delayed), UsernameHelperTest, and component tests for the confirmation/IP-bound unblock and for the escaped log view (needs com_bfstop on the same branch). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01R3snnF5dGbTM4SazFxGY7q --- CHANGELOG | 18 +++ src/Extension/Bfstop.php | 47 +++++- src/Helper/DatabaseHelper.php | 18 +++ src/Helper/RiskHelper.php | 25 +++- src/Helper/UsernameHelper.php | 49 +++++++ tests/Integration/ComponentTest.php | 62 ++++++++ tests/Integration/FailedLoginTest.php | 153 ++++++++++++++++++++ tests/Integration/fixtures/failed_login.php | 26 ++++ tests/Unit/UsernameHelperTest.php | 52 +++++++ 9 files changed, 435 insertions(+), 15 deletions(-) create mode 100644 src/Helper/UsernameHelper.php create mode 100644 tests/Integration/FailedLoginTest.php create mode 100644 tests/Integration/fixtures/failed_login.php create mode 100644 tests/Unit/UsernameHelperTest.php diff --git a/CHANGELOG b/CHANGELOG index e875a1d..09df556 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -57,6 +57,24 @@ and private client addresses reported by the proxy are accepted. Not all proxies append to an incoming header: pick a header yours sets or appends to + - Security: a failed login is now recorded, counted and checked against + the block threshold before the configured delay is applied, not after + it, and a request whose IP address just got (or already is) blocked is + not delayed any more. Before, a burst of parallel login attempts all + ran before the first of them was counted, and every one of them held + on to a server worker for the whole delay + - Security: usernames of failed logins which do not belong to an existing + account (and are not on the list of common usernames) are now stored, + shown, logged and mailed as an unreadable hash by default, as users + often type their password into the username field; the hash is the same + for the same input, so attempts are still counted together. The new + setting "Usernames Not Matching an Account" restores the old behaviour. + Entries stored by earlier versions stay as they are - purge them in the + failed login and username statistics views if you want them gone + - Security: unblocking by the link in the notification email now needs a + confirmation (a POST request) and only works from the IP address which + was blocked, so mail security scanners or link previews which open + every link in a message can't unblock an attacker (see com_bfstop) 1.5.2 (2024-02-18) - Note: Only component changes, no plugin changes diff --git a/src/Extension/Bfstop.php b/src/Extension/Bfstop.php index a4701a7..16e2db2 100644 --- a/src/Extension/Bfstop.php +++ b/src/Extension/Bfstop.php @@ -16,6 +16,7 @@ use Codeling\Plugin\System\Bfstop\Helper\NotifierHelper; use Codeling\Plugin\System\Bfstop\Helper\RiskHelper; use Codeling\Plugin\System\Bfstop\Helper\TokenHelper; +use Codeling\Plugin\System\Bfstop\Helper\UsernameHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\Log\Log; use Joomla\CMS\Plugin\CMSPlugin; @@ -361,6 +362,20 @@ private function determineBaseDelayDuration() return $delayDuration; } + /** + * The form in which the username of a failed login is stored, mailed and + * logged, see UsernameHelper. + */ + private function usernameForStorage($username) + { + $mode = $this->getStringParam('unknownUsernameMode', UsernameHelper::ModeHash); + $isReadable = ($mode !== UsernameHelper::ModePlain) && + ($this->mydb->userExists($username) || + RiskHelper::isCommonUsername($this->params, $username)); + return UsernameHelper::forStorage($username, $mode, $isReadable, + $this->getApplication()->get('secret', '')); + } + public function onUserLoginFailure($event) { $user = $event->getArgument(0); @@ -383,23 +398,22 @@ public function onUserLoginFailure($event) $username = mb_strimwidth($user['username'], 0, 150, "..."); $riskScore = RiskHelper::computeScore($this->mydb, $this->logger, $this->params, $ipAddress, $username); - $delayDuration = $this->determineDelayDuration($riskScore); - if ($delayDuration != 0) - { - sleep((int) round($delayDuration)); - } - $logEntry = new \stdClass(); $logEntry->id = null; $logEntry->ipaddress = $ipAddress; $logEntry->logtime = date("Y-m-d H:i:s"); - $logEntry->username = $username; + $logEntry->username = $this->usernameForStorage($username); $logEntry->origin = $this->getApplication()->getClientId(); $this->logger->log('Failed login attempt from IP address '. $logEntry->ipaddress, Log::DEBUG); - // insert into log: + // Everything is recorded and evaluated *before* the delay below. The + // password has been checked by now, so the delay only holds back the + // response; if the attempt was only counted after the sleep, a burst + // of parallel requests would all run before the first of them + // reached the block threshold, and each of them would tie up a + // worker for the whole delay. $this->mydb->insertFailedLogin($logEntry); $this->notifyOfRemainingAttempts($logEntry, $riskScore); @@ -408,6 +422,23 @@ public function onUserLoginFailure($event) $this->notifier->failedLogin($logEntry, $maxNumber); $this->blockIfTooManyAttempts($logEntry, $riskScore); $this->accountThrottleIfNeeded($logEntry); + + $delayDuration = $this->determineDelayDuration($riskScore); + if ($delayDuration != 0) + { + // a blocked address gets nothing more out of a slow response; + // don't let it hold on to a worker (attackers could otherwise use + // the delay to exhaust the server's workers) + if ($this->mydb->isIPBlocked($ipAddress)) + { + $this->logger->log('Not delaying the response, IP address '. + $ipAddress.' is blocked', Log::DEBUG); + } + else + { + sleep((int) round($delayDuration)); + } + } } public function onUserLogin($event) diff --git a/src/Helper/DatabaseHelper.php b/src/Helper/DatabaseHelper.php index e966960..f231a9f 100644 --- a/src/Helper/DatabaseHelper.php +++ b/src/Helper/DatabaseHelper.php @@ -403,6 +403,24 @@ private function getUserEmailWhere($where) } } + public function userExists($username) + { + try + { + // LOWER: Joomla treats usernames case-insensitively on login + // (MySQL's default collations do, PostgreSQL's don't) + $sql = "SELECT COUNT(*) FROM #__users WHERE LOWER(username) = LOWER(". + $this->db->quote($username).")"; + $this->db->setQuery($sql); + return ((int) $this->db->loadResult()) > 0; + } + catch (\Exception $e) + { + $this->logger->log("Database exception occured: ".$e->getMessage(), Log::ERROR); + return false; + } + } + public function getUserEmailByID($uid) { return $this->getUserEmailWhere("id=".((int)$uid)); diff --git a/src/Helper/RiskHelper.php b/src/Helper/RiskHelper.php index 9c7ae04..913decd 100644 --- a/src/Helper/RiskHelper.php +++ b/src/Helper/RiskHelper.php @@ -52,22 +52,33 @@ private static function knownIpScore(DatabaseHelper $db, LoggerHelper $logger, R return 0; } - private static function commonUsernameScore(Registry $params, $username) + /** + * Whether $username is on the admin's list of common/high-value + * usernames (case-insensitive). + */ + public static function isCommonUsername(Registry $params, $username) { - if (!self::getBoolParam($params, 'riskCommonUsernameEnabled', true)) - { - return 0; - } $list = (string) $params->get('riskCommonUsernames', ''); $candidates = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $list)))); foreach ($candidates as $candidate) { if ($candidate !== '' && strcasecmp($candidate, $username) === 0) { - return self::getIntParam($params, 'riskCommonUsernamePoints', 2); + return true; } } - return 0; + return false; + } + + private static function commonUsernameScore(Registry $params, $username) + { + if (!self::getBoolParam($params, 'riskCommonUsernameEnabled', true)) + { + return 0; + } + return self::isCommonUsername($params, $username) + ? self::getIntParam($params, 'riskCommonUsernamePoints', 2) + : 0; } private static function userAgentScore(Registry $params) diff --git a/src/Helper/UsernameHelper.php b/src/Helper/UsernameHelper.php new file mode 100644 index 0000000..e84e4a3 --- /dev/null +++ b/src/Helper/UsernameHelper.php @@ -0,0 +1,49 @@ +assertSame(0, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_unblock_token')); } + public function testTokenUnblockNeedsConfirmationAndTheBlockedIp() + { + $ip = '203.0.113.5'; + $blockId = $this->insert('#__bfstop_bannedip', array('ipaddress' => $ip, 'crdate' => self::minutesAgo(0), 'duration' => 60), 'id'); + $this->insert('#__bfstop_unblock_token', array('token' => 'valid', 'block_id' => $blockId, 'crdate' => self::minutesAgo(60))); + $helper = new DatabaseHelper($this->logger); + $model = $this->model(TokenunblockModel::class); + $tokens = 'SELECT COUNT(*) FROM #__bfstop_unblock_token'; + + $this->assertSame(TokenunblockModel::ResultInvalid, $model->process('', true, $ip, $this->logger)); + + // opening the link (what a mail scanner does) changes nothing + $this->assertSame(TokenunblockModel::ResultConfirm, $model->process('valid', false, $ip, $this->logger)); + $this->assertTrue($helper->isIPBlocked($ip)); + $this->assertSame(1, (int) $this->queryValue($tokens)); + + // confirming from another IP address doesn't unblock either + $this->assertSame(TokenunblockModel::ResultWrongIp, $model->process('valid', true, '198.51.100.9', $this->logger)); + $this->assertTrue($helper->isIPBlocked($ip)); + $this->assertSame(1, (int) $this->queryValue($tokens)); + + // neither does an unknown token + $this->assertSame(TokenunblockModel::ResultFailed, $model->process('unknown', true, $ip, $this->logger)); + $this->logger->errors = array(); // expected: "token not found" error + $this->assertTrue($helper->isIPBlocked($ip)); + + $this->assertSame(TokenunblockModel::ResultUnblocked, $model->process('valid', true, $ip, $this->logger)); + $this->assertFalse($helper->isIPBlocked($ip)); + $this->assertSame(0, (int) $this->queryValue($tokens)); + } + + public function testTokenUnblockComparesIpv6AddressesNotSpellings() + { + $blockId = $this->insert('#__bfstop_bannedip', array('ipaddress' => '2001:db8::1', 'crdate' => self::minutesAgo(0), 'duration' => 60), 'id'); + $this->insert('#__bfstop_unblock_token', array('token' => 'valid', 'block_id' => $blockId, 'crdate' => self::minutesAgo(1))); + $model = $this->model(TokenunblockModel::class); + $this->assertSame(TokenunblockModel::ResultWrongIp, $model->process('valid', true, '2001:db8::2', $this->logger)); + $this->assertSame(TokenunblockModel::ResultUnblocked, $model->process('valid', true, '2001:0DB8:0:0:0:0:0:1', $this->logger)); + } + + public function testLogViewEscapesLogContents() + { + // the message of a log entry can contain what a visitor sent (e.g. a username) + $view = new class { + public $items; + public function escape($string) + { + return htmlspecialchars((string) $string, ENT_QUOTES, 'UTF-8'); + } + }; + $view->items = array((object) array('date' => '2026-01-01T00:00:00+00:00', + 'priority' => 'DEBUG', 'message' => 'Unknown user () blocked')); + ob_start(); + (function () + { + include getenv('COM_BFSTOP_ROOT').'/admin/tmpl/log/default_body.php'; + })->call($view); + $html = ob_get_clean(); + $this->assertStringNotContainsString('