diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f64671c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + unit: + name: Syntax check and unit tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.4'] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: phpunit:11 + coverage: none + - name: Check PHP syntax + run: find . -name '*.php' -not -path './.git/*' -print0 | xargs -0 -n1 php -l + - name: Unit tests + # PHPUnit 11 needs PHP >= 8.2 + if: matrix.php != '8.1' + run: phpunit -c tests/phpunit.xml.dist --testsuite unit + + integration: + name: Joomla ${{ matrix.joomla }}, ${{ matrix.db }}, PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { joomla: 5.4.8, php: '8.2', db: 'mysql:8.0' } + - { joomla: 5.4.8, php: '8.2', db: 'mariadb:10.11' } + - { joomla: 5.4.8, php: '8.2', db: 'postgres:12' } + - { joomla: 6.1.3, php: '8.4', db: 'mysql:8.4' } + - { joomla: 6.1.3, php: '8.4', db: 'mariadb:11.4' } + - { joomla: 6.1.3, php: '8.4', db: 'postgres:17' } + env: + JOOMLA_VERSION: ${{ matrix.joomla }} + DB_HOST: 127.0.0.1 + DB_NAME: joomla + DB_PASS: bfstop-test + steps: + - name: Set paths + run: | + echo "JOOMLA_ROOT=$RUNNER_TEMP/joomla" >> "$GITHUB_ENV" + echo "COM_BFSTOP_ROOT=$RUNNER_TEMP/com_bfstop" >> "$GITHUB_ENV" + - uses: actions/checkout@v7 + + # the component is tested together with the plugin: use its branch of + # the same name if there is one (for changes spanning both), else main + - name: Determine com_bfstop branch + id: com + run: | + branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + if git ls-remote --exit-code --heads https://github.com/codeling/com_bfstop.git "$branch" > /dev/null; then + echo "ref=$branch" >> "$GITHUB_OUTPUT" + else + echo "ref=main" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@v7 + with: + repository: codeling/com_bfstop + ref: ${{ steps.com.outputs.ref }} + path: com_bfstop + - name: Move com_bfstop checkout out of the plugin's + run: mv com_bfstop "$COM_BFSTOP_ROOT" + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mysqli, pdo_mysql, pgsql, pdo_pgsql, zip, mbstring, intl, gd + tools: phpunit:11 + coverage: none + + - name: Start database (${{ matrix.db }}) + env: + DB_IMAGE: ${{ matrix.db }} + run: tests/ci/start-database.sh + + - name: Install Joomla and bfstop + run: tests/ci/install-joomla.sh + + - name: Tests + run: phpunit -c tests/phpunit.xml.dist + + - name: Plugin log + if: failure() + run: cat "$JOOMLA_ROOT"/administrator/logs/*.php || true diff --git a/CHANGELOG b/CHANGELOG index 87aecd0..af9b78d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -35,6 +35,11 @@ two-factor auth when risk is high) would need to hook earlier in the login pipeline than this plugin currently does and is left for a separate, larger initiative + - PostgreSQL support (#206): install/uninstall scripts for PostgreSQL; + the plugin previously installed without creating its tables there and + 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 1.5.2 (2024-02-18) - Note: Only component changes, no plugin changes diff --git a/bfstop.xml b/bfstop.xml index e050379..d38f485 100644 --- a/bfstop.xml +++ b/bfstop.xml @@ -38,16 +38,19 @@ sql/install.mysql.utf8.sql + sql/install.postgresql.utf8.sql sql/uninstall.mysql.utf8.sql + sql/uninstall.postgresql.utf8.sql sql/updates + sql/updates/postgresql diff --git a/sql/install.postgresql.utf8.sql b/sql/install.postgresql.utf8.sql new file mode 100644 index 0000000..f63d4fc --- /dev/null +++ b/sql/install.postgresql.utf8.sql @@ -0,0 +1,86 @@ +-- install script for bfstop plugin, PostgreSQL version (issue #206); +-- see install.mysql.utf8.sql for a description of the tables. Keep both in sync! +-- +-- differences to the MySQL schema: +-- - handled is a smallint, not a BOOLEAN: the code compares it with 0/1, +-- which PostgreSQL doesn't allow for boolean columns +-- - no unsigned integer types in PostgreSQL +-- - ipaddress in bannedip and allowlist is varchar(49) (as for MySQL +-- installs updated via 1.2.0.sql), to hold IPv6 subnets like +-- "ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255/128" + +CREATE TABLE IF NOT EXISTS "#__bfstop_failedlogin" ( + "id" serial NOT NULL, + "username" varchar(150) NOT NULL, + "ipaddress" varchar(45) NOT NULL, + "logtime" timestamp without time zone NOT NULL, + "origin" integer NOT NULL, + "handled" smallint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "#__bfstop_failedlogin_username_logtime" ON "#__bfstop_failedlogin" ("username", "logtime"); + + +CREATE TABLE IF NOT EXISTS "#__bfstop_bannedip" ( + "id" serial NOT NULL, + "ipaddress" varchar(49) NOT NULL, + "crdate" timestamp without time zone NOT NULL, + "duration" integer NOT NULL, + "attempts" integer NOT NULL DEFAULT 0, + "last_attempt" timestamp without time zone DEFAULT NULL, + PRIMARY KEY ("id") +); + + +CREATE TABLE IF NOT EXISTS "#__bfstop_unblock" ( + "block_id" integer NOT NULL, + "source" integer NOT NULL, + "crdate" timestamp without time zone NOT NULL, + PRIMARY KEY ("block_id") +); + + +CREATE TABLE IF NOT EXISTS "#__bfstop_unblock_token" ( + "token" varchar(40) NOT NULL, + "block_id" integer NOT NULL, + "crdate" timestamp without time zone NOT NULL, + PRIMARY KEY ("token") +); + + +CREATE TABLE IF NOT EXISTS "#__bfstop_allowlist" ( + "id" serial NOT NULL, + "ipaddress" varchar(49) NOT NULL, + "notes" varchar(255) NOT NULL DEFAULT '', + PRIMARY KEY ("id") +); + + +CREATE TABLE IF NOT EXISTS "#__bfstop_knownip" ( + "id" serial NOT NULL, + "ipaddress" varchar(45) NOT NULL, + "username" varchar(150) NOT NULL, + "first_success" timestamp without time zone NOT NULL, + "last_success" timestamp without time zone NOT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "#__bfstop_knownip_ip_username" UNIQUE ("ipaddress", "username") +); + + +CREATE TABLE IF NOT EXISTS "#__bfstop_dnscache" ( + "ipaddress" varchar(45) NOT NULL, + "hostname" varchar(255) DEFAULT NULL, + "checked_at" timestamp without time zone NOT NULL, + PRIMARY KEY ("ipaddress") +); + + +CREATE TABLE IF NOT EXISTS "#__bfstop_username_stats" ( + "username" varchar(150) NOT NULL, + "attempts" integer NOT NULL DEFAULT 0, + "first_attempt" timestamp without time zone NOT NULL, + "last_attempt" timestamp without time zone NOT NULL, + PRIMARY KEY ("username") +); +CREATE INDEX IF NOT EXISTS "#__bfstop_username_stats_attempts" ON "#__bfstop_username_stats" ("attempts"); +CREATE INDEX IF NOT EXISTS "#__bfstop_username_stats_last_attempt" ON "#__bfstop_username_stats" ("last_attempt"); diff --git a/sql/uninstall.postgresql.utf8.sql b/sql/uninstall.postgresql.utf8.sql new file mode 100644 index 0000000..44c197f --- /dev/null +++ b/sql/uninstall.postgresql.utf8.sql @@ -0,0 +1,17 @@ +-- uninstall script for bfstop plugin, PostgreSQL version + +DROP TABLE IF EXISTS "#__bfstop_failedlogin"; + +DROP TABLE IF EXISTS "#__bfstop_bannedip"; + +DROP TABLE IF EXISTS "#__bfstop_unblock"; + +DROP TABLE IF EXISTS "#__bfstop_unblock_token"; + +DROP TABLE IF EXISTS "#__bfstop_allowlist"; + +DROP TABLE IF EXISTS "#__bfstop_knownip"; + +DROP TABLE IF EXISTS "#__bfstop_dnscache"; + +DROP TABLE IF EXISTS "#__bfstop_username_stats"; diff --git a/sql/updates/postgresql/2.0.0.sql b/sql/updates/postgresql/2.0.0.sql new file mode 100644 index 0000000..0ca1a70 --- /dev/null +++ b/sql/updates/postgresql/2.0.0.sql @@ -0,0 +1 @@ +-- PostgreSQL support starts with version 2.0.0, see install.postgresql.utf8.sql diff --git a/sql/updates/postgresql/index.html b/sql/updates/postgresql/index.html new file mode 100644 index 0000000..0dc101b --- /dev/null +++ b/sql/updates/postgresql/index.html @@ -0,0 +1 @@ + diff --git a/src/Helper/DatabaseHelper.php b/src/Helper/DatabaseHelper.php index 1a8c9f6..6d4691a 100644 --- a/src/Helper/DatabaseHelper.php +++ b/src/Helper/DatabaseHelper.php @@ -32,6 +32,27 @@ public function getClientString($id) return ($id == 0) ? 'Frontend' : 'Backend'; } + // $time (as written by date("Y-m-d H:i:s")) moved back by $minutes; + // computed in PHP so that no database-specific date arithmetic (like + // MySQL's DATE_SUB) is needed, see issue #206 + private static function minutesBefore($time, $minutes) + { + return date("Y-m-d H:i:s", strtotime($time) - ((int)$minutes) * 60); + } + + // SQL expression for $dateExpr + $minutesExpr minutes, for the cases + // where the number of minutes comes from a column and can therefore not + // be computed in PHP. Neither DATE_ADD nor Joomla's + // DatabaseQuery::dateAdd() work on PostgreSQL for this (issue #206) + private function addMinutesSql($dateExpr, $minutesExpr) + { + if ($this->db->getServerType() === 'postgresql') + { + return '('.$dateExpr.' + '.$minutesExpr." * INTERVAL '1 minute')"; + } + return 'DATE_ADD('.$dateExpr.', INTERVAL '.$minutesExpr.' MINUTE)'; + } + public function __construct(LoggerHelper $logger) { $this->db = Factory::getDbo(); @@ -63,9 +84,9 @@ public function eventsInInterval( // check if in the last $interval hours, $number incidents have occured already: $sql = "SELECT COUNT(*) FROM ".$table." t ". "WHERE t.".$timecol. - " between DATE_SUB(". - $this->db->quote($time). - ", INTERVAL $interval MINUTE) AND ". + " between ". + $this->db->quote(self::minutesBefore($time, $interval)). + " AND ". $this->db->quote($time). " ".$additionalWhere; $this->db->setQuery($sql); @@ -108,9 +129,8 @@ public function getFailedLoginsInLastHour() { $nowDateTime = date("Y-m-d H:i:s"); $sql = "SELECT COUNT(*) FROM #__bfstop_failedlogin ". - "WHERE logtime > DATE_SUB(". - $this->db->quote($nowDateTime). - ", INTERVAL 1 HOUR)"; + "WHERE logtime > ". + $this->db->quote(self::minutesBefore($nowDateTime, 60)); $this->db->setQuery($sql); return $this->db->loadResult(); } @@ -139,8 +159,8 @@ public function getFormattedFailedList($ipAddress, $curTime, $interval) $sql = "SELECT * FROM #__bfstop_failedlogin t where ipaddress=". $this->db->quote($ipAddress). " AND t.logtime". - " between DATE_SUB(".$this->db->quote($curTime). - ", INTERVAL $interval MINUTE) AND ". + " between ".$this->db->quote(self::minutesBefore($curTime, $interval)). + " AND ". $this->db->quote($curTime); $this->db->setQuery($sql); $entries = $this->db->loadObjectList(); @@ -165,126 +185,39 @@ public function getFormattedFailedList($ipAddress, $curTime, $interval) } } - public function ipAddressMatch($ipaddress) - { - // literal match - return - "(". - "ipaddress=".$this->db->quote($ipaddress)." AND ". - "LOCATE('/', ipaddress) = 0". - ")"; - } - - public function ipSubNetIPv4Match($ipaddress) - { - $DashPos = 'LOCATE("/", ipaddress)'; - $SubNetAddress = 'SUBSTR(ipaddress, 1, LOCATE("/", ipaddress)-1)'; - $BitsText = 'SUBSTR(ipaddress, '.$DashPos.'+1, LENGTH(ipaddress)-'.$DashPos.')'; - // the original mask expression used $BitsText directly as a - // string in "32 - SUBSTR(...)"; MySQL/MariaDB then coerces that - // implicitly to a DOUBLE, and if a stored row has a corrupted - // prefix length (huge or malformed - e.g. hand-edited in the DB, - // or an old bug that let one in), "32 - " produces a DOUBLE - // whose magnitude overflows BIGINT UNSIGNED once the shift - // operator converts it, raising "BIGINT UNSIGNED value is out of - // range" for every query against the whole table (#134). - $RawBits = 'CAST('.$BitsText.' AS SIGNED)'; - // clamped to [0,32] before any further arithmetic, so a corrupted - // row can never blow up the mask math into an out-of-range error - - // same fix as ipSubNetIPv6Match() below (#142). The BitsValid - // guard further down keeps such a clamped-but-bogus row from - // silently matching as a wildcard. - $Bits = 'GREATEST(LEAST('.$RawBits.', 32), 0)'; - $IPv4NetMask = '~((1 << (32 - '.$Bits.'))-1)'; - // rejects a corrupted prefix length outright (rather than letting - // the clamp above silently turn it into a "/0" wildcard match) - - // the digit-count-limited REGEXP is itself overflow-safe, so this - // check never needs the clamped value to decide validity - $BitsValid = $BitsText." REGEXP '^[0-9]{1,2}$' AND ".$RawBits." BETWEEN 0 AND 32"; - return - "(". - // IPv4 subnet match (CIDR Suffix notation) - "(". - "LOCATE('/', ipaddress) != 0 AND LOCATE('.', ipaddress) != 0 AND ". - $BitsValid." AND ". - "(INET_ATON(".$this->db->quote($ipaddress).") & ".$IPv4NetMask.")". - " = ". - "(INET_ATON(".$SubNetAddress.") & ".$IPv4NetMask.")". - ")". - ")"; - } - - // splits a stored INET6_ATON() value into its high/low 8-byte halves and - // widens each to a plain BIGINT UNSIGNED, since MySQL's bitwise operators - // only work on 64-bit integers, not on 16-byte VARBINARY values directly - // (see issue #142/#117 - a 128-bit mask can't be applied in one step) - private function inet6HalfAsUnsigned($ipExpr, $startByte) - { - return 'CAST(CONV(HEX(SUBSTR(INET6_ATON('.$ipExpr.'), '.$startByte.', 8)), 16, 10) AS UNSIGNED)'; - } - - public function ipSubNetIPv6Match($ipaddress) - { - $ipQuoted = $this->db->quote($ipaddress); - $DashPos = 'LOCATE("/", ipaddress)'; - $SubNetAddress = 'SUBSTR(ipaddress, 1, '.$DashPos.'-1)'; - $BitsText = 'SUBSTR(ipaddress, '.$DashPos.'+1, LENGTH(ipaddress)-'.$DashPos.')'; - // signed, not unsigned: bits-64 goes negative for a /0..'/63 prefix, - // and an UNSIGNED subtraction underflowing below 0 raises "BIGINT - // UNSIGNED out of range" in strict mode (this is the #117 crash). - $RawBits = 'CAST('.$BitsText.' AS SIGNED)'; - // clamped to [0,128] before any further arithmetic, so a row with a - // corrupted prefix length (e.g. hand-edited in the DB) can never - // blow up the '-64'/shift math below into an out-of-range error - - // see #134, which hit this exact class of bug in the analogous - // IPv4 query. The BitsValid guard further down keeps such a - // clamped-but-bogus row from silently matching as a wildcard. - $Bits = 'GREATEST(LEAST('.$RawBits.', 128), 0)'; - - // the 128-bit prefix length is applied as two independent 64-bit - // masks, one per half of the address - $HiBits = 'LEAST('.$Bits.', 64)'; - $LoBits = 'GREATEST('.$Bits.' - 64, 0)'; - $HiMask = '(0xFFFFFFFFFFFFFFFF << (64 - '.$HiBits.'))'; - $LoMask = '(0xFFFFFFFFFFFFFFFF << (64 - '.$LoBits.'))'; - - // rejects a corrupted prefix length outright (rather than letting - // the clamp above silently turn it into a "/0" wildcard match) - - // the digit-count-limited REGEXP is itself overflow-safe, so this - // check never needs the clamped value to decide validity - $BitsValid = $BitsText." REGEXP '^[0-9]{1,3}$' AND ".$RawBits." BETWEEN 0 AND 128"; - - return - "(". - // IPv6 subnet match (CIDR Suffix notation) - "(". - "LOCATE('/', ipaddress) != 0 AND LOCATE(':', ipaddress) != 0 AND ". - $BitsValid." AND ". - "INET6_ATON(".$ipQuoted.") IS NOT NULL AND LENGTH(INET6_ATON(".$ipQuoted.")) = 16 AND ". - "INET6_ATON(".$SubNetAddress.") IS NOT NULL AND LENGTH(INET6_ATON(".$SubNetAddress.")) = 16 AND ". - "(".$this->inet6HalfAsUnsigned($ipQuoted, 1)." & ".$HiMask.")". - " = ". - "(".$this->inet6HalfAsUnsigned($SubNetAddress, 1)." & ".$HiMask.")". - " AND ". - "(".$this->inet6HalfAsUnsigned($ipQuoted, 9)." & ".$LoMask.")". - " = ". - "(".$this->inet6HalfAsUnsigned($SubNetAddress, 9)." & ".$LoMask.")". - ")". - ")"; - } - - private function loadMatchingEntries($sql, $action) + /** + * Loads the rows of $table (aliased t, needs id and ipaddress columns) + * which match the given IP address, either literally or as part of a + * stored IPv4/IPv6 subnet (CIDR notation). Only the literal match is done + * in SQL; subnet entries are matched in PHP (IpHelper::isInSubnet), which + * works the same on every database - the previous SQL implementation + * relied on MySQL-only functions (INET_ATON, INET6_ATON, LOCATE, REGEXP, + * ...), see issue #206, and was the source of #117, #134 and #142. + */ + private function loadMatchingEntries($ipaddress, $table, $additionalWhere, $action) { try { + // LOWER: IPv6 addresses may be stored in upper case; MySQL's + // default collations compare case-insensitively, PostgreSQL doesn't + $sql = "SELECT t.id, t.ipaddress FROM ".$table." t WHERE ". + "((t.ipaddress NOT LIKE '%/%' AND LOWER(t.ipaddress) = LOWER(".$this->db->quote($ipaddress)."))". + " OR t.ipaddress LIKE '%/%')". + $additionalWhere; $this->db->setQuery($sql); - $entries = $this->db->loadObjectList(); - foreach ($entries as $entry) + $entries = array(); + foreach ($this->db->loadObjectList() as $entry) { + if (strpos($entry->ipaddress, '/') !== false && + !IpHelper::isInSubnet($ipaddress, $entry->ipaddress)) + { + continue; + } $this->logger->log($action." because of entry: ". "id=".$entry->id.", ". "ipaddress=".$entry->ipaddress, Log::DEBUG); + $entries[] = $entry; } return $entries; } @@ -295,33 +228,22 @@ private function loadMatchingEntries($sql, $action) } } - private function checkForEntries($sql, $action) - { - return count($this->loadMatchingEntries($sql, $action)); - } - /** * IDs of all currently active blocks matching the given IP address, * whether as single address or as part of a blocked IPv4/IPv6 subnet. */ public function getActiveBlockIds($ipaddress) { - $sqlCheckPattern = "SELECT id, ipaddress, crdate, duration FROM #__bfstop_bannedip b WHERE ". - "%s AND (b.duration=0 OR DATE_ADD(b.crdate, INTERVAL b.duration MINUTE) >= ". + $activeWhere = " AND (t.duration=0 OR ". + $this->addMinutesSql('t.crdate', 't.duration')." >= ". $this->db->quote(date("Y-m-d H:i:s")).")". - " AND NOT EXISTS (SELECT 1 FROM #__bfstop_unblock u WHERE b.id = u.block_id)"; + " AND NOT EXISTS (SELECT 1 FROM #__bfstop_unblock u WHERE t.id = u.block_id)"; $ids = array(); - foreach (array( - $this->ipAddressMatch($ipaddress), - $this->ipSubNetIPv4Match($ipaddress), - $this->ipSubNetIPv6Match($ipaddress)) as $matchExpr) + foreach ($this->loadMatchingEntries($ipaddress, '#__bfstop_bannedip', $activeWhere, "Blocked") as $entry) { - foreach ($this->loadMatchingEntries(sprintf($sqlCheckPattern, $matchExpr), "Blocked") as $entry) - { - $ids[] = (int)$entry->id; - } + $ids[] = (int)$entry->id; } - return array_values(array_unique($ids)); + return $ids; } public function isIPBlocked($ipaddress) @@ -357,14 +279,7 @@ public function recordBlockedAttempt($blockIds) public function isIPOnAllowList($ipaddress) { - $sqlCheckPattern = "SELECT id, ipaddress from #__bfstop_allowlist WHERE %s"; - $sqlIPCheck = sprintf($sqlCheckPattern, $this->ipAddressMatch($ipaddress)); - $sqlSubNetIPv4Check = sprintf($sqlCheckPattern, $this->ipSubNetIPv4Match($ipaddress)); - $sqlSubNetIPv6Check = sprintf($sqlCheckPattern, $this->ipSubNetIPv6Match($ipaddress)); - $entryCount = $this->checkForEntries($sqlIPCheck, "Allowed"); - $entryCount += $this->checkForEntries($sqlSubNetIPv4Check, "Allowed"); - $entryCount += $this->checkForEntries($sqlSubNetIPv6Check, "Allowed"); - return ($entryCount > 0); + return count($this->loadMatchingEntries($ipaddress, '#__bfstop_allowlist', '', "Allowed")) > 0; } public function blockIP($logEntry, $duration, $usehtaccess, $htaccessPath) @@ -510,8 +425,17 @@ private function recordUsernameAttempt($username, $logtime) $time = $this->db->quote($logtime); $sql = 'INSERT INTO #__bfstop_username_stats'. ' (username, attempts, first_attempt, last_attempt) VALUES ('. - $this->db->quote($username).', 1, '.$time.', '.$time.')'. - ' ON DUPLICATE KEY UPDATE attempts=attempts+1, last_attempt='.$time; + $this->db->quote($username).', 1, '.$time.', '.$time.')'; + // upsert: ON DUPLICATE KEY UPDATE is MySQL-only (issue #206) + if ($this->db->getServerType() === 'postgresql') + { + $sql .= ' ON CONFLICT (username) DO UPDATE SET'. + ' attempts=#__bfstop_username_stats.attempts+1, last_attempt='.$time; + } + else + { + $sql .= ' ON DUPLICATE KEY UPDATE attempts=attempts+1, last_attempt='.$time; + } $this->db->setQuery($sql); $this->db->execute(); } @@ -676,15 +600,14 @@ public function purgeOldEntries($purgeAgeWeeks) // all timestamps are written with PHP's date(), so compare against // the same clock instead of the database's NOW(), which may use a // different time zone - $now = $this->db->quote(date("Y-m-d H:i:s")); - $deleteDate = 'DATE_SUB('.$now. - ', INTERVAL '.((int) $purgeAgeWeeks). - ' WEEK)'; + $now = date("Y-m-d H:i:s"); + $deleteDate = $this->db->quote(self::minutesBefore($now, + ((int) $purgeAgeWeeks) * 7 * 24 * 60)); $this->db->setQuery('DELETE FROM #__bfstop_failedlogin WHERE logtime < '.$deleteDate); $this->db->execute(); - $this->db->setQuery('DELETE FROM #__bfstop_bannedip WHERE duration != 0 AND - DATE_ADD(crdate, INTERVAL duration MINUTE) < '.$deleteDate); + $this->db->setQuery('DELETE FROM #__bfstop_bannedip WHERE duration != 0 AND '. + $this->addMinutesSql('crdate', 'duration').' < '.$deleteDate); $this->db->execute(); $this->db->setQuery('DELETE FROM #__bfstop_unblock WHERE NOT EXISTS '. @@ -699,7 +622,7 @@ public function purgeOldEntries($purgeAgeWeeks) // admin-configured purge age above - this just reclaims the // storage for rows nothing will ever read as valid again. $this->db->setQuery('DELETE FROM #__bfstop_dnscache WHERE checked_at < '. - 'DATE_SUB('.$now.', INTERVAL '.self::$DNS_CACHE_TTL_DAYS.' DAY)'); + $this->db->quote(self::minutesBefore($now, self::$DNS_CACHE_TTL_DAYS * 24 * 60))); $this->db->execute(); } catch (\Exception $e) @@ -713,9 +636,11 @@ public function saveParams($params) try { $query = $this->db->getQuery(true); - $query->update('#__extensions AS a'); - $query->set('a.params = '. $this->db->quote((string)$params)); - $query->where('a.element = '.$this->db->quote('bfstop')); + // no table alias here: PostgreSQL doesn't allow qualified column + // names in the SET clause (issue #206) + $query->update($this->db->quoteName('#__extensions')); + $query->set($this->db->quoteName('params').' = '. $this->db->quote((string)$params)); + $query->where($this->db->quoteName('element').' = '.$this->db->quote('bfstop')); $this->db->setQuery($query); $this->db->execute(); } diff --git a/src/Helper/IpHelper.php b/src/Helper/IpHelper.php index 73f8d8f..ef8b8e4 100644 --- a/src/Helper/IpHelper.php +++ b/src/Helper/IpHelper.php @@ -33,6 +33,45 @@ class IpHelper 'HTTP_CLIENT_IP', ); + /** + * Whether $ip lies within $subnet, given in CIDR notation (e.g. + * "192.0.2.0/24" or "2001:db8::/32"). Anything malformed - an address + * that doesn't parse, IPv4 vs. IPv6 mismatch, or a prefix length that + * isn't a plain number within range for the address family - never + * matches, so a corrupted stored entry can't turn into a wildcard. + */ + public static function isInSubnet($ip, $subnet) + { + $parts = explode('/', $subnet); + if (count($parts) !== 2 || !preg_match('/^[0-9]{1,3}$/', $parts[1])) + { + return false; + } + $ipBin = @inet_pton($ip); + $subnetBin = @inet_pton($parts[0]); + if ($ipBin === false || $subnetBin === false || strlen($ipBin) !== strlen($subnetBin)) + { + return false; + } + $bits = (int)$parts[1]; + if ($bits > strlen($ipBin) * 8) + { + return false; + } + $fullBytes = intdiv($bits, 8); + if (substr($ipBin, 0, $fullBytes) !== substr($subnetBin, 0, $fullBytes)) + { + return false; + } + $remainingBits = $bits % 8; + if ($remainingBits === 0) + { + return true; + } + $mask = (0xff << (8 - $remainingBits)) & 0xff; + return (ord($ipBin[$fullBytes]) & $mask) === (ord($subnetBin[$fullBytes]) & $mask); + } + private static function firstValidPublicIP($headerValue) { foreach (explode(',', $headerValue) as $ip) diff --git a/tests/Integration/ComponentTest.php b/tests/Integration/ComponentTest.php new file mode 100644 index 0000000..8803724 --- /dev/null +++ b/tests/Integration/ComponentTest.php @@ -0,0 +1,132 @@ + $this->db), new MVCFactory('Codeling\\Component\\Bfstop')); + $model->setDatabase($this->db); + return $model; + } + + private function listCount($class) + { + $model = $this->model($class); + $getListQuery = new \ReflectionMethod($model, 'getListQuery'); + $this->db->setQuery($getListQuery->invoke($model)); + return count($this->db->loadObjectList()); + } + + /** + * Saves a new entry like the component's edit views do; the id comes + * empty from the form when creating a new entry + */ + private function saveNew($modelClass, array $data) + { + $model = $this->model($modelClass); + $model->getState(); // populate the state now, so it doesn't overwrite the saved id later + $this->assertTrue($model->save(array('id' => '') + $data), implode(', ', $model->getErrors())); + return (int) $model->getState($model->getName().'.id'); + } + + public function testTablesAndListViews() + { + $blockId = $this->saveNew(BlockModel::class, + array('ipaddress' => '192.0.2.0/24', 'crdate' => '2026-01-02', 'duration' => '0')); + $allowId = $this->saveNew(AllowModel::class, + array('ipaddress' => '198.51.100.0/24', 'notes' => 'test')); + $this->assertGreaterThan(0, $blockId); + $this->assertGreaterThan(0, $allowId); + $this->insert('#__bfstop_unblock', array('block_id' => $blockId, 'source' => 0, 'crdate' => self::minutesAgo(0))); + $this->insert('#__bfstop_failedlogin', array('ipaddress' => '192.0.2.1', 'logtime' => self::minutesAgo(0), 'username' => 'bob', 'origin' => 0)); + + $this->assertSame(1, $this->listCount(BlocklistModel::class)); + $this->assertSame(1, $this->listCount(AllowlistModel::class)); + $this->assertSame(1, $this->listCount(FailedloginlistModel::class)); + + $this->model(AllowlistModel::class)->remove(array($allowId), $this->logger); + $this->assertSame(0, $this->listCount(AllowlistModel::class)); + } + + public function testPurgeFailedLogins() + { + $this->insert('#__bfstop_failedlogin', array('ipaddress' => '192.0.2.1', 'logtime' => self::minutesAgo(31 * 24 * 60), 'username' => 'old', 'origin' => 0)); + $this->insert('#__bfstop_failedlogin', array('ipaddress' => '192.0.2.1', 'logtime' => self::minutesAgo(0), 'username' => 'new', 'origin' => 0)); + $this->assertSame(1, $this->model(FailedloginlistModel::class)->purgeOlderThan(30)); + $this->assertSame('new', $this->queryValue('SELECT username FROM #__bfstop_failedlogin')); + } + + public function testUsernameStatistics() + { + $this->insert('#__bfstop_username_stats', array('username' => 'Admin', 'attempts' => 12, + 'first_attempt' => self::minutesAgo(3 * 24 * 60), 'last_attempt' => self::minutesAgo(60))); + $this->insert('#__bfstop_username_stats', array('username' => 'old', 'attempts' => 3, + 'first_attempt' => self::minutesAgo(40 * 24 * 60), 'last_attempt' => self::minutesAgo(31 * 24 * 60))); + + $this->assertSame(2, $this->listCount(UsernamestatsModel::class)); + $model = $this->model(UsernamestatsModel::class); + $this->assertSame(12, $model->getMaxAttempts()); + $this->assertSame(1, $model->purgeNotSeenFor(30)); + $this->assertSame('Admin', $this->queryValue('SELECT username FROM #__bfstop_username_stats')); + } + + public function testUnblockViaEmailToken() + { + $blockId = $this->insert('#__bfstop_bannedip', array('ipaddress' => '203.0.113.5', 'crdate' => self::minutesAgo(0), 'duration' => 60), 'id'); + $this->insert('#__bfstop_unblock_token', array('token' => 'expired', 'block_id' => $blockId, 'crdate' => self::minutesAgo(4 * 24 * 60))); + $this->insert('#__bfstop_unblock_token', array('token' => 'valid', 'block_id' => $blockId, 'crdate' => self::minutesAgo(60))); + $helper = new DatabaseHelper($this->logger); + $this->assertTrue($helper->isIPBlocked('203.0.113.5')); + + $model = $this->model(TokenunblockModel::class); + $this->assertFalse((bool) $model->unblock('expired', $this->logger), 'expired token must not unblock'); + $this->logger->errors = array(); // expected: "token not found" error + $this->assertTrue((bool) $model->unblock('valid', $this->logger)); + + $this->assertFalse($helper->isIPBlocked('203.0.113.5')); + $this->assertSame(1, (int) $this->queryValue('SELECT source FROM #__bfstop_unblock WHERE block_id='.$blockId)); + $this->assertSame(0, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_unblock_token')); + } + + public function testWarnsAboutAdminUserCaseInsensitively() + { + // tests/ci/install-joomla.sh creates the super user as "Admin" + $app = Factory::getApplication(); + $app->getMessageQueue(true); + $controller = new DisplayController(array('base_path' => JPATH_ADMINISTRATOR.'/components/com_bfstop'), new MVCFactory('Codeling\\Component\\Bfstop'), $app, $app->getInput()); + $controller->warnIfAdminUserExists(); + $this->assertCount(1, $app->getMessageQueue(true)); + } +} diff --git a/tests/Integration/DatabaseHelperTest.php b/tests/Integration/DatabaseHelperTest.php new file mode 100644 index 0000000..d22c2ed --- /dev/null +++ b/tests/Integration/DatabaseHelperTest.php @@ -0,0 +1,221 @@ +helper = new DatabaseHelper($this->logger); + } + + private function failedLogin($ip, $username, $minutesAgo, $origin = 0) + { + $this->helper->insertFailedLogin((object) array('ipaddress' => $ip, + 'logtime' => self::minutesAgo($minutesAgo), 'username' => $username, 'origin' => $origin)); + } + + private function block($ip, $minutesAgo, $duration) + { + return $this->insert('#__bfstop_bannedip', array('ipaddress' => $ip, + 'crdate' => self::minutesAgo($minutesAgo), 'duration' => $duration), 'id'); + } + + private function activeBlocks($ip) + { + $ids = $this->helper->getActiveBlockIds($ip); + sort($ids); + return $ids; + } + + public function testFailedLoginCounting() + { + $now = self::minutesAgo(0); + foreach (array(0, 5, 30, 120) as $minutesAgo) + { + $this->failedLogin('203.0.113.5', 'bob', $minutesAgo); + } + $this->failedLogin('203.0.113.99', 'bob', 1, 1); + $this->failedLogin('203.0.113.5', 'eve', 8 * 24 * 60); + + $this->assertSame(3, $this->helper->getNumberOfFailedLogins(60, '203.0.113.5', $now)); + $this->assertSame(4, $this->helper->getNumberOfFailedLogins(180, '203.0.113.5', $now)); + $this->assertSame(4, $this->helper->getNumberOfFailedLoginsForUsername(60, 'bob', $now)); + $this->assertSame(4, (int) $this->helper->getFailedLoginsInLastHour()); + $list = $this->helper->getFormattedFailedList('203.0.113.5', $now, 60); + $this->assertSame(3 + 2, substr_count($list, "\n"), "2 header lines + 3 entries:\n".$list); + + $this->helper->setFailedLoginHandled((object) array('ipaddress' => '203.0.113.5', 'username' => 'bob'), true); + $this->assertSame(0, $this->helper->getNumberOfFailedLogins(60, '203.0.113.5', $now)); + $this->assertSame(1, $this->helper->getNumberOfFailedLoginsForUsername(60, 'bob', $now), 'other IP not handled'); + } + + public function testUsernameStatistics() + { + $this->failedLogin('203.0.113.5', 'bob', 30); + $this->failedLogin('203.0.113.6', 'bob', 20); + $this->failedLogin('203.0.113.5', 'bob', 10); + $this->failedLogin('203.0.113.5', 'eve', 5); + + $this->db->setQuery('SELECT username, attempts, first_attempt, last_attempt FROM #__bfstop_username_stats ORDER BY username'); + $rows = $this->db->loadObjectList(); + $this->assertCount(2, $rows); + $this->assertSame(array('bob', 3), array($rows[0]->username, (int) $rows[0]->attempts)); + $this->assertSame(self::minutesAgo(30), substr($rows[0]->first_attempt, 0, 19)); + $this->assertSame(self::minutesAgo(10), substr($rows[0]->last_attempt, 0, 19)); + $this->assertSame(array('eve', 1), array($rows[1]->username, (int) $rows[1]->attempts)); + + // not removed by the automatic purge, unlike the failed logins themselves + $this->insert('#__bfstop_username_stats', array('username' => 'old', 'attempts' => 7, + 'first_attempt' => self::minutesAgo(90 * 24 * 60), 'last_attempt' => self::minutesAgo(80 * 24 * 60))); + $this->helper->purgeOldEntries(1); + $this->assertSame(3, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_username_stats')); + } + + public function testActiveBlocks() + { + $exact = $this->block('203.0.113.5', 0, 60); + $this->block('203.0.113.6', 120, 60); // expired + $unlimited = $this->block('203.0.113.7', 500000, 0); + $net4 = $this->block('198.51.100.0/24', 0, 0); + $net4small = $this->block('203.0.113.4/31', 0, 0); + $net6 = $this->block('2001:DB8:AB::/48', 0, 60); + $exact6 = $this->block('2001:DB8::9', 0, 60); + $this->block('192.0.2.0/99', 0, 0); // corrupted prefix lengths + $this->block('192.0.2.0/abc', 0, 0); + $unblocked = $this->block('203.0.113.8', 0, 0); + $this->insert('#__bfstop_unblock', array('block_id' => $unblocked, 'source' => 0, 'crdate' => self::minutesAgo(0))); + + $this->assertSame(array($exact, $net4small), $this->activeBlocks('203.0.113.5')); + $this->assertSame(array(), $this->activeBlocks('203.0.113.6'), 'expired'); + $this->assertSame(array($unlimited), $this->activeBlocks('203.0.113.7')); + $this->assertSame(array($net4), $this->activeBlocks('198.51.100.200')); + $this->assertSame(array(), $this->activeBlocks('198.51.101.1')); + $this->assertSame(array($net6), $this->activeBlocks('2001:db8:ab:1::5')); + $this->assertSame(array(), $this->activeBlocks('2001:db8:ac::1')); + $this->assertSame(array($exact6), $this->activeBlocks('2001:db8::9'), 'stored in upper case'); + $this->assertSame(array(), $this->activeBlocks('192.0.2.1'), 'corrupted entries must not match'); + $this->assertSame(array(), $this->activeBlocks('203.0.113.8'), 'unblocked'); + $this->assertTrue($this->helper->isIPBlocked('198.51.100.1')); + $this->assertFalse($this->helper->isIPBlocked('192.0.2.200')); + } + + public function testNumberOfPreviousBlocks() + { + $this->block('203.0.113.7', 500000, 0); + $this->block('203.0.113.7', 10, 60); + $manuallyUnblocked = $this->block('203.0.113.8', 10, 60); + $this->insert('#__bfstop_unblock', array('block_id' => $manuallyUnblocked, 'source' => 0, 'crdate' => self::minutesAgo(0))); + $unblockedByMail = $this->block('203.0.113.9', 10, 60); + $this->insert('#__bfstop_unblock', array('block_id' => $unblockedByMail, 'source' => 1, 'crdate' => self::minutesAgo(0))); + + $this->assertSame(2, $this->helper->getNumberOfPreviousBlocks('203.0.113.7')); + $this->assertSame(0, $this->helper->getNumberOfPreviousBlocks('203.0.113.8')); + $this->assertSame(1, $this->helper->getNumberOfPreviousBlocks('203.0.113.9')); + } + + public function testBlockIPAndRecordAttempts() + { + $this->failedLogin('203.0.113.50', 'bob', 0); + $id = $this->helper->blockIP((object) array('ipaddress' => '203.0.113.50', 'username' => 'bob'), 30, false, ''); + $this->assertGreaterThan(0, $id); + $this->assertSame(array($id), $this->activeBlocks('203.0.113.50')); + $this->assertSame(0, $this->helper->getNumberOfFailedLogins(60, '203.0.113.50', self::minutesAgo(0)), + 'failed logins are marked handled when blocking'); + + $this->helper->recordBlockedAttempt(array($id)); + $this->helper->recordBlockedAttempt(array($id)); + $this->db->setQuery('SELECT attempts, last_attempt FROM #__bfstop_bannedip WHERE id='.$id); + $row = $this->db->loadObject(); + $this->assertSame(2, (int) $row->attempts); + $this->assertNotNull($row->last_attempt); + } + + public function testAllowList() + { + $this->insert('#__bfstop_allowlist', array('ipaddress' => '10.1.0.0/16', 'notes' => '')); + $this->insert('#__bfstop_allowlist', array('ipaddress' => '2001:DB8:FFFF::1', 'notes' => '')); + $this->insert('#__bfstop_allowlist', array('ipaddress' => '198.51.100.3', 'notes' => '')); + + $this->assertTrue($this->helper->isIPOnAllowList('10.1.2.3')); + $this->assertFalse($this->helper->isIPOnAllowList('10.2.0.1')); + $this->assertTrue($this->helper->isIPOnAllowList('2001:db8:ffff::1')); + $this->assertTrue($this->helper->isIPOnAllowList('198.51.100.3')); + $this->assertFalse($this->helper->isIPOnAllowList('198.51.100.30')); + } + + public function testUnblockToken() + { + $this->assertSame('tokenA', $this->helper->getNewUnblockToken(1, 'tokenA')); + $this->assertTrue($this->helper->unblockTokenExists('tokenA')); + $this->assertFalse($this->helper->unblockTokenExists('tokenB')); + } + + public function testKnownIpUsername() + { + $login = (object) array('ipaddress' => '203.0.113.5', 'username' => 'bob'); + $this->helper->successfulLogin($login); + $this->helper->successfulLogin($login); // second time: update instead of insert + $this->assertTrue($this->helper->isKnownIpUsername('203.0.113.5', 'bob')); + $this->assertFalse($this->helper->isKnownIpUsername('203.0.113.5', 'eve')); + $this->assertFalse($this->helper->isKnownIpUsername('203.0.113.6', 'bob')); + $this->assertSame(1, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_knownip')); + } + + public function testDnsCache() + { + $this->assertFalse($this->helper->getCachedHostname('203.0.113.5'), 'not cached'); + $this->helper->cacheHostname('203.0.113.5', 'host.example'); + $this->assertSame('host.example', $this->helper->getCachedHostname('203.0.113.5')); + $this->helper->cacheHostname('203.0.113.5', null); + $this->assertNull($this->helper->getCachedHostname('203.0.113.5'), 'cached "no PTR record"'); + } + + public function testPurgeOldEntries() + { + $this->failedLogin('203.0.113.5', 'old', 8 * 24 * 60); + $this->failedLogin('203.0.113.5', 'recent', 60); + $oldExpiredBlock = $this->block('203.0.113.60', 10 * 24 * 60, 60); + $oldUnlimitedBlock = $this->block('203.0.113.61', 10 * 24 * 60, 0); + $this->insert('#__bfstop_unblock', array('block_id' => $oldExpiredBlock, 'source' => 0, 'crdate' => self::minutesAgo(0))); + $this->insert('#__bfstop_unblock_token', array('token' => 'old', 'block_id' => $oldExpiredBlock, 'crdate' => self::minutesAgo(8 * 24 * 60))); + $this->insert('#__bfstop_dnscache', array('ipaddress' => '203.0.113.77', 'hostname' => null, 'checked_at' => self::minutesAgo(8 * 24 * 60))); + $this->insert('#__bfstop_dnscache', array('ipaddress' => '203.0.113.78', 'hostname' => null, 'checked_at' => self::minutesAgo(60))); + + $this->helper->purgeOldEntries(1); + + $this->assertSame('recent', $this->queryValue('SELECT username FROM #__bfstop_failedlogin')); + $this->assertSame(array($oldUnlimitedBlock), array_map('intval', $this->db->setQuery('SELECT id FROM #__bfstop_bannedip')->loadColumn())); + $this->assertSame(0, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_unblock'), 'unblock of purged block'); + $this->assertSame(0, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_unblock_token')); + $this->assertSame('203.0.113.78', $this->queryValue('SELECT ipaddress FROM #__bfstop_dnscache')); + } + + public function testSaveParams() + { + $original = $this->getPluginParams(); + try + { + $this->helper->saveParams(new Registry(array('marker' => 'saved-by-test'))); + $this->assertStringContainsString('saved-by-test', $this->getPluginParams()); + } + finally + { + $this->setPluginParams($original); + } + } +} diff --git a/tests/Integration/InstallTest.php b/tests/Integration/InstallTest.php new file mode 100644 index 0000000..92458c8 --- /dev/null +++ b/tests/Integration/InstallTest.php @@ -0,0 +1,42 @@ +db->getPrefix(); + $existing = array_map('strtolower', $this->db->getTableList()); + foreach (self::Tables as $table) + { + $this->assertContains($prefix.'bfstop_'.$table, $existing); + } + } + + public function testPluginEnabledAndSchemaVersionRecorded() + { + $this->db->setQuery("SELECT e.enabled, s.version_id FROM #__extensions e ". + "LEFT JOIN #__schemas s ON s.extension_id = e.extension_id ". + "WHERE e.type='plugin' AND e.element='bfstop'"); + $row = $this->db->loadObject(); + $this->assertSame(1, (int) $row->enabled); + $manifest = simplexml_load_file(dirname(__DIR__, 2).'/bfstop.xml'); + $this->assertSame((string) $manifest->version, $row->version_id); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..8a66e23 --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,121 @@ +errors[] = $msg; + } + } +} + +/** + * Base class for tests running against a real Joomla site with bfstop + * installed (see tests/README.md); skipped if JOOMLA_ROOT isn't set. + */ +abstract class IntegrationTestCase extends TestCase +{ + public const Tables = array('failedlogin', 'bannedip', 'unblock', + 'unblock_token', 'allowlist', 'knownip', 'dnscache', 'username_stats'); + + protected $db; + protected $logger; + + public static function setUpBeforeClass(): void + { + if (!getenv('JOOMLA_ROOT')) + { + self::markTestSkipped('JOOMLA_ROOT not set - integration tests need an installed Joomla site, see tests/README.md'); + } + } + + protected function setUp(): void + { + $this->db = Factory::getDbo(); + $this->logger = new RecordingLogger(); + $this->emptyTables(); + } + + protected function emptyTables() + { + $existing = array_map('strtolower', $this->db->getTableList()); + foreach (self::Tables as $table) + { + if (!in_array($this->db->getPrefix().'bfstop_'.$table, $existing)) + { + $this->fail('Table #__bfstop_'.$table.' does not exist - the installation did not create it'); + } + $this->db->setQuery('DELETE FROM #__bfstop_'.$table); + $this->db->execute(); + } + } + + protected function assertPostConditions(): void + { + $this->assertSame(array(), $this->logger->errors, 'errors were logged'); + } + + /** 'Y-m-d H:i:s' timestamp $minutes ago */ + protected static function minutesAgo($minutes) + { + return date('Y-m-d H:i:s', time() - $minutes * 60); + } + + /** inserts a row, returns the new id if $key is given */ + protected function insert($table, array $row, $key = null) + { + $object = (object) $row; + $this->db->insertObject($table, $object, $key); + return $key ? (int) $object->$key : null; + } + + protected function queryValue($sql) + { + $this->db->setQuery($sql); + return $this->db->loadResult(); + } + + protected function getPluginParams() + { + return $this->queryValue("SELECT params FROM #__extensions WHERE type='plugin' AND element='bfstop'"); + } + + protected function setPluginParams($params) + { + $this->db->setQuery('UPDATE #__extensions SET params='.$this->db->quote($params). + ", enabled=1 WHERE type='plugin' AND element='bfstop'"); + $this->db->execute(); + } +} diff --git a/tests/Integration/PluginEventsTest.php b/tests/Integration/PluginEventsTest.php new file mode 100644 index 0000000..30eed89 --- /dev/null +++ b/tests/Integration/PluginEventsTest.php @@ -0,0 +1,124 @@ + 'full', + 'blockNumber' => 3, + 'checkInterval' => 60, + 'blockDuration' => 60, + 'delayDuration' => 0, + 'riskDelaySecondsPerPoint' => 0, + 'riskBlockNumberReductionPerPoint' => 0, + 'accountThrottleEnabled' => 0, + 'notifyBlockedNumber' => 0, + 'logLevel' => 8, // errors only + ); + + private static $originalParams; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + $db = Factory::getDbo(); + $db->setQuery("SELECT params FROM #__extensions WHERE type='plugin' AND element='bfstop'"); + self::$originalParams = $db->loadResult(); + $db->setQuery('UPDATE #__extensions SET params='.$db->quote(json_encode(self::Params)). + ", enabled=1 WHERE type='plugin' AND element='bfstop'"); + $db->execute(); + // forget the plugin list possibly already loaded with the old params + (new \ReflectionProperty(PluginHelper::class, 'plugins'))->setValue(null, null); + $app = Factory::getApplication(); + PluginHelper::importPlugin('system', 'bfstop', true, $app->getDispatcher()); + } + + public static function tearDownAfterClass(): void + { + if (self::$originalParams !== null) + { + $db = Factory::getDbo(); + $db->setQuery('UPDATE #__extensions SET params='.$db->quote(self::$originalParams). + " WHERE type='plugin' AND element='bfstop'"); + $db->execute(); + } + } + + private function dispatch($eventName, array $arguments) + { + Factory::getApplication()->getDispatcher()->dispatch($eventName, new Event($eventName, $arguments)); + } + + private function failedLogin($ip, $username) + { + $_SERVER['REMOTE_ADDR'] = $ip; + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0'; + $this->dispatch('onUserLoginFailure', array(array('username' => $username, 'status' => 4), array())); + } + + /** + * Runs a request from the given IP address in a separate process, since + * the plugin ends a blocked request with exit(); returns its output. + */ + private function requestFrom($ip) + { + $command = escapeshellarg(PHP_BINARY).' '.escapeshellarg(__DIR__.'/fixtures/request.php').' '.escapeshellarg($ip).' 2>&1'; + exec($command, $output, $exitCode); + $output = implode("\n", $output); + $this->assertSame(0, $exitCode, $output); + return $output; + } + + private function pluginLogErrors() + { + $file = Factory::getApplication()->get('log_path').'/plg_system_bfstop.log.php'; + return is_file($file) ? substr_count(file_get_contents($file), ' ERROR ') : 0; + } + + public function testBlocksAfterTooManyFailedLogins() + { + $logErrorsBefore = $this->pluginLogErrors(); + $ip = '203.0.113.21'; + $this->failedLogin($ip, 'Admin'); + $this->failedLogin($ip, 'Admin'); + $this->assertSame(2, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_failedlogin')); + $this->assertSame(0, (int) $this->queryValue('SELECT COUNT(*) FROM #__bfstop_bannedip')); + $this->failedLogin($ip, 'Admin'); + $this->assertSame($ip, $this->queryValue('SELECT ipaddress FROM #__bfstop_bannedip')); + + $this->assertStringContainsString('has been blocked', $this->requestFrom($ip)); + $this->assertSame(1, (int) $this->queryValue('SELECT attempts FROM #__bfstop_bannedip')); + $this->assertStringContainsString('NOT BLOCKED', $this->requestFrom('203.0.113.22')); + + $this->assertSame($logErrorsBefore, $this->pluginLogErrors(), 'plugin logged errors'); + } + + public function testSubnetBlockAndAllowList() + { + $this->insert('#__bfstop_bannedip', array('ipaddress' => '198.51.100.0/24', 'crdate' => self::minutesAgo(0), 'duration' => 0)); + $this->insert('#__bfstop_allowlist', array('ipaddress' => '198.51.100.128/25', 'notes' => '')); + $this->assertStringContainsString('has been blocked', $this->requestFrom('198.51.100.1')); + $this->assertStringContainsString('NOT BLOCKED', $this->requestFrom('198.51.100.200'), 'allow list wins'); + } + + public function testSuccessfulLoginRecordsKnownIp() + { + $_SERVER['REMOTE_ADDR'] = '203.0.113.30'; + $this->dispatch('onUserLogin', array(array('username' => 'Admin'), array())); + $this->assertSame('Admin', $this->queryValue("SELECT username FROM #__bfstop_knownip WHERE ipaddress='203.0.113.30'")); + } +} diff --git a/tests/Integration/TokenHelperTest.php b/tests/Integration/TokenHelperTest.php new file mode 100644 index 0000000..4559cd5 --- /dev/null +++ b/tests/Integration/TokenHelperTest.php @@ -0,0 +1,21 @@ +logger); + $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $token); + $this->assertNotSame($token, TokenHelper::getToken($this->logger)); + } +} diff --git a/tests/Integration/fixtures/request.php b/tests/Integration/fixtures/request.php new file mode 100644 index 0000000..006c42f --- /dev/null +++ b/tests/Integration/fixtures/request.php @@ -0,0 +1,23 @@ +getDispatcher()); +$app->getDispatcher()->dispatch('onAfterInitialise', new Event('onAfterInitialise', array())); +echo "NOT BLOCKED\n"; diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..911cf9c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,37 @@ +# Tests + +- `Unit/`: tests needing nothing but PHP and [PHPUnit](https://phpunit.de) 11. +- `Integration/`: tests against a real Joomla site with bfstop installed. They + run every database query of the plugin (and of the component, if + `COM_BFSTOP_ROOT` is set) on the site's database, and let the plugin react + to Joomla events as on a live site (failed logins, requests from blocked + addresses). Skipped if `JOOMLA_ROOT` is not set. + +The classes under test are always loaded from this checkout (and the +component checkout), not from the copies installed into the Joomla site, so +the site only needs to be set up again when the database schema changes. + +GitHub Actions runs everything on each push and pull request, on Joomla 5 and +6 with MySQL, MariaDB and PostgreSQL, see `.github/workflows/ci.yml`. + +## Running locally + +Unit tests only: + + phpunit -c tests/phpunit.xml.dist --testsuite unit + +For the integration tests, create an empty database, then let +`tests/ci/install-joomla.sh` set up a Joomla site with bfstop in it (it +downloads Joomla, installs it, then installs plugin and component from the +checkouts). **Everything in that database will be overwritten.** For example, +with PostgreSQL: + + export JOOMLA_VERSION=5.4.8 JOOMLA_ROOT=/tmp/bfstop-joomla \ + COM_BFSTOP_ROOT=/path/to/com_bfstop \ + DB_TYPE=pgsql DB_HOST=127.0.0.1 DB_USER=postgres DB_PASS=secret DB_NAME=bfstop_test + tests/ci/install-joomla.sh + phpunit -c tests/phpunit.xml.dist + +`DB_TYPE` is `mysqli` for MySQL/MariaDB. The tests empty the bfstop tables +and change the plugin's settings (restoring them afterwards), so don't point +`JOOMLA_ROOT` at a site you care about. diff --git a/tests/Unit/IpHelperTest.php b/tests/Unit/IpHelperTest.php new file mode 100644 index 0000000..0a23859 --- /dev/null +++ b/tests/Unit/IpHelperTest.php @@ -0,0 +1,58 @@ +assertTrue(IpHelper::isInSubnet('198.51.100.200', '198.51.100.0/24')); + $this->assertFalse(IpHelper::isInSubnet('198.51.101.1', '198.51.100.0/24')); + $this->assertTrue(IpHelper::isInSubnet('203.0.113.5', '203.0.113.4/31')); + $this->assertFalse(IpHelper::isInSubnet('203.0.113.6', '203.0.113.4/31')); + $this->assertTrue(IpHelper::isInSubnet('203.0.113.5', '203.0.113.5/32')); + $this->assertFalse(IpHelper::isInSubnet('203.0.113.6', '203.0.113.5/32')); + $this->assertTrue(IpHelper::isInSubnet('192.0.2.1', '0.0.0.0/0')); + // subnet address not aligned to the prefix length + $this->assertTrue(IpHelper::isInSubnet('198.51.100.7', '198.51.100.99/24')); + } + + public function testIPv6Subnets() + { + $this->assertTrue(IpHelper::isInSubnet('2001:db8:ab:1::5', '2001:DB8:AB::/48')); + $this->assertFalse(IpHelper::isInSubnet('2001:db8:ac::1', '2001:db8:ab::/48')); + $this->assertTrue(IpHelper::isInSubnet('2001:db8::1', '2001:db8::/32')); + $this->assertTrue(IpHelper::isInSubnet('2001:db8::1', '2001:db8::1/128')); + $this->assertFalse(IpHelper::isInSubnet('2001:db8::2', '2001:db8::1/128')); + $this->assertTrue(IpHelper::isInSubnet('2001:db8:0:7fff::1', '2001:db8::/49')); + $this->assertFalse(IpHelper::isInSubnet('2001:db8:0:8000::1', '2001:db8::/49')); + } + + public function testMalformedNeverMatches() + { + // prefix length out of range, not a number, missing, or too long + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/33')); + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/99')); + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/abc')); + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/')); + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/-1')); + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/0024')); + $this->assertFalse(IpHelper::isInSubnet('2001:db8::1', '2001:db8::/129')); + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0')); + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '192.0.2.0/24/8')); + // unparseable addresses + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', 'foo/24')); + $this->assertFalse(IpHelper::isInSubnet('', '192.0.2.0/24')); + // IPv4 vs. IPv6 never match each other + $this->assertFalse(IpHelper::isInSubnet('192.0.2.1', '::/0')); + $this->assertFalse(IpHelper::isInSubnet('2001:db8::1', '0.0.0.0/0')); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..022ae23 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,78 @@ +alias($alias, 'session.cli'); + } + $app = $container->get(SiteApplication::class); + Factory::$application = $app; + $app->createExtensionNamespaceMap(); + // as in a real request, the language is loaded before any plugin (plugins + // only load their own language strings if there already is one) + $app->loadLanguage(); +} + +$sourceNamespaces = array( + 'Codeling\\Plugin\\System\\Bfstop\\' => dirname(__DIR__).'/src/', +); +$comRoot = getenv('COM_BFSTOP_ROOT'); +if ($comRoot) +{ + $comRoot = realpath($comRoot); + $sourceNamespaces['Codeling\\Component\\Bfstop\\Administrator\\'] = $comRoot.'/admin/src/'; + $sourceNamespaces['Codeling\\Component\\Bfstop\\Site\\'] = $comRoot.'/site/src/'; +} +$sourceNamespaces['Codeling\\Bfstop\\Tests\\'] = __DIR__.'/'; +spl_autoload_register(function ($class) use ($sourceNamespaces) +{ + foreach ($sourceNamespaces as $prefix => $dir) + { + if (strncmp($class, $prefix, strlen($prefix)) === 0) + { + $file = $dir.str_replace('\\', '/', substr($class, strlen($prefix))).'.php'; + if (is_file($file)) + { + require $file; + return; + } + } + } +}, true, true); diff --git a/tests/ci/install-joomla.sh b/tests/ci/install-joomla.sh new file mode 100755 index 0000000..30d30f4 --- /dev/null +++ b/tests/ci/install-joomla.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Sets up a Joomla site with bfstop installed, for the integration tests: +# downloads the Joomla full package (unless $JOOMLA_ROOT already contains a +# not yet installed Joomla), runs Joomla's CLI installer against the given +# database, and installs the plugin (and, if $COM_BFSTOP_ROOT is set, the +# component) from the source checkouts through Joomla's extension installer. +# +# Environment: +# JOOMLA_VERSION Joomla version to download, e.g. 5.4.8 +# JOOMLA_ROOT directory to set up the site in +# DB_TYPE mysqli or pgsql +# DB_HOST, DB_USER, DB_PASS, DB_NAME database connection (database must exist) +# COM_BFSTOP_ROOT optional: checkout of https://github.com/codeling/com_bfstop +set -euo pipefail + +: "${JOOMLA_ROOT:?}" "${DB_TYPE:?}" "${DB_HOST:?}" "${DB_USER:?}" "${DB_NAME:?}" +plugin_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +build_dir="$(mktemp -d)" + +if [ ! -f "$JOOMLA_ROOT/index.php" ]; then + : "${JOOMLA_VERSION:?}" + echo "Downloading Joomla $JOOMLA_VERSION" + mkdir -p "$JOOMLA_ROOT" + curl -fsSL "https://github.com/joomla/joomla-cms/releases/download/$JOOMLA_VERSION/Joomla_$JOOMLA_VERSION-Stable-Full_Package.tar.gz" \ + | tar -xz -C "$JOOMLA_ROOT" +fi + +echo "Installing Joomla ($DB_TYPE on $DB_HOST)" +# the admin username deliberately differs from "admin" only in case, see +# ComponentTest::testWarnsAboutAdminUserCaseInsensitively +php "$JOOMLA_ROOT/installation/joomla.php" install -n \ + --site-name="bfstop tests" \ + --admin-user="Test Admin" --admin-username=Admin \ + --admin-password=bfstop-test-password --admin-email=admin@example.org \ + --db-type="$DB_TYPE" --db-host="$DB_HOST" --db-user="$DB_USER" \ + --db-pass="${DB_PASS:-}" --db-name="$DB_NAME" --db-prefix=jos_ \ + --db-encryption=0 --public-folder="" + +install_extension() { + local src="$1" zip="$build_dir/$2.zip" + (cd "$src" && zip -qr "$zip" . -x '.git/*' '.github/*' 'tests/*') + echo "Installing $2" + php "$JOOMLA_ROOT/cli/joomla.php" extension:install --path="$zip" -n +} + +install_extension "$plugin_root" plg_system_bfstop +if [ -n "${COM_BFSTOP_ROOT:-}" ]; then + install_extension "$COM_BFSTOP_ROOT" com_bfstop +fi +rm -rf "$build_dir" diff --git a/tests/ci/start-database.sh b/tests/ci/start-database.sh new file mode 100755 index 0000000..918f54f --- /dev/null +++ b/tests/ci/start-database.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# Starts a database server in a docker container named "db" (for CI) and +# waits until it accepts connections. Writes DB_TYPE and DB_USER for +# install-joomla.sh to $GITHUB_ENV (if set). +# +# Environment: +# DB_IMAGE docker image, e.g. mysql:8.4, mariadb:11.4 or postgres:17 +# DB_HOST, DB_PASS, DB_NAME +set -euo pipefail + +: "${DB_IMAGE:?}" "${DB_HOST:?}" "${DB_PASS:?}" "${DB_NAME:?}" + +case "$DB_IMAGE" in + postgres:*) + docker run -d --name db -p 5432:5432 -e POSTGRES_PASSWORD="$DB_PASS" -e POSTGRES_DB="$DB_NAME" "$DB_IMAGE" + db_type=pgsql + db_user=postgres + ready() { PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U postgres -d "$DB_NAME" -c 'SELECT 1' > /dev/null 2>&1; } + ;; + mysql:*|mariadb:*) + docker run -d --name db -p 3306:3306 -e MYSQL_ROOT_PASSWORD="$DB_PASS" -e MYSQL_DATABASE="$DB_NAME" "$DB_IMAGE" + db_type=mysqli + db_user=root + ready() { mysql -h "$DB_HOST" -uroot -p"$DB_PASS" -e 'SELECT 1' "$DB_NAME" > /dev/null 2>&1; } + ;; + *) + echo "Unsupported database image: $DB_IMAGE" >&2 + exit 1 + ;; +esac + +if [ -n "${GITHUB_ENV:-}" ]; then + echo "DB_TYPE=$db_type" >> "$GITHUB_ENV" + echo "DB_USER=$db_user" >> "$GITHUB_ENV" +fi + +for _ in $(seq 90); do + if ready; then + echo "Database ready (DB_TYPE=$db_type, DB_USER=$db_user)" + exit 0 + fi + sleep 2 +done +docker logs db +exit 1 diff --git a/tests/phpunit.xml.dist b/tests/phpunit.xml.dist new file mode 100644 index 0000000..5752431 --- /dev/null +++ b/tests/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + Unit + + + Integration + + + diff --git a/unittests/cryptotest.php b/unittests/cryptotest.php deleted file mode 100644 index 54813d7..0000000 --- a/unittests/cryptotest.php +++ /dev/null @@ -1,56 +0,0 @@ -message = $msg; - $logMsg->level = $lvl; - $this->logMessages[] = $logMsg; - } -} - -class BFStopTokenGeneratorTest extends PHPUnit_Framework_TestCase -{ - public function testGenerate() { - $testlogger = new TestLogger; - $token = BFStopTokenGenerator::getToken($testlogger); - printf("Generated Token: %s", $token); - $this->assertEquals(strlen($token), 40); - $this->assertTrue(ctype_xdigit($token)); - - if (function_exists('openssl_random_pseudo_bytes') || - (function_exists('mcrypt_create_iv') && - (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' || - version_compare(phpversion(), '5.3.7') > 0) ) ) { - $this->assertEquals(sizeof($testlogger->logMessages), 1); - $this->assertEquals($testlogger->logMessages[0]->level, Log::VERBOSE); - } else { - $this->assertEquals(sizeof($testlogger->logMessages), 1); - $this->assertEquals($testlogger->logMessages[0]->level, Log::WARNING); - } - } -} diff --git a/updatescript.php b/updatescript.php index 6d7d37c..114cf93 100644 --- a/updatescript.php +++ b/updatescript.php @@ -51,21 +51,25 @@ public function postflight($type, InstallerAdapter $adapter) public function update(InstallerAdapter $adapter) { // for version 1.4.2, whitelist was renamed to allowlist, but only for updates; - // for new installs, the old name remained, so let's fix this for all installations: + // for new installs, the old name remained, so let's fix this for all installations + // (MySQL only - PostgreSQL is supported from 2.0.0 on, so no such table exists there): $db = Factory::getDbo(); - try + if ($db->getServerType() === 'mysql') { - $sql = "SELECT COUNT(*) FROM `#__bfstop_whitelist`"; - $db->setQuery($sql); - $numEntries = ((int)$db->loadResult()); - $sql = "RENAME TABLE `#__bfstop_whitelist` TO `#__bfstop_allowlist`"; - $db->setQuery($sql); - $db->execute(); - } - catch (Exception $e) - { - // if table doesn't exist, there's nothing we need to do -// Log::add("Update ERROR: ".$e->getMessage(), Log::ERROR, 'Update'); + try + { + $sql = "SELECT COUNT(*) FROM `#__bfstop_whitelist`"; + $db->setQuery($sql); + $numEntries = ((int)$db->loadResult()); + $sql = "RENAME TABLE `#__bfstop_whitelist` TO `#__bfstop_allowlist`"; + $db->setQuery($sql); + $db->execute(); + } + catch (Exception $e) + { + // if table doesn't exist, there's nothing we need to do +// Log::add("Update ERROR: ".$e->getMessage(), Log::ERROR, 'Update'); + } } // for 2.0.0, the previously separate blockEnabled/useHtaccess switches